Files
Firelink/src-tauri/src/queue.rs
T

9338 lines
350 KiB
Rust

use base64::Engine as _;
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
use crate::power::PowerManager;
use crate::retry::{
backoff_and_emit, is_aria2_name_resolution_error, is_transient_network_error,
BackoffOutcome, MAX_RETRIES,
};
use log;
use serde::Deserialize;
use serde_json;
use std::collections::{HashMap, HashSet, VecDeque};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager};
use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
use ts_rs::TS;
/// Default capacity when no setting is read yet.
pub const DEFAULT_MAX_CONCURRENT: usize = 3;
pub const MAX_QUEUE_CONCURRENT: usize = 12;
pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__";
pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1;
pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16;
pub const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB: u64 = 1024;
pub const MAX_TORRENT_TRACKER_TIMEOUT: u32 = 604_800;
pub const MAX_TORRENT_TRACKER_INTERVAL: u32 = 604_800;
pub const DEFAULT_TORRENT_MAX_OPEN_FILES: u32 = 100;
pub const MIN_TORRENT_MAX_OPEN_FILES: u32 = 1;
pub const MAX_TORRENT_MAX_OPEN_FILES: u32 = 4_096;
pub const DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 10;
pub const MIN_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 1;
pub const MAX_TORRENT_DHT_MESSAGE_TIMEOUT: u32 = 600;
pub const DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 2;
pub const MIN_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 1;
pub const MAX_TORRENT_MAX_CONCURRENT_SEEDS: u32 = 64;
pub const MAX_TORRENT_NETWORK_VALUE_LENGTH: usize = 256;
pub const MAX_TORRENT_PEER_ID_PREFIX_BYTES: usize = 20;
pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128;
pub const MAX_TORRENT_PIECES_FOR_PROGRESS: u64 = 10_000_000;
pub const MAX_TORRENT_AVAILABILITY_PEERS: usize = 4_096;
/// Poller gaps beyond this bounded interval are treated conservatively. In
/// particular, a suspended machine must not accrue wall-clock seed time.
pub const MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS: u64 = 5;
pub const MAX_TORRENT_WEB_SEEDS: usize = 256;
pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048;
pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024;
pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999";
pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
pub const MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB: u32 = 1_048_576;
pub fn normalize_minimum_normal_download_speed_kib(value: u32) -> Result<u32, String> {
if value > MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB {
return Err(format!(
"minimum normal download speed must be between 0 and {MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB} KiB/s"
));
}
Ok(value)
}
pub fn normalize_sftp_host_key_md(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let (kind, digest) = value
.split_once('=')
.ok_or_else(|| "SFTP host-key fingerprint must use TYPE=DIGEST form".to_string())?;
let kind = kind.trim().to_ascii_lowercase();
let digest = digest.trim().to_ascii_lowercase();
let expected_length = match kind.as_str() {
"md5" => 32,
"sha-1" => 40,
_ => return Err("SFTP host-key fingerprint type must be md5 or sha-1".to_string()),
};
if digest.len() != expected_length || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(format!(
"SFTP {kind} host-key fingerprint must contain exactly {expected_length} hexadecimal characters"
));
}
Ok(Some(format!("{kind}={digest}")))
}
pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH
|| value.chars().any(char::is_control)
{
return Err("Torrent bind address is too long or contains control characters".to_string());
}
let address = value
.parse::<std::net::IpAddr>()
.map_err(|_| "Torrent bind address must be a valid IPv4 or IPv6 address".to_string())?;
Ok(Some(address.to_string()))
}
pub fn normalize_aria2_disk_cache(value: Option<&str>) -> Result<String, String> {
let value = value.map(str::trim).filter(|value| !value.is_empty()).unwrap_or(DEFAULT_ARIA2_DISK_CACHE);
if value == "0" {
return Ok("0".to_string());
}
let (digits, multiplier, suffix) = match value.as_bytes().last().copied() {
Some(b'k' | b'K') => (&value[..value.len() - 1], 1_u64, "K"),
Some(b'm' | b'M') => (&value[..value.len() - 1], 1024_u64, "M"),
_ => return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string()),
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string());
}
let amount = digits
.parse::<u64>()
.map_err(|_| "Aria2 disk cache is too large".to_string())?;
let kib = amount
.checked_mul(multiplier)
.ok_or_else(|| "Aria2 disk cache is too large".to_string())?;
if kib == 0 || kib > MAX_ARIA2_DISK_CACHE_MIB * 1024 {
return Err(format!(
"Aria2 disk cache must be between 1K and {MAX_ARIA2_DISK_CACHE_MIB}M"
));
}
Ok(format!("{amount}{suffix}"))
}
pub fn normalize_torrent_file_allocation(value: Option<&str>) -> Result<String, String> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok("prealloc".to_string()),
Some("prealloc") => Ok("prealloc".to_string()),
Some("none") => Ok("none".to_string()),
Some(_) => Err("Torrent file allocation must be prealloc or none".to_string()),
}
}
pub fn clamp_download_connections(connections: i32) -> i32 {
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
}
pub fn normalize_torrent_max_open_files(value: u32) -> Result<u32, String> {
if !(MIN_TORRENT_MAX_OPEN_FILES..=MAX_TORRENT_MAX_OPEN_FILES).contains(&value) {
return Err(format!(
"torrent maximum open files must be between {MIN_TORRENT_MAX_OPEN_FILES} and {MAX_TORRENT_MAX_OPEN_FILES}"
));
}
Ok(value)
}
pub fn normalize_torrent_dht_message_timeout(value: u32) -> Result<u32, String> {
if !(MIN_TORRENT_DHT_MESSAGE_TIMEOUT..=MAX_TORRENT_DHT_MESSAGE_TIMEOUT).contains(&value) {
return Err(format!(
"DHT message timeout must be between {MIN_TORRENT_DHT_MESSAGE_TIMEOUT} and {MAX_TORRENT_DHT_MESSAGE_TIMEOUT} seconds"
));
}
Ok(value)
}
fn normalize_torrent_web_seed_uri(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value.len() > MAX_TORRENT_WEB_SEED_URI_LENGTH {
return Err(format!(
"Torrent web-seed URIs must be between 1 and {MAX_TORRENT_WEB_SEED_URI_LENGTH} bytes"
));
}
if value.chars().any(char::is_control) {
return Err("Torrent web-seed URIs must not contain control characters".to_string());
}
let parsed = url::Url::parse(value).map_err(|_| "Torrent web-seed URI is invalid".to_string())?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err("Torrent web-seed URI must use HTTP or HTTPS".to_string());
}
if parsed.host_str().is_none_or(str::is_empty)
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
{
return Err("Torrent web-seed URI must have a host and no credentials or fragment".to_string());
}
Ok(parsed.to_string())
}
pub fn normalize_torrent_web_seeds(
seeds: Option<&[crate::ipc::TorrentWebSeed]>,
files: &[crate::ipc::TorrentFile],
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let Some(seeds) = seeds else {
return Ok(Vec::new());
};
if seeds.len() > MAX_TORRENT_WEB_SEEDS {
return Err(format!("a Torrent may have at most {MAX_TORRENT_WEB_SEEDS} web seeds"));
}
let mut normalized = Vec::with_capacity(seeds.len());
let mut seen = HashSet::new();
for seed in seeds {
if !files.iter().any(|file| file.index == seed.file_index) {
return Err(format!("Torrent web-seed file index {} is out of range", seed.file_index));
}
let uri = normalize_torrent_web_seed_uri(&seed.uri)?;
if seen.insert((seed.file_index, uri.clone())) {
normalized.push(crate::ipc::TorrentWebSeed {
file_index: seed.file_index,
uri,
});
}
}
Ok(normalized)
}
fn expand_torrent_web_seed_uri(
seed: &crate::ipc::TorrentWebSeed,
files: &[crate::ipc::TorrentFile],
) -> Result<String, String> {
let uri = normalize_torrent_web_seed_uri(&seed.uri)?;
if files.len() <= 1 {
return Ok(uri);
}
let file = files
.iter()
.find(|file| file.index == seed.file_index)
.ok_or_else(|| "Torrent web-seed file index is out of range".to_string())?;
let mut parsed = url::Url::parse(&uri).map_err(|_| "Torrent web-seed URI is invalid".to_string())?;
let base_path = parsed.path().trim_end_matches('/').to_string();
parsed.set_path(&base_path);
{
let mut path = parsed
.path_segments_mut()
.map_err(|_| "Torrent web-seed URI cannot accept a file path".to_string())?;
for segment in file.path.replace('\\', "/").split('/') {
if !segment.is_empty() && segment != "." && segment != ".." {
path.push(segment);
}
}
}
Ok(parsed.to_string())
}
pub fn expand_torrent_web_seeds(
seeds: &[crate::ipc::TorrentWebSeed],
files: &[crate::ipc::TorrentFile],
) -> Result<Vec<(u32, String)>, String> {
let normalized = normalize_torrent_web_seeds(Some(seeds), files)?;
normalized
.iter()
.map(|seed| Ok((seed.file_index, expand_torrent_web_seed_uri(seed, files)?)))
.collect()
}
fn parse_aria2_web_seed_uris(value: &serde_json::Value) -> Result<Vec<String>, String> {
let entries = value
.as_array()
.ok_or_else(|| "aria2.getUris returned a non-array result".to_string())?;
let mut uris = Vec::with_capacity(entries.len());
let mut seen = HashSet::new();
for entry in entries {
let uri = entry
.get("uri")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "aria2.getUris returned a malformed URI entry".to_string())?;
let uri = normalize_torrent_web_seed_uri(uri)?;
if seen.insert(uri.clone()) {
uris.push(uri);
}
}
Ok(uris)
}
pub fn normalize_torrent_max_concurrent_seeds(value: u32) -> Result<u32, String> {
if !(MIN_TORRENT_MAX_CONCURRENT_SEEDS..=MAX_TORRENT_MAX_CONCURRENT_SEEDS).contains(&value) {
return Err(format!(
"maximum concurrent Torrent seeds must be between {MIN_TORRENT_MAX_CONCURRENT_SEEDS} and {MAX_TORRENT_MAX_CONCURRENT_SEEDS}"
));
}
Ok(value)
}
fn normalize_optional_torrent_network_value(
value: Option<&str>,
field: &str,
) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH
|| value.chars().any(|character| character.is_control())
{
return Err(format!("{field} is too long or contains control characters"));
}
Ok(Some(value.to_string()))
}
fn normalize_torrent_port(value: &str, field: &str) -> Result<u16, String> {
let port = value
.trim()
.parse::<u32>()
.map_err(|_| format!("{field} contains an invalid port"))?;
if !(1..=u16::MAX as u32).contains(&port) {
return Err(format!("{field} ports must be between 1 and 65535"));
}
Ok(port as u16)
}
fn normalize_torrent_listen_port(value: &str, field: &str) -> Result<u16, String> {
let port = normalize_torrent_port(value, field)?;
if port < MIN_TORRENT_LISTEN_PORT {
return Err(format!(
"{field} ports must be between {MIN_TORRENT_LISTEN_PORT} and 65535"
));
}
Ok(port)
}
pub(crate) fn normalize_torrent_port_spec(
value: Option<&str>,
field: &str,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, field)? else {
return Ok(None);
};
let mut normalized = Vec::new();
for entry in value.split(',') {
let entry = entry.trim();
if entry.is_empty() {
return Err(format!("{field} contains an empty port entry"));
}
if let Some((start, end)) = entry.split_once('-') {
let start = normalize_torrent_listen_port(start, field)?;
let end = normalize_torrent_listen_port(end, field)?;
if start > end {
return Err(format!("{field} contains a reversed port range"));
}
normalized.push(format!("{start}-{end}"));
} else {
normalized.push(normalize_torrent_listen_port(entry, field)?.to_string());
}
}
Ok(Some(normalized.join(",")))
}
pub(crate) fn torrent_port_spec_contains(spec: &str, port: u16) -> bool {
spec.split(',').any(|entry| {
let entry = entry.trim();
if let Some((start, end)) = entry.split_once('-') {
matches!((start.parse::<u16>(), end.parse::<u16>()), (Ok(start), Ok(end)) if start <= port && port <= end)
} else {
entry.parse::<u16>() == Ok(port)
}
})
}
pub(crate) fn normalize_torrent_external_ip(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "Torrent external IP")?
else {
return Ok(None);
};
let address = value
.parse::<std::net::IpAddr>()
.map_err(|_| "Torrent external IP must be a valid IPv4 or IPv6 address".to_string())?;
Ok(Some(address.to_string()))
}
pub(crate) fn normalize_torrent_dht_entry_point(
value: Option<&str>,
ipv6: bool,
) -> Result<Option<String>, String> {
let field = if ipv6 {
"IPv6 DHT entry point"
} else {
"IPv4 DHT entry point"
};
let Some(value) = normalize_optional_torrent_network_value(value, field)? else {
return Ok(None);
};
let (host, port) = if let Some(rest) = value.strip_prefix('[') {
let (host, port) = rest
.split_once(']')
.and_then(|(host, suffix)| suffix.strip_prefix(':').map(|port| (host, port)))
.ok_or_else(|| format!("{field} must use host:port syntax"))?;
if host.parse::<std::net::Ipv6Addr>().is_err() {
return Err(format!("{field} has an invalid IPv6 host"));
}
(format!("[{host}]"), normalize_torrent_port(port, field)?)
} else {
let (host, port) = value
.rsplit_once(':')
.ok_or_else(|| format!("{field} must use host:port syntax"))?;
if host.is_empty() || host.contains(':') || host.contains(['/', '\\', '@']) {
return Err(format!("{field} has an invalid host"));
}
if url::Host::parse(host).is_err() {
return Err(format!("{field} has an invalid host"));
}
(host.to_ascii_lowercase(), normalize_torrent_port(port, field)?)
};
if ipv6 && !host.starts_with('[') {
return Err(format!("{field} must use an IPv6 host"));
}
if !ipv6 && host.starts_with('[') {
return Err(format!("{field} must use an IPv4 or hostname host"));
}
Ok(Some(format!("{host}:{port}")))
}
pub(crate) fn normalize_torrent_dht_listen_addr6(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "IPv6 DHT listen address")?
else {
return Ok(None);
};
let address = value
.parse::<std::net::Ipv6Addr>()
.map_err(|_| "IPv6 DHT listen address must be a valid IPv6 address".to_string())?;
Ok(Some(address.to_string()))
}
pub(crate) fn normalize_torrent_lpd_interface(
value: Option<&str>,
) -> Result<Option<String>, String> {
normalize_optional_torrent_network_value(value, "Torrent LPD interface")
}
pub(crate) fn normalize_torrent_peer_id_prefix(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer ID prefix")?
else {
return Ok(None);
};
if !value.is_ascii() || value.len() > MAX_TORRENT_PEER_ID_PREFIX_BYTES {
return Err(format!(
"Torrent peer ID prefix must be printable ASCII and at most {MAX_TORRENT_PEER_ID_PREFIX_BYTES} bytes"
));
}
Ok(Some(value))
}
pub(crate) fn normalize_torrent_peer_agent(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(value) = normalize_optional_torrent_network_value(value, "Torrent peer agent")?
else {
return Ok(None);
};
if value.len() > MAX_TORRENT_PEER_AGENT_LENGTH {
return Err(format!(
"Torrent peer agent must be at most {MAX_TORRENT_PEER_AGENT_LENGTH} bytes"
));
}
Ok(Some(value))
}
fn reorder_selected_queue_tasks(
queue_tasks: &[QueuedTask],
ids: &[String],
target_index: usize,
) -> Option<Vec<QueuedTask>> {
let selected_ids = ids.iter().collect::<HashSet<_>>();
let selected_tasks = queue_tasks
.iter()
.filter(|task| selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
if selected_tasks.is_empty() {
return None;
}
let unselected_tasks = queue_tasks
.iter()
.filter(|task| !selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
let insert_index = target_index.min(unselected_tasks.len());
let mut reordered = Vec::with_capacity(queue_tasks.len());
reordered.extend_from_slice(&unselected_tasks[..insert_index]);
reordered.extend(selected_tasks);
reordered.extend_from_slice(&unselected_tasks[insert_index..]);
Some(reordered)
}
fn reorder_selected_queue_tasks_in_order(
queue_tasks: &[QueuedTask],
ids: &[String],
target_index: usize,
) -> Option<Vec<QueuedTask>> {
let selected_ids = ids.iter().collect::<HashSet<_>>();
let mut seen_ids = HashSet::new();
let selected_tasks = ids
.iter()
.filter(|id| seen_ids.insert(*id))
.filter_map(|id| queue_tasks.iter().find(|task| task.id == *id))
.cloned()
.collect::<Vec<_>>();
if selected_tasks.is_empty() {
return None;
}
let unselected_tasks = queue_tasks
.iter()
.filter(|task| !selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
let insert_index = target_index.min(unselected_tasks.len());
let mut reordered = Vec::with_capacity(queue_tasks.len());
reordered.extend_from_slice(&unselected_tasks[..insert_index]);
reordered.extend(selected_tasks);
reordered.extend_from_slice(&unselected_tasks[insert_index..]);
Some(reordered)
}
type Aria2ControlLocks = Arc<StdMutex<HashMap<String, Arc<Mutex<()>>>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Aria2GidMapping {
pub id: String,
pub epoch: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TorrentTelemetrySnapshot {
pub uploaded_bytes: u64,
pub seeded_seconds: u64,
}
#[derive(Debug, Clone)]
struct TorrentTelemetryState {
gid: String,
epoch: u64,
last_upload_length: Option<u64>,
uploaded_bytes: u64,
seeded_seconds: u64,
last_observed_at: Option<Instant>,
last_was_seeding: bool,
}
impl TorrentTelemetryState {
fn new(gid: &str, epoch: u64, now: Instant) -> Self {
Self {
gid: gid.to_string(),
epoch,
last_upload_length: None,
uploaded_bytes: 0,
seeded_seconds: 0,
last_observed_at: Some(now),
last_was_seeding: false,
}
}
fn snapshot(&self) -> TorrentTelemetrySnapshot {
TorrentTelemetrySnapshot {
uploaded_bytes: self.uploaded_bytes,
seeded_seconds: self.seeded_seconds,
}
}
fn observe(
&mut self,
gid: &str,
epoch: u64,
upload_length: Option<u64>,
is_seeding: bool,
now: Instant,
) -> TorrentTelemetrySnapshot {
if self.gid != gid || self.epoch != epoch {
// A new GID or control epoch is a new daemon counter lifecycle.
// Preserve Firelink totals, but never interpret the new counter
// as a continuation of the old one.
if self.last_was_seeding {
if let Some(previous) = self.last_observed_at {
let seconds = now
.saturating_duration_since(previous)
.min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS))
.as_secs();
self.seeded_seconds = self.seeded_seconds.saturating_add(seconds);
}
}
self.gid = gid.to_string();
self.epoch = epoch;
self.last_upload_length = None;
self.last_observed_at = Some(now);
self.last_was_seeding = false;
} else if self.last_was_seeding {
if let Some(previous) = self.last_observed_at {
let seconds = now
.saturating_duration_since(previous)
.min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS))
.as_secs();
self.seeded_seconds = self.seeded_seconds.saturating_add(seconds);
}
}
if let Some(current) = upload_length {
if let Some(previous) = self.last_upload_length {
if current >= previous {
self.uploaded_bytes = self
.uploaded_bytes
.saturating_add(current.saturating_sub(previous));
}
// A decreased Aria2 counter is a daemon/lifecycle reset. The
// current value becomes the new baseline, without adding it.
}
self.last_upload_length = Some(current);
}
self.last_observed_at = Some(now);
self.last_was_seeding = is_seeding;
self.snapshot()
}
}
/// Owns one per-download control lock and removes its idle map entry when the
/// last operation for that download finishes.
pub struct Aria2ControlGuard {
locks: Aria2ControlLocks,
id: String,
lock: Arc<Mutex<()>>,
guard: Option<OwnedMutexGuard<()>>,
}
impl Drop for Aria2ControlGuard {
fn drop(&mut self) {
// Release the async mutex before inspecting Arc ownership. The map
// entry and this guard are then the only strong references when no
// waiter is pending, so the entry can be removed safely.
self.guard.take();
let mut locks = self.locks.lock().unwrap_or_else(|error| error.into_inner());
let should_remove = locks.get(&self.id).is_some_and(|candidate| {
Arc::ptr_eq(candidate, &self.lock) && Arc::strong_count(&self.lock) == 2
});
if should_remove {
locks.remove(&self.id);
}
}
}
/// Outcome of an aria2 completion that arrived before its gid was stored.
/// Carries the outcome so the correct state emit survives the race.
#[derive(Debug, Clone)]
pub enum PendingOutcome {
Complete,
/// BitTorrent payload is complete, but Aria2 is still seeding. Keep the
/// GID, ownership record, and queue permit until the final complete
/// notification arrives.
Seeding,
Error(String),
}
/// Result of recycling an aria2 transfer's connections. A refresh can race
/// with daemon completion or leave the transfer paused after an ambiguous
/// unpause failure, so callers must handle the verified daemon outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Aria2RefreshOutcome {
Resumed,
Paused,
Complete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Aria2SeedControlOutcome {
Resumed,
Paused,
Complete,
}
/// Result of rebuilding an aria2 job while retaining its partial file and
/// queue permit. `Refresh` is the compatibility path for test/alternate
/// spawners that do not own aria2's addUri options.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Aria2RecreateOutcome {
NewGid(String),
Complete,
Refresh,
/// The old daemon job is gone and the replacement could not be created.
/// The queue manager must retire the stale mapping and surface a paused
/// lifecycle so the next user resume can enqueue a fresh job.
Unavailable(String),
}
fn aria2_recovery_should_rebuild_after_pause_error(status: &str) -> bool {
status == "removed"
}
/// What kind of sidecar a queued task spawns. Drives which runner the
/// dispatcher invokes.
#[derive(Debug, Clone)]
pub enum TaskKind {
Aria2,
Media,
}
/// Everything needed to start a sidecar, captured at enqueue time so the
/// dispatcher can spawn it later without round-tripping back to the frontend.
#[derive(Debug, Clone)]
pub struct QueuedTask {
pub id: String,
pub queue_id: String,
pub kind: TaskKind,
pub payload: SpawnPayload,
/// Frontend lifecycle generation that owns this sidecar and its permit.
/// Manual internal/test tasks use generation 0.
pub lifecycle_generation: u64,
}
#[derive(Debug, Clone)]
struct QueuePermitOwnership {
queue_id: String,
lifecycle_generation: u64,
active: bool,
}
#[derive(Debug, Clone)]
struct SeedWaiter {
id: String,
queue_id: String,
lifecycle_generation: u64,
}
#[derive(Debug, Default)]
struct SeedCapacityState {
enabled: bool,
max_concurrent: usize,
owners: HashSet<String>,
waiting: VecDeque<SeedWaiter>,
starting: HashSet<String>,
}
#[derive(Debug, Clone, Copy)]
struct SeedBudget {
remaining_minutes: Option<f64>,
started_at: std::time::Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SeedAdmissionOutcome {
Seeding,
Waiting,
Complete,
}
/// Args mirroring start_download / start_media_download. Kept untyped-loose
/// (String/Option) to match the existing command signatures exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Aria2ResolverMode {
/// Use Aria2's normal resolver configuration. This is the initial mode
/// and deliberately leaves daemon-wide behavior unchanged.
Automatic,
/// Use the host operating system resolver for this transfer.
System,
}
impl Default for Aria2ResolverMode {
fn default() -> Self {
Self::Automatic
}
}
#[derive(Debug, Clone, Default)]
pub struct SpawnPayload {
pub url: String,
pub destination: String,
pub filename: String,
pub connections: Option<i32>,
pub speed_limit: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub sftp_host_key_md: Option<String>,
pub headers: Option<String>,
pub checksum: Option<String>,
pub cookies: Option<String>,
pub mirrors: Option<String>,
pub user_agent: Option<String>,
pub max_tries: Option<i32>,
pub minimum_normal_download_speed_kib: u32,
pub retry_not_found_errors: bool,
pub adaptive_mirror_selection: bool,
pub proxy: Option<String>,
/// Runtime-only resolver selection. This is never part of an enqueue or
/// persisted download payload.
pub aria2_resolver_mode: Aria2ResolverMode,
pub format_selector: Option<String>,
pub cookie_source: Option<String>,
pub is_media: bool,
pub is_torrent: bool,
pub torrent_path: Option<String>,
pub torrent_file_indices: Option<Vec<u32>>,
pub torrent_seed_time: Option<f64>,
pub torrent_seed_ratio: Option<f64>,
pub torrent_seed_remaining: Option<f64>,
pub torrent_web_seeds: Option<Vec<crate::ipc::TorrentWebSeed>>,
pub torrent_upload_limit: Option<String>,
pub torrent_max_peers: Option<u32>,
pub torrent_peer_speed_limit: Option<String>,
pub torrent_check_integrity: bool,
pub torrent_trackers: Option<String>,
pub torrent_exclude_trackers: Option<String>,
pub torrent_tracker_connect_timeout: Option<u32>,
pub torrent_tracker_timeout: Option<u32>,
pub torrent_tracker_interval: Option<u32>,
pub torrent_stop_timeout: Option<u32>,
pub torrent_prioritize_piece: Option<String>,
pub torrent_remove_unselected_file: bool,
pub torrent_encryption_policy: Option<String>,
pub torrent_file_allocation: Option<String>,
pub torrent_verify_only: bool,
pub torrent_verify_restore_status: Option<String>,
pub torrent_verified_length: Option<u64>,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
/// runners; in tests it is replaced with a fake that records calls and
/// optionally hangs to simulate a long-running download.
#[async_trait::async_trait]
pub trait SidecarSpawner: Send + Sync + 'static {
/// Spawn an aria2 download. Returns the gid. Must return quickly (the
/// permit is already parked before this is called).
async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result<String, String>;
/// Force-remove an aria2 gid created by a retry that raced with user
/// cancellation.
async fn remove_uri(&self, gid: &str) -> Result<(), String>;
/// Recycle the connections for an active aria2 transfer without changing
/// its gid or releasing its queue permit. Production uses forcePause /
/// unpause; test spawners can leave this unsupported.
async fn refresh_uri(&self, _gid: &str) -> Result<Aria2RefreshOutcome, String> {
Err("aria2 connection refresh is unavailable".to_string())
}
/// Rebuild an active aria2 job with the current payload/options. The
/// partial file remains resumable because the production addUri path uses
/// continue + always-resume. Alternate spawners may return `Refresh` to
/// retain the older same-GID pause/unpause behavior.
async fn recreate_uri(
&self,
_id: &str,
_gid: &str,
_payload: &SpawnPayload,
) -> Result<Aria2RecreateOutcome, String> {
Ok(Aria2RecreateOutcome::Refresh)
}
/// Change one active aria2 transfer's runtime download cap. Media
/// runners intentionally keep the default implementation: yt-dlp reads
/// its limit only when the process starts.
async fn set_download_speed_limit(
&self,
_gid: &str,
_limit: Option<&str>,
) -> Result<(), String> {
Err("live aria2 speed limits are unavailable".to_string())
}
/// Change one active BitTorrent transfer's runtime upload cap without
/// replacing its GID or queue permit.
async fn set_torrent_upload_limit(
&self,
_gid: &str,
_limit: Option<&str>,
) -> Result<(), String> {
Err("live torrent upload limits are unavailable".to_string())
}
/// Change the peer cap and low-speed peer expansion threshold without
/// replacing the Torrent GID or queue permit.
async fn set_torrent_peer_options(
&self,
_gid: &str,
_max_peers: u32,
_peer_speed_limit: &str,
) -> Result<(), String> {
Err("live torrent peer options are unavailable".to_string())
}
/// Pause a completed Torrent before releasing its download permit to the
/// Firelink-owned seed-slot pool.
async fn pause_for_seed(&self, _gid: &str) -> Result<Aria2SeedControlOutcome, String> {
Err("live Torrent seed-slot pausing is unavailable".to_string())
}
/// Resume a Torrent after a Firelink seed slot has been reserved.
async fn resume_for_seed(&self, _gid: &str) -> Result<Aria2SeedControlOutcome, String> {
Err("live Torrent seed-slot resuming is unavailable".to_string())
}
async fn get_torrent_uris(&self, _gid: &str) -> Result<Vec<String>, String> {
Err("live Torrent web-seed inspection is unavailable".to_string())
}
async fn change_torrent_uris(
&self,
_gid: &str,
_file_index: u32,
_delete: &[String],
_add: &[String],
) -> Result<(), String> {
Err("live Torrent web-seed changes are unavailable".to_string())
}
/// Run a media download to completion. The permit is parked for the full
/// duration; release is handled by QueueManager on the runner's exit.
async fn run_media(
&self,
id: &str,
payload: &SpawnPayload,
lifecycle_generation: u64,
) -> Result<(), String>;
}
/// The centralized concurrency gatekeeper. One instance lives in AppState.
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
registered_ids: Mutex<HashSet<String>>,
registered_lifecycle_generations: Mutex<HashMap<String, u64>>,
enqueue_cancellations: Mutex<HashMap<String, u64>>,
enqueue_generations: Mutex<HashMap<String, u64>>,
pending: Mutex<VecDeque<QueuedTask>>,
semaphore: Arc<Semaphore>,
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
active_permit_generations: Mutex<HashMap<String, u64>>,
active_kinds: Mutex<HashMap<String, TaskKind>>,
/// Queue overrides are stored only for queues with an explicit limit.
/// Missing entries inherit the global target capacity.
queue_limits: Mutex<HashMap<String, usize>>,
/// One entry represents either a queued dispatch reservation or an active
/// transfer. Keeping both phases in one map makes queue-slot ownership
/// exactly-once across the async addUri handoff.
queue_permit_ownership: Mutex<HashMap<String, QueuePermitOwnership>>,
/// Serializes queue-slot selection with global permit acquisition and
/// ownership transitions.
admission_gate: Mutex<()>,
/// Last queue selected by the dispatcher. Selection starts after this
/// queue when multiple queues have eligible work.
dispatch_cursor: Mutex<Option<String>>,
target_capacity: AtomicUsize,
slots_to_retire: AtomicUsize,
notify: Notify,
/// Firelink-owned seed capacity. Seeders keep the Aria2 GID and mapping
/// alive, but release the download semaphore while they own a seed slot.
seed_capacity: StdMutex<SeedCapacityState>,
seed_budgets: StdMutex<HashMap<String, SeedBudget>>,
/// Firelink lifetime Torrent upload/seed accounting. Raw Aria2 counters
/// are scoped to the current GID and control epoch and never leave this
/// process as durable state.
torrent_telemetry: Mutex<HashMap<String, TorrentTelemetryState>>,
torrent_move_cancellations: Mutex<HashSet<String>>,
/// aria2 gid -> download id map (shared with the WS poller).
pub aria2_gids: Arc<std::sync::RwLock<HashMap<String, Aria2GidMapping>>>,
/// gid -> buffered (id_placeholder, outcome) for completions that arrived
/// before the gid was stored. Drained by `remember_gid`.
pub pending_completion: Arc<Mutex<HashMap<String, (String, PendingOutcome)>>>,
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
/// Initial aria2 addUri handoffs that have not yet either published a GID
/// or removed a stale late GID. Removal waits for these handoffs before
/// deleting owned assets so a magnet cannot leave an orphaned output.
aria2_dispatch_inflight: Mutex<HashMap<String, HashSet<u64>>>,
aria2_dispatch_notify: Notify,
/// The daemon-wide download cap currently applied to aria2. This mirrors
/// successful RPC changes so the poller can avoid treating an intentional
/// cap as a degraded connection pool.
aria2_global_speed_limit: Arc<StdMutex<Option<String>>>,
/// Capability reported by aria2.getVersion. Keep the fallback disabled
/// until the daemon explicitly advertises Async DNS; an unknown
/// capability must not be treated as permission to change resolver mode.
aria2_async_dns_supported: AtomicBool,
/// 0-based transient-error strike counter per aria2 download id.
aria2_retry_strikes: Mutex<HashMap<String, usize>>,
/// Download ids whose aria2 retry loop must not create another job.
aria2_retry_cancelled: Mutex<HashSet<String>>,
/// Download ids with a retry worker currently sleeping or re-adding a gid.
/// A duplicate aria2 error event must not create a second worker.
aria2_retry_inflight: Mutex<HashMap<String, u64>>,
/// The gid whose terminal event initiated each in-flight retry.
aria2_retrying_gids: Mutex<HashSet<String>>,
/// Gids whose terminal events must be ignored after a lifecycle transition.
/// This is bounded so a long-lived daemon cannot grow the set indefinitely.
aria2_ignored_gids: Mutex<VecDeque<String>>,
/// Wakes retry backoff workers when a pause/remove action cancels them.
aria2_retry_cancel_notify: Notify,
/// Serializes control RPCs for one download (pause, resume, refresh, and
/// retry handoff) without blocking control operations for other downloads.
aria2_control_locks: Aria2ControlLocks,
/// Serializes GID mapping transitions with early WebSocket event
/// buffering. The RwLock protects individual map access; this lock makes
/// map replacement, ignored-GID retirement, and pending-event draining a
/// single state transition.
aria2_gid_state: Mutex<()>,
/// Monotonic per-download aria2 control generation. Long-running queued
/// resume tasks capture this and abort when a later pause/remove wins.
aria2_control_epochs: Mutex<HashMap<String, u64>>,
/// Backend-owned power policy and active-transfer accounting.
power_manager: Arc<PowerManager>,
spawner: Arc<dyn SidecarSpawner>,
app_handle: AppHandle<R>,
}
impl QueueManager<tauri::Wry> {
/// Production constructor. Wired up in lib.rs setup().
pub fn new(app_handle: AppHandle<tauri::Wry>, capacity: usize) -> Self {
let spawner: Arc<dyn SidecarSpawner> = Arc::new(ProductionSpawner::new(app_handle.clone()));
Self::test_new(app_handle, capacity, spawner)
}
}
impl<R: tauri::Runtime> QueueManager<R> {
/// Test-only constructor injecting a fake spawner.
pub fn test_new(
app_handle: AppHandle<R>,
capacity: usize,
spawner: Arc<dyn SidecarSpawner>,
) -> Self {
Self {
registered_ids: Mutex::new(HashSet::new()),
registered_lifecycle_generations: Mutex::new(HashMap::new()),
enqueue_cancellations: Mutex::new(HashMap::new()),
enqueue_generations: Mutex::new(HashMap::new()),
pending: Mutex::new(VecDeque::new()),
semaphore: Arc::new(Semaphore::new(capacity)),
active_permits: Mutex::new(HashMap::new()),
active_permit_generations: Mutex::new(HashMap::new()),
active_kinds: Mutex::new(HashMap::new()),
queue_limits: Mutex::new(HashMap::new()),
queue_permit_ownership: Mutex::new(HashMap::new()),
admission_gate: Mutex::new(()),
dispatch_cursor: Mutex::new(None),
target_capacity: AtomicUsize::new(capacity),
slots_to_retire: AtomicUsize::new(0),
notify: Notify::new(),
seed_capacity: StdMutex::new(SeedCapacityState {
enabled: false,
max_concurrent: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS as usize,
..SeedCapacityState::default()
}),
seed_budgets: StdMutex::new(HashMap::new()),
torrent_telemetry: Mutex::new(HashMap::new()),
torrent_move_cancellations: Mutex::new(HashSet::new()),
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
pending_completion: Arc::new(Mutex::new(HashMap::new())),
aria2_payloads: Mutex::new(HashMap::new()),
aria2_dispatch_inflight: Mutex::new(HashMap::new()),
aria2_dispatch_notify: Notify::new(),
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
aria2_async_dns_supported: AtomicBool::new(false),
aria2_retry_strikes: Mutex::new(HashMap::new()),
aria2_retry_cancelled: Mutex::new(HashSet::new()),
aria2_retry_inflight: Mutex::new(HashMap::new()),
aria2_retrying_gids: Mutex::new(HashSet::new()),
aria2_ignored_gids: Mutex::new(VecDeque::new()),
aria2_retry_cancel_notify: Notify::new(),
aria2_control_locks: Arc::new(StdMutex::new(HashMap::new())),
aria2_gid_state: Mutex::new(()),
aria2_control_epochs: Mutex::new(HashMap::new()),
power_manager: PowerManager::new(),
spawner,
app_handle,
}
}
pub fn power_manager(&self) -> Arc<PowerManager> {
Arc::clone(&self.power_manager)
}
pub fn set_aria2_async_dns_supported(&self, supported: bool) {
self.aria2_async_dns_supported
.store(supported, Ordering::Relaxed);
}
fn aria2_system_resolver_fallback_available(&self) -> bool {
self.aria2_async_dns_supported.load(Ordering::Relaxed)
}
/// Accept one lifecycle-fenced Aria2 status sample and return Firelink's
/// monotonic lifetime counters. Poller callers must already have checked
/// the mapping; the key and epoch checks here provide a second fence at
/// the accounting owner itself.
pub async fn observe_torrent_telemetry(
&self,
id: &str,
gid: &str,
epoch: u64,
upload_length: Option<u64>,
is_seeding: bool,
now: Instant,
) -> TorrentTelemetrySnapshot {
let mut telemetry = self.torrent_telemetry.lock().await;
let state = telemetry
.entry(id.to_string())
.or_insert_with(|| TorrentTelemetryState::new(gid, epoch, now));
state.observe(gid, epoch, upload_length, is_seeding, now)
}
/// Restore the durable Firelink totals before the first raw Aria2 sample
/// for a download. Raw upload counters are intentionally not restored;
/// the next observation establishes a fresh GID/epoch baseline.
pub async fn hydrate_torrent_telemetry(
&self,
id: &str,
uploaded_bytes: u64,
seeded_seconds: u64,
) {
let mut telemetry = self.torrent_telemetry.lock().await;
let state = telemetry
.entry(id.to_string())
.or_insert_with(|| TorrentTelemetryState::new("", 0, Instant::now()));
state.uploaded_bytes = state.uploaded_bytes.max(uploaded_bytes);
state.seeded_seconds = state.seeded_seconds.max(seeded_seconds);
}
/// Clear the one-shot integrity override only for the still-current
/// lifecycle. A normal user integrity preference is never changed here.
pub async fn clear_torrent_relocation_check(&self, id: &str, epoch: u64) -> bool {
if !self.is_aria2_control_epoch_current(id, epoch).await {
return false;
}
let mut payloads = self.aria2_payloads.lock().await;
let Some(payload) = payloads.get_mut(id) else {
return false;
};
if !payload.is_torrent {
return false;
}
payload.torrent_check_integrity = false;
true
}
pub async fn begin_torrent_move(&self, id: &str) {
self.torrent_move_cancellations.lock().await.remove(id);
}
pub async fn cancel_torrent_move(&self, id: &str) {
self.torrent_move_cancellations
.lock()
.await
.insert(id.to_string());
}
pub async fn torrent_move_cancelled(&self, id: &str) -> bool {
self.torrent_move_cancellations.lock().await.contains(id)
}
pub async fn finish_torrent_move(&self, id: &str) {
self.torrent_move_cancellations.lock().await.remove(id);
}
/// Drop counters after terminal cleanup/removal. Persisted lifetime
/// totals remain owned by the DownloadItem row; this only removes raw
/// process-local lifecycle state.
pub async fn forget_torrent_telemetry(&self, id: &str) {
self.torrent_telemetry.lock().await.remove(id);
}
pub fn app_handle(&self) -> AppHandle<R> {
self.app_handle.clone()
}
pub fn activate_power_management(&self) -> Result<(), String> {
self.power_manager.activate()
}
pub fn set_power_preferences(
&self,
prevent_system_sleep: bool,
prevent_display_sleep: bool,
) -> Result<(), String> {
self.power_manager
.set_preferences(prevent_system_sleep, prevent_display_sleep)
}
pub fn set_system_sleep_prevention(&self, enabled: bool) -> Result<(), String> {
self.power_manager.set_system_prevention(enabled)
}
/// Apply the persisted seed policy to the live queue manager. Existing
/// seeders are not paused when the setting changes; the new limit applies
/// to the next seed admission and to waiting seeders.
pub fn configure_seed_capacity(&self, enabled: bool, max_concurrent: u32) {
let max_concurrent = normalize_torrent_max_concurrent_seeds(max_concurrent)
.unwrap_or(DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS) as usize;
if let Ok(mut state) = self.seed_capacity.lock() {
state.enabled = enabled;
state.max_concurrent = max_concurrent;
if enabled {
if let Ok(budgets) = self.seed_budgets.lock() {
state.owners.extend(budgets.keys().cloned());
}
}
}
self.notify.notify_waiters();
}
fn seed_capacity_enabled(&self) -> bool {
self.seed_capacity
.lock()
.map(|state| state.enabled)
.unwrap_or(false)
}
fn seed_owner(&self, id: &str) -> bool {
self.seed_capacity
.lock()
.map(|state| state.owners.contains(id))
.unwrap_or(false)
}
pub fn is_seed_owner(&self, id: &str) -> bool {
self.seed_owner(id)
}
fn seed_waiting(&self, id: &str) -> bool {
self.seed_capacity.lock().map_or(false, |state| {
state.waiting.iter().any(|waiter| waiter.id == id)
|| state.starting.contains(id)
})
}
fn add_seed_waiter(&self, waiter: SeedWaiter) {
if let Ok(mut state) = self.seed_capacity.lock() {
if !state.owners.contains(&waiter.id)
&& !state.starting.contains(&waiter.id)
&& !state.waiting.iter().any(|candidate| candidate.id == waiter.id)
{
state.waiting.push_back(waiter);
}
}
self.notify.notify_waiters();
}
fn reserve_seed_slot(&self, id: &str) -> bool {
let Ok(mut state) = self.seed_capacity.lock() else {
return false;
};
if state.owners.contains(id) {
return true;
}
if !state.enabled || state.owners.len() >= state.max_concurrent {
return false;
}
state.owners.insert(id.to_string());
true
}
fn reserve_seed_waiter(&self) -> Option<SeedWaiter> {
let Ok(mut state) = self.seed_capacity.lock() else {
return None;
};
if state.enabled && state.owners.len() >= state.max_concurrent {
return None;
}
let waiter = state.waiting.pop_front()?;
state.owners.insert(waiter.id.clone());
state.starting.insert(waiter.id.clone());
Some(waiter)
}
fn finish_seed_start(&self, id: &str) {
if let Ok(mut state) = self.seed_capacity.lock() {
state.starting.remove(id);
}
}
fn abandon_seed_start(&self, id: &str) {
if let Ok(mut state) = self.seed_capacity.lock() {
state.starting.remove(id);
state.owners.remove(id);
}
self.notify.notify_waiters();
}
/// Stop tracking a seed lifecycle. This is called for terminal, manual
/// pause, and remove paths; releasing a download permit while a seed slot
/// is merely changing hands deliberately does not call it.
pub fn release_seed_tracking(&self, id: &str) {
if let Ok(mut state) = self.seed_capacity.lock() {
state.owners.remove(id);
state.starting.remove(id);
state.waiting.retain(|waiter| waiter.id != id);
}
if let Ok(mut budgets) = self.seed_budgets.lock() {
budgets.remove(id);
}
self.notify.notify_waiters();
}
pub fn is_waiting_to_seed(&self, id: &str) -> bool {
self.seed_waiting(id)
}
pub fn wake_seed_waiters(&self) {
self.notify.notify_waiters();
}
async fn record_seed_started(&self, id: &str) {
let remaining_minutes = self
.aria2_payloads
.lock()
.await
.get(id)
.and_then(|payload| {
payload
.torrent_seed_remaining
.or(payload.torrent_seed_time)
.filter(|minutes| minutes.is_finite() && *minutes > 0.0)
});
if let Ok(mut budgets) = self.seed_budgets.lock() {
budgets.insert(
id.to_string(),
SeedBudget {
remaining_minutes,
started_at: std::time::Instant::now(),
},
);
}
}
/// Persist the remaining time in the in-memory payload before a seeder is
/// parked or manually paused. The frontend persistence path receives the
/// same value through the WaitingToSeed event and can restore it after a
/// restart.
pub async fn capture_seed_remaining(&self, id: &str) -> Option<f64> {
let remaining = self.seed_budgets.lock().ok().and_then(|mut budgets| {
let budget = budgets.get_mut(id)?;
let elapsed = budget.started_at.elapsed().as_secs_f64() / 60.0;
budget.remaining_minutes.map(|minutes| (minutes - elapsed).max(0.0))
});
if let Some(remaining) = remaining {
if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) {
payload.torrent_seed_time = Some(remaining);
payload.torrent_seed_remaining = Some(remaining);
}
}
remaining
}
async fn release_download_permit_for_seed(&self, id: &str) {
let _admission_gate = self.admission_gate.lock().await;
let removed = self.active_permits.lock().await.remove(id).is_some();
self.active_permit_generations.lock().await.remove(id);
self.queue_permit_ownership.lock().await.remove(id);
if removed {
self.notify.notify_waiters();
}
drop(_admission_gate);
if removed {
self.sync_power_activity().await;
}
}
async fn admit_seed_after_completion(&self, id: &str) -> SeedAdmissionOutcome {
if self.seed_owner(id) {
return SeedAdmissionOutcome::Seeding;
}
if self.seed_waiting(id) {
return SeedAdmissionOutcome::Waiting;
}
if self.reserve_seed_slot(id) {
self.release_download_permit_for_seed(id).await;
self.record_seed_started(id).await;
return SeedAdmissionOutcome::Seeding;
}
let Some(gid) = self.aria2_gid_for_download(id) else {
return SeedAdmissionOutcome::Seeding;
};
match self.spawner.pause_for_seed(&gid).await {
Ok(Aria2SeedControlOutcome::Paused) => {
self.record_seed_started(id).await;
let remaining = self.capture_seed_remaining(id).await;
let ownership = self.queue_permit_ownership.lock().await.get(id).cloned();
let lifecycle_generation = ownership
.as_ref()
.map(|ownership| ownership.lifecycle_generation)
.or(self.registered_lifecycle_generation(id).await)
.unwrap_or_default();
self.release_download_permit_for_seed(id).await;
self.add_seed_waiter(SeedWaiter {
id: id.to_string(),
queue_id: ownership
.as_ref()
.map(|ownership| ownership.queue_id.clone())
.unwrap_or_else(|| "main".to_string()),
lifecycle_generation,
});
let _ = remaining;
SeedAdmissionOutcome::Waiting
}
Ok(Aria2SeedControlOutcome::Complete) => SeedAdmissionOutcome::Complete,
Ok(Aria2SeedControlOutcome::Resumed) | Err(_) => {
// An ambiguous pause must retain the download permit and GID;
// treating the transfer as a live seeder is the conservative
// choice until the next daemon status event resolves it.
self.reserve_seed_slot_even_if_full(id);
self.record_seed_started(id).await;
SeedAdmissionOutcome::Seeding
}
}
}
fn reserve_seed_slot_even_if_full(&self, id: &str) {
if let Ok(mut state) = self.seed_capacity.lock() {
state.owners.insert(id.to_string());
}
}
fn emit_waiting_to_seed(&self, id: &str, remaining: Option<f64>) {
use tauri::Emitter;
let _ = self.app_handle.emit(
"download-state",
DownloadStateEvent::waiting_to_seed(id, remaining),
);
}
async fn try_start_waiting_seed(self: &Arc<Self>) -> bool {
let Some(waiter) = self.reserve_seed_waiter() else {
return false;
};
let manager = Arc::clone(self);
tauri::async_runtime::spawn(async move {
manager.resume_waiting_seed(waiter).await;
});
true
}
async fn resume_waiting_seed(self: Arc<Self>, waiter: SeedWaiter) {
let id = waiter.id.clone();
let Some(gid) = self.aria2_gid_for_download(&id) else {
self.abandon_seed_start(&id);
return;
};
if !self
.is_registered_generation_or_legacy(&id, waiter.lifecycle_generation)
.await
|| !self.seed_waiting(&id)
|| !matches!(self.active_kind(&id).await, Some(TaskKind::Aria2))
{
self.abandon_seed_start(&id);
return;
}
let control_epoch = self.current_aria2_control_epoch(&id).await;
let Some(permit_candidate) = self
.acquire_aria2_permit_candidate_for_queue(
&id,
&waiter.queue_id,
waiter.lifecycle_generation,
control_epoch,
)
.await
else {
self.abandon_seed_start(&id);
return;
};
let _control_guard = self.acquire_aria2_control(&id).await;
if !self
.is_registered_generation_or_legacy(&id, waiter.lifecycle_generation)
.await
|| !self.seed_waiting(&id)
|| !matches!(self.active_kind(&id).await, Some(TaskKind::Aria2))
|| self.aria2_gid_for_download(&id).as_deref() != Some(gid.as_str())
|| !self.is_aria2_control_epoch_current(&id, control_epoch).await
{
self.release_aria2_permit_candidate(&id, waiter.lifecycle_generation)
.await;
self.abandon_seed_start(&id);
return;
}
let epoch = self.next_aria2_control_epoch(&id).await;
if !self.rebind_aria2_gid_epoch(&id, &gid, epoch).await {
self.release_aria2_permit_candidate(&id, waiter.lifecycle_generation)
.await;
self.abandon_seed_start(&id);
return;
}
if !self
.park_aria2_permit_if_missing_for_queue(
&id,
&waiter.queue_id,
waiter.lifecycle_generation,
permit_candidate,
)
.await
{
self.release_aria2_permit_candidate(&id, waiter.lifecycle_generation)
.await;
self.abandon_seed_start(&id);
return;
}
match self.spawner.resume_for_seed(&gid).await {
Ok(Aria2SeedControlOutcome::Resumed) => {
if !self.is_aria2_control_epoch_current(&id, epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &Aria2GidMapping { id: id.clone(), epoch })
{
let _ = self.spawner.pause_for_seed(&gid).await;
self.release_download_permit_for_seed(&id).await;
self.abandon_seed_start(&id);
return;
}
self.record_seed_started(&id).await;
// Keep the lifecycle visibly in the starting state until its
// download permit has been released. Otherwise observers can
// see a resumed seeder before the permit transition finishes.
self.release_download_permit_for_seed(&id).await;
self.finish_seed_start(&id);
self.emit_state(&id, DownloadStatus::Seeding);
}
Ok(Aria2SeedControlOutcome::Complete) => {
self.finish_seed_start(&id);
self.apply_completion_locked(&id, PendingOutcome::Complete).await;
}
Ok(Aria2SeedControlOutcome::Paused) => {
let remaining = self.capture_seed_remaining(&id).await;
self.release_download_permit_for_seed(&id).await;
self.finish_seed_start(&id);
self.abandon_seed_start(&id);
self.add_seed_waiter(SeedWaiter {
id: id.clone(),
queue_id: waiter.queue_id,
lifecycle_generation: waiter.lifecycle_generation,
});
self.emit_waiting_to_seed(&id, remaining);
}
Err(error) => {
// An unverified unpause must not release either the seed
// owner or its download permit: the daemon may already be
// active even though the RPC/status check was unavailable.
// Keep the item fenced in the starting state until a
// terminal event or an explicit user pause resolves it.
log::warn!(
"Torrent seed resume [{}] could not be verified; retaining seed ownership and permit: {}",
id,
error
);
self.emit_waiting_to_seed(&id, None);
}
}
}
async fn sync_power_activity(&self) {
let active_transfers = self.active_permits.lock().await.len();
self.power_manager
.set_active_transfer_count(active_transfers);
}
/// Current pending order, as id list. Returned by move_in_queue.
pub async fn pending_order(&self, queue_id: Option<&str>) -> Vec<String> {
self.pending
.lock()
.await
.iter()
.filter(|task| queue_id.is_none_or(|queue_id| task.queue_id == queue_id))
.map(|t| t.id.clone())
.collect()
}
/// Temporarily remove one not-yet-admitted task while a caller performs a
/// lifecycle-safe reconfiguration. The admission gate prevents the
/// dispatcher from popping the same task between the caller's validation
/// and mutation of its payload.
pub async fn take_pending_task(&self, id: &str) -> Option<(usize, QueuedTask)> {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let index = pending.iter().position(|task| task.id == id)?;
pending.remove(index).map(|task| (index, task))
}
/// Restore a task removed by `take_pending_task`, preserving its queue
/// position even when another queue's work was admitted meanwhile.
pub async fn restore_pending_task(&self, index: usize, task: QueuedTask) {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let insert_at = index.min(pending.len());
pending.insert(insert_at, task);
self.notify.notify_one();
}
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
pub async fn release_registered_id(&self, id: &str) {
self.registered_ids.lock().await.remove(id);
self.registered_lifecycle_generations.lock().await.remove(id);
// A released lifecycle cannot be resumed by a delayed retry worker.
// Epoch checks remain the authoritative guard; removing this marker
// prevents terminal downloads from accumulating cancellation entries.
self.aria2_retry_cancelled.lock().await.remove(id);
self.release_seed_tracking(id);
self.notify.notify_waiters();
}
pub async fn is_registered(&self, id: &str) -> bool {
self.registered_ids.lock().await.contains(id)
}
pub async fn registered_lifecycle_generation(&self, id: &str) -> Option<u64> {
self.registered_lifecycle_generations
.lock()
.await
.get(id)
.copied()
}
async fn is_registered_generation(&self, id: &str, generation: u64) -> bool {
self.registered_lifecycle_generations
.lock()
.await
.get(id)
.copied()
== Some(generation)
}
async fn is_registered_generation_or_legacy(&self, id: &str, generation: u64) -> bool {
self.is_registered_generation(id, generation).await
|| (generation == 0
&& self.registered_lifecycle_generation(id).await.is_none()
&& self.is_registered(id).await)
}
pub(crate) async fn release_registered_id_for_generation(&self, id: &str, generation: u64) {
let released = {
let mut registered = self.registered_ids.lock().await;
let mut generations = self.registered_lifecycle_generations.lock().await;
if generations.get(id).copied() == Some(generation) {
registered.remove(id);
generations.remove(id);
true
} else {
false
}
};
if released {
self.aria2_retry_cancelled.lock().await.remove(id);
self.release_seed_tracking(id);
self.notify.notify_waiters();
}
}
/// Reject an in-flight enqueue generation if a newer UI action supersedes it.
pub async fn cancel_enqueue_generation(&self, id: &str, generation: u64) {
let mut cancellations = self.enqueue_cancellations.lock().await;
cancellations
.entry(id.to_string())
.and_modify(|current| *current = (*current).max(generation))
.or_insert(generation);
}
/// Return a generation newer than every observed or cancelled enqueue for
/// an internally-created lifecycle. The caller still passes this value to
/// `reserve_enqueue_generation`, which performs the atomic ownership and
/// cancellation checks before the enqueue is committed.
pub async fn next_enqueue_generation(&self, id: &str) -> Result<u64, String> {
let cancellations = self.enqueue_cancellations.lock().await;
let generations = self.enqueue_generations.lock().await;
let previous_generation = generations.get(id).copied().unwrap_or_default();
let cancelled_generation = cancellations.get(id).copied().unwrap_or_default();
previous_generation
.max(cancelled_generation)
.checked_add(1)
.ok_or_else(|| "Download lifecycle generation exhausted".to_string())
}
/// Atomically reserve an ID after rejecting cancelled or replayed generations.
/// The returned watermark must be passed to `rollback_enqueue_reservation`
/// if ownership registration fails before the task is committed.
pub async fn reserve_enqueue_generation(
&self,
id: &str,
generation: u64,
) -> Result<Option<u64>, String> {
let cancellations = self.enqueue_cancellations.lock().await;
if cancellations
.get(id)
.is_some_and(|cancelled| *cancelled >= generation)
{
return Err("Download enqueue was superseded by a newer user action".to_string());
}
let mut generations = self.enqueue_generations.lock().await;
let previous_generation = generations.get(id).copied();
if previous_generation.is_some_and(|seen| seen >= generation) {
return Err("Download enqueue was superseded by a newer user action".to_string());
}
let mut registered = self.registered_ids.lock().await;
if registered.contains(id) {
return Err("Duplicate task".to_string());
}
registered.insert(id.to_string());
self.registered_lifecycle_generations
.lock()
.await
.insert(id.to_string(), generation);
generations.insert(id.to_string(), generation);
Ok(previous_generation)
}
pub async fn rollback_enqueue_reservation(
&self,
id: &str,
generation: u64,
previous_generation: Option<u64>,
) {
let mut generations = self.enqueue_generations.lock().await;
let mut registered = self.registered_ids.lock().await;
if generations.get(id).copied() != Some(generation) {
return;
}
registered.remove(id);
self.registered_lifecycle_generations.lock().await.remove(id);
match previous_generation {
Some(previous) => {
generations.insert(id.to_string(), previous);
}
None => {
generations.remove(id);
}
}
}
pub async fn commit_reserved_enqueue(
&self,
mut task: QueuedTask,
generation: u64,
) -> Result<(), String> {
let id = task.id.clone();
let cancellations = self.enqueue_cancellations.lock().await;
if cancellations
.get(&id)
.is_some_and(|cancelled| *cancelled >= generation)
{
return Err("Download enqueue was superseded by a newer user action".to_string());
}
task.lifecycle_generation = generation;
self.pending.lock().await.push_back(task);
self.emit_state(id, DownloadStatus::Queued);
self.notify.notify_one();
Ok(())
}
/// Atomically checks the generation watermark before registering a task.
pub async fn push_with_generation(
&self,
task: QueuedTask,
generation: u64,
) -> Result<(), String> {
let id = task.id.clone();
let previous_generation = self.reserve_enqueue_generation(&id, generation).await?;
if let Err(error) = self.commit_reserved_enqueue(task, generation).await {
self.rollback_enqueue_reservation(&id, generation, previous_generation)
.await;
return Err(error);
}
Ok(())
}
/// Enqueue a task without a frontend lifecycle token. This is retained for
/// internal/test callers and still gets replay protection at generation 0.
pub async fn push(&self, task: QueuedTask) -> Result<(), String> {
self.push_with_generation(task, 0).await
}
/// Replace the explicit per-queue concurrency overrides. A missing queue
/// entry inherits the global limit; every effective queue limit is capped
/// by the global target at admission time.
pub async fn replace_queue_limits(
&self,
limits: Vec<(String, Option<usize>)>,
) -> Result<(), String> {
let mut next = HashMap::with_capacity(limits.len());
for (queue_id, limit) in limits {
let queue_id = queue_id.trim();
if queue_id.is_empty() {
return Err("Queue id cannot be empty".to_string());
}
if next.contains_key(queue_id) {
return Err(format!("Duplicate queue id '{queue_id}'"));
}
if let Some(limit) = limit {
if !(1..=MAX_QUEUE_CONCURRENT).contains(&limit) {
return Err(format!(
"Queue concurrency must be between 1 and {MAX_QUEUE_CONCURRENT}"
));
}
next.insert(queue_id.to_string(), limit);
}
}
let _admission_gate = self.admission_gate.lock().await;
*self.queue_limits.lock().await = next;
self.notify.notify_waiters();
Ok(())
}
pub async fn next_aria2_control_epoch(&self, id: &str) -> u64 {
let mut epochs = self.aria2_control_epochs.lock().await;
let epoch = epochs.get(id).copied().unwrap_or_default().wrapping_add(1);
epochs.insert(id.to_string(), epoch);
self.notify.notify_waiters();
epoch
}
pub async fn is_aria2_control_epoch_current(&self, id: &str, epoch: u64) -> bool {
self.aria2_control_epochs
.lock()
.await
.get(id)
.copied()
.unwrap_or_default()
== epoch
}
pub async fn current_aria2_control_epoch(&self, id: &str) -> u64 {
self.aria2_control_epochs
.lock()
.await
.get(id)
.copied()
.unwrap_or_default()
}
pub async fn is_aria2_retry_cancelled(&self, id: &str) -> bool {
self.aria2_retry_cancelled.lock().await.contains(id)
}
/// Serialize control RPCs for one download while allowing unrelated
/// downloads to pause, resume, or refresh concurrently.
pub async fn acquire_aria2_control(&self, id: &str) -> Aria2ControlGuard {
let lock = {
let mut locks = self
.aria2_control_locks
.lock()
.unwrap_or_else(|error| error.into_inner());
Arc::clone(
locks
.entry(id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(()))),
)
};
let guard = lock.clone().lock_owned().await;
Aria2ControlGuard {
locks: Arc::clone(&self.aria2_control_locks),
id: id.to_string(),
lock,
guard: Some(guard),
}
}
pub async fn has_aria2_retry_state(&self, id: &str) -> bool {
self.aria2_retry_strikes.lock().await.contains_key(id)
}
pub async fn aria2_requested_connections(&self, id: &str) -> Option<i32> {
self.aria2_payloads
.lock()
.await
.get(id)
.and_then(|payload| {
if payload.is_torrent {
None
} else {
payload.connections
}
})
.map(clamp_download_connections)
}
pub async fn aria2_torrent_seeding_requested(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(torrent_seeding_requested)
}
/// Whether a currently seeding Torrent owns the Firelink permit that
/// allows seed-time accounting. Separate seed slots require explicit
/// ownership; legacy single-pool mode keeps the transfer permit live.
pub async fn aria2_torrent_seed_permit_owned(&self, id: &str) -> bool {
if self.seed_capacity_enabled() {
self.seed_owner(id)
} else {
self.aria2_torrent_seeding_requested(id).await
}
}
pub async fn aria2_is_torrent(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent)
}
pub async fn aria2_is_torrent_verification(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.torrent_verify_only)
}
async fn capture_torrent_verification_evidence(&self, id: &str) {
if !self.aria2_is_torrent_verification(id).await {
return;
}
let Some(gid) = self.aria2_gid_for_download(id) else {
return;
};
let Some(mapping) = self.aria2_gid_mapping(&gid) else {
return;
};
let Some(state) = self.app_handle.try_state::<crate::AppState>() else {
return;
};
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = state.aria2_secret.clone();
drop(state);
let Ok(status) = crate::rpc_call(
port,
&secret,
"aria2.tellStatus",
serde_json::json!([gid, ["status", "totalLength", "verifiedLength", "verifyIntegrityPending"]]),
)
.await else {
return;
};
if !self.is_current_aria2_gid_mapping(&gid, &mapping)
|| !self
.is_aria2_control_epoch_current(id, mapping.epoch)
.await
{
return;
}
let status_name = status.get("status").and_then(|value| value.as_str());
let total = status
.get("totalLength")
.and_then(|value| value.as_str().and_then(|value| value.parse::<u64>().ok()).or_else(|| value.as_u64()));
let verified = status
.get("verifiedLength")
.and_then(|value| value.as_str().and_then(|value| value.parse::<u64>().ok()).or_else(|| value.as_u64()));
let pending = status
.get("verifyIntegrityPending")
.is_some_and(|value| value.as_bool() == Some(true) || value.as_str() == Some("true"));
if let Some(total) = Self::complete_torrent_verification_length(
status_name,
pending,
total,
verified,
) {
self.record_torrent_verified_length(id, mapping.epoch, total)
.await;
}
}
fn complete_torrent_verification_length(
status: Option<&str>,
verify_pending: bool,
total: Option<u64>,
verified: Option<u64>,
) -> Option<u64> {
if matches!(status, Some("complete" | "active" | "waiting"))
&& !verify_pending
{
return verified
.zip(total)
.filter(|(verified, total)| verified >= total)
.map(|(_, total)| total);
}
None
}
pub async fn record_torrent_verified_length(&self, id: &str, epoch: u64, length: u64) {
if !self.is_aria2_control_epoch_current(id, epoch).await {
return;
}
if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) {
if payload.torrent_verify_only {
let current = payload.torrent_verified_length.unwrap_or(0);
payload.torrent_verified_length = Some(current.max(length));
}
}
}
async fn torrent_files_for_payload(
&self,
id: &str,
payload: &SpawnPayload,
) -> Result<Vec<crate::ipc::TorrentFile>, String> {
let path = payload
.torrent_path
.as_deref()
.ok_or_else(|| "Torrent metadata is unavailable for web-seed management".to_string())?;
let path = crate::torrent::validate_managed_torrent_path(&self.app_handle, id, path)?;
let bytes = tokio::fs::read(path)
.await
.map_err(|error| format!("could not read cached Torrent metadata: {error}"))?;
Ok(crate::torrent::parse_torrent_bytes(&bytes)?.files)
}
async fn current_torrent_mapping(
&self,
id: &str,
) -> Result<(String, Aria2GidMapping), String> {
if !self.is_registered(id).await || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) {
return Err("download is not an active Aria2 Torrent".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active Torrent transfer has no GID".to_string())?;
let mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active Torrent transfer has no current GID mapping".to_string())?;
if mapping.id != id || !self.is_aria2_control_epoch_current(id, mapping.epoch).await {
return Err("Torrent lifecycle changed before web-seed inspection".to_string());
}
Ok((gid, mapping))
}
pub async fn get_aria2_torrent_web_seeds(
&self,
id: &str,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let control_guard = self.acquire_aria2_control(id).await;
self.get_aria2_torrent_web_seeds_locked(id, &control_guard)
.await
}
pub async fn get_aria2_torrent_web_seeds_locked(
&self,
id: &str,
_control_guard: &Aria2ControlGuard,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let payload = self
.aria2_payloads
.lock()
.await
.get(id)
.cloned()
.ok_or_else(|| "Torrent retry payload is unavailable".to_string())?;
let files = self.torrent_files_for_payload(id, &payload).await?;
let desired = normalize_torrent_web_seeds(payload.torrent_web_seeds.as_deref(), &files)?;
let (gid, mapping) = self.current_torrent_mapping(id).await?;
let current = self.spawner.get_torrent_uris(&gid).await?;
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
return Err("Torrent lifecycle changed while reading web seeds".to_string());
}
let expected = expand_torrent_web_seeds(&desired, &files)?;
if expected.iter().any(|(_, uri)| !current.iter().any(|candidate| candidate == uri)) {
return Err("Aria2 web-seed state differs from Firelink's persisted state".to_string());
}
Ok(desired
.into_iter()
.filter(|seed| {
expand_torrent_web_seed_uri(seed, &files)
.ok()
.is_some_and(|uri| current.iter().any(|candidate| candidate == &uri))
})
.collect())
}
pub async fn normalize_aria2_torrent_web_seeds(
&self,
id: &str,
seeds: &[crate::ipc::TorrentWebSeed],
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let control_guard = self.acquire_aria2_control(id).await;
self.normalize_aria2_torrent_web_seeds_locked(id, seeds, &control_guard)
.await
}
pub async fn normalize_aria2_torrent_web_seeds_locked(
&self,
id: &str,
seeds: &[crate::ipc::TorrentWebSeed],
_control_guard: &Aria2ControlGuard,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let payload = self
.aria2_payloads
.lock()
.await
.get(id)
.cloned()
.ok_or_else(|| "Torrent retry payload is unavailable".to_string())?;
let files = self.torrent_files_for_payload(id, &payload).await?;
normalize_torrent_web_seeds(Some(seeds), &files)
}
async fn rollback_torrent_web_seed_changes(
&self,
id: &str,
gid: &str,
mapping: &Aria2GidMapping,
changes: &[(u32, Vec<String>, Vec<String>)],
) -> bool {
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(gid, mapping)
{
return false;
}
let mut restored = true;
for (file_index, delete, add) in changes.iter().rev() {
if let Err(error) = self
.spawner
.change_torrent_uris(gid, *file_index, add, delete)
.await
{
restored = false;
log::warn!(
"Torrent web-seed rollback [{}] failed for gid {} file {}: {}",
id,
gid,
file_index,
error
);
}
}
restored
}
async fn restore_torrent_web_seed_payload_if_current(
&self,
id: &str,
gid: &str,
mapping: &Aria2GidMapping,
old_payload: &SpawnPayload,
) {
if self.is_aria2_control_epoch_current(id, mapping.epoch).await
&& self.is_current_aria2_gid_mapping(gid, mapping)
{
if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) {
payload.torrent_web_seeds = old_payload.torrent_web_seeds.clone();
}
}
}
pub async fn set_aria2_torrent_web_seeds(
&self,
id: &str,
seeds: Vec<crate::ipc::TorrentWebSeed>,
) -> Result<
(
Vec<crate::ipc::TorrentWebSeed>,
Vec<crate::ipc::TorrentWebSeed>,
),
String,
> {
let control_guard = self.acquire_aria2_control(id).await;
self.set_aria2_torrent_web_seeds_locked(id, seeds, &control_guard)
.await
}
pub async fn set_aria2_torrent_web_seeds_locked(
&self,
id: &str,
seeds: Vec<crate::ipc::TorrentWebSeed>,
_control_guard: &Aria2ControlGuard,
) -> Result<
(
Vec<crate::ipc::TorrentWebSeed>,
Vec<crate::ipc::TorrentWebSeed>,
),
String,
> {
let old_payload = self
.aria2_payloads
.lock()
.await
.get(id)
.cloned()
.ok_or_else(|| "Torrent retry payload is unavailable".to_string())?;
let files = self.torrent_files_for_payload(id, &old_payload).await?;
let desired = normalize_torrent_web_seeds(Some(&seeds), &files)?;
let old = normalize_torrent_web_seeds(old_payload.torrent_web_seeds.as_deref(), &files)?;
let (gid, mapping) = self.current_torrent_mapping(id).await?;
let current = self.spawner.get_torrent_uris(&gid).await?;
let old_expanded = expand_torrent_web_seeds(&old, &files)?;
let new_expanded = expand_torrent_web_seeds(&desired, &files)?;
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
return Err("Torrent lifecycle changed while reading web seeds".to_string());
}
if old_expanded.iter().any(|(_, uri)| !current.iter().any(|candidate| candidate == uri)) {
return Err("Aria2 web-seed state differs from Firelink's persisted state".to_string());
}
if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) {
payload.torrent_web_seeds = Some(desired.clone());
}
let mut changes = Vec::<(u32, Vec<String>, Vec<String>)>::new();
let mut current_set = current.into_iter().collect::<HashSet<_>>();
let mut file_indices = old_expanded.iter().map(|(index, _)| *index).collect::<HashSet<_>>();
file_indices.extend(new_expanded.iter().map(|(index, _)| *index));
let mut file_indices = file_indices.into_iter().collect::<Vec<_>>();
file_indices.sort_unstable();
for file_index in file_indices {
let delete = old_expanded
.iter()
.filter(|(index, uri)| *index == file_index && current_set.contains(uri))
.map(|(_, uri)| uri.clone())
.collect::<Vec<_>>();
let add = new_expanded
.iter()
.filter(|(index, uri)| *index == file_index && !current_set.contains(uri))
.map(|(_, uri)| uri.clone())
.collect::<Vec<_>>();
if delete.is_empty() && add.is_empty() {
continue;
}
changes.push((file_index, delete.clone(), add.clone()));
if let Err(error) = self.spawner.change_torrent_uris(&gid, file_index, &delete, &add).await {
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
self.restore_torrent_web_seed_payload_if_current(
id,
&gid,
&mapping,
&old_payload,
)
.await;
return Err(error);
}
for uri in &delete {
current_set.remove(uri);
}
current_set.extend(add.iter().cloned());
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
self.restore_torrent_web_seed_payload_if_current(
id,
&gid,
&mapping,
&old_payload,
)
.await;
return Err("Torrent lifecycle changed while changing web seeds".to_string());
}
}
let readback = match self.spawner.get_torrent_uris(&gid).await {
Ok(readback) => readback,
Err(error) => {
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
self.restore_torrent_web_seed_payload_if_current(
id,
&gid,
&mapping,
&old_payload,
)
.await;
return Err(error);
}
};
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
self.restore_torrent_web_seed_payload_if_current(
id,
&gid,
&mapping,
&old_payload,
)
.await;
return Err("Torrent lifecycle changed while reading back web seeds".to_string());
}
let expected = new_expanded.into_iter().map(|(_, uri)| uri).collect::<HashSet<_>>();
if readback.iter().cloned().collect::<HashSet<_>>() != expected {
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
self.restore_torrent_web_seed_payload_if_current(
id,
&gid,
&mapping,
&old_payload,
)
.await;
return Err("Aria2 web-seed readback did not match the requested state".to_string());
}
Ok((desired, old))
}
/// Attach the persisted typed seed set after addTorrent returns its GID.
/// The addTorrent URI parameter is intentionally left unscoped; this
/// method is the only path that supplies the required Aria2 file index.
pub async fn install_initial_torrent_web_seeds(&self, id: &str) -> Result<(), String> {
let _control_guard = self.acquire_aria2_control(id).await;
let payload = self
.aria2_payloads
.lock()
.await
.get(id)
.cloned()
.ok_or_else(|| "Torrent retry payload is unavailable".to_string())?;
let desired = payload.torrent_web_seeds.clone().unwrap_or_default();
if desired.is_empty() {
return Ok(());
}
let files = self.torrent_files_for_payload(id, &payload).await?;
let (gid, mapping) = self.current_torrent_mapping(id).await?;
let current = self.spawner.get_torrent_uris(&gid).await?;
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
return Err("Torrent lifecycle changed while reading initial web seeds".to_string());
}
let expanded = expand_torrent_web_seeds(&desired, &files)?;
let mut current_set = current.into_iter().collect::<HashSet<_>>();
let mut changes = Vec::<(u32, Vec<String>, Vec<String>)>::new();
for (file_index, uri) in &expanded {
if current_set.contains(uri) {
continue;
}
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
return Err("Torrent lifecycle changed while attaching web seeds".to_string());
}
let add = vec![uri.clone()];
changes.push((*file_index, Vec::new(), add.clone()));
if let Err(error) = self
.spawner
.change_torrent_uris(&gid, *file_index, &[], &add)
.await
{
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
return Err(error);
}
current_set.insert(uri.clone());
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
return Err("Torrent lifecycle changed while attaching web seeds".to_string());
}
}
let readback = match self.spawner.get_torrent_uris(&gid).await {
Ok(readback) => readback,
Err(error) => {
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
return Err(error);
}
};
if !self.is_aria2_control_epoch_current(id, mapping.epoch).await
|| !self.is_current_aria2_gid_mapping(&gid, &mapping)
{
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
return Err("Torrent lifecycle changed while reading initial web seeds".to_string());
}
let expected = expanded.into_iter().map(|(_, uri)| uri).collect::<HashSet<_>>();
if readback.into_iter().collect::<HashSet<_>>() != expected {
self.rollback_torrent_web_seed_changes(&id, &gid, &mapping, &changes)
.await;
return Err("Aria2 did not retain the persisted Torrent web-seed set".to_string());
}
Ok(())
}
pub fn set_aria2_global_speed_limit(&self, limit: Option<String>) {
*self
.aria2_global_speed_limit
.lock()
.unwrap_or_else(|error| error.into_inner()) = limit;
}
pub async fn aria2_speed_limited(&self, id: &str) -> bool {
if self
.aria2_global_speed_limit
.lock()
.unwrap_or_else(|error| error.into_inner())
.is_some()
{
return true;
}
self.aria2_payloads
.lock()
.await
.get(id)
.and_then(|payload| payload.speed_limit.as_deref())
.and_then(crate::normalize_speed_limit_for_aria2)
.is_some()
}
/// Change an active aria2 transfer's speed cap without replacing its GID
/// or queue permit. The per-download control lock and post-RPC ownership
/// check make a late response harmless if terminal cleanup or a retry
/// transition wins the lifecycle race.
pub async fn set_aria2_download_speed_limit(
&self,
id: &str,
limit: Option<String>,
) -> Result<(), String> {
let normalized_limit = match limit.as_deref().map(str::trim) {
None | Some("") => None,
Some(raw) => Some(
crate::normalize_speed_limit_for_aria2(raw)
.ok_or_else(|| "invalid download speed limit".to_string())?,
),
};
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await
|| !matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
{
return Err("download is not an active aria2 transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active aria2 transfer has no gid".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active aria2 transfer has no current gid mapping".to_string())?;
if expected_mapping.id != id {
return Err("aria2 gid belongs to another download".to_string());
}
if !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("active aria2 transfer has a stale control epoch".to_string());
}
self.spawner
.set_download_speed_limit(&gid, normalized_limit.as_deref())
.await?;
let still_current = self.is_registered(id).await
&& matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("download lifecycle changed while setting speed limit".to_string());
}
let mut payloads = self.aria2_payloads.lock().await;
let payload = payloads
.get_mut(id)
.ok_or_else(|| "active aria2 transfer payload is unavailable".to_string())?;
payload.speed_limit = normalized_limit;
Ok(())
}
/// Change an active Torrent's upload cap without replacing its GID or
/// queue permit. The control lock and post-RPC ownership check fence a
/// late response from a terminal or replaced lifecycle.
pub async fn set_aria2_torrent_upload_limit(
&self,
id: &str,
limit: Option<String>,
) -> Result<(), String> {
let normalized_limit = match limit.as_deref().map(str::trim) {
None | Some("") => None,
Some(raw) => Some(
crate::normalize_speed_limit_for_aria2(raw)
.ok_or_else(|| "invalid torrent upload limit".to_string())?,
),
};
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await
|| !matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
{
return Err("download is not an active aria2 transfer".to_string());
}
let is_torrent = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent);
if !is_torrent {
return Err("download is not a Torrent transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active Torrent transfer has no gid".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?;
if expected_mapping.id != id {
return Err("aria2 gid belongs to another download".to_string());
}
if !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("active Torrent transfer has a stale control epoch".to_string());
}
self.spawner
.set_torrent_upload_limit(&gid, normalized_limit.as_deref())
.await?;
let still_current = self.is_registered(id).await
&& matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("Torrent lifecycle changed while setting upload limit".to_string());
}
let mut payloads = self.aria2_payloads.lock().await;
let payload = payloads
.get_mut(id)
.ok_or_else(|| "active Torrent transfer payload is unavailable".to_string())?;
payload.torrent_upload_limit = normalized_limit;
Ok(())
}
/// Change active Torrent peer settings without replacing the GID or queue
/// permit. Clearing a setting restores Aria2's documented default while
/// keeping the persisted payload override-free for future retries.
pub async fn set_aria2_torrent_peer_options(
&self,
id: &str,
max_peers: Option<i64>,
peer_speed_limit: Option<String>,
) -> Result<(), String> {
let normalized_max_peers = normalize_torrent_max_peers(max_peers)?;
let normalized_peer_speed_limit =
normalize_torrent_peer_speed_limit(peer_speed_limit.as_deref())?;
let rpc_max_peers = normalized_max_peers.unwrap_or(ARIA2_DEFAULT_TORRENT_MAX_PEERS);
let rpc_peer_speed_limit = normalized_peer_speed_limit
.as_deref()
.unwrap_or(ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT);
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await
|| !matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
{
return Err("download is not an active aria2 transfer".to_string());
}
let is_torrent = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent);
if !is_torrent {
return Err("download is not a Torrent transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active Torrent transfer has no gid".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?;
if expected_mapping.id != id {
return Err("aria2 gid belongs to another download".to_string());
}
if !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("active Torrent transfer has a stale control epoch".to_string());
}
self.spawner
.set_torrent_peer_options(&gid, rpc_max_peers, rpc_peer_speed_limit)
.await?;
let still_current = self.is_registered(id).await
&& matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("Torrent lifecycle changed while setting peer options".to_string());
}
let mut payloads = self.aria2_payloads.lock().await;
let payload = payloads
.get_mut(id)
.ok_or_else(|| "active Torrent transfer payload is unavailable".to_string())?;
payload.torrent_max_peers = normalized_max_peers;
payload.torrent_peer_speed_limit = normalized_peer_speed_limit;
Ok(())
}
/// Return bounded peer diagnostics for the current Torrent GID. Endpoint
/// and peer-id fields live only in this response; they are not persisted.
/// The control lock and post-RPC mapping check prevent a late response from
/// being attributed to a replaced or terminal lifecycle.
pub async fn get_aria2_torrent_peers(
&self,
id: &str,
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await {
return Err("Torrent peer diagnostics are unavailable for this lifecycle".to_string());
}
let is_torrent = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent);
if !is_torrent {
return Err("download is not a Torrent transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active Torrent transfer has no gid".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?;
if expected_mapping.id != id
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("active Torrent transfer has a stale control epoch".to_string());
}
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getPeers",
serde_json::json!([gid]),
)
.await
.map_err(|error| {
format!(
"aria2.getPeers failed: {}",
crate::redact_sensitive_text(&error)
)
})?;
let diagnostics = parse_torrent_peer_diagnostics(result)?;
let still_current = self.is_registered(id).await
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("Torrent lifecycle changed while reading peer diagnostics".to_string());
}
Ok(diagnostics)
}
/// Compute bounded, anonymized swarm availability for the current
/// Torrent lifecycle. The raw local/peer bitfields are consumed in native
/// memory and never returned to the frontend.
pub async fn get_aria2_torrent_availability(
&self,
id: &str,
) -> Result<crate::ipc::TorrentAvailabilitySnapshot, String> {
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await {
return Err("Torrent availability is unavailable for this lifecycle".to_string());
}
if !self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent)
{
return Err("download is not a Torrent transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active Torrent has no gid".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active Torrent has no current gid mapping".to_string())?;
if expected_mapping.id != id
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("active Torrent has a stale control epoch".to_string());
}
let state = self.app_handle.state::<crate::AppState>();
let status = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.tellStatus",
serde_json::json!([gid, ["bitfield", "numPieces"]]),
)
.await
.map_err(|error| {
format!(
"aria2.tellStatus failed: {}",
crate::redact_sensitive_text(&error)
)
})?;
if !self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("Torrent lifecycle changed while reading availability".to_string());
}
let peers = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getPeers",
serde_json::json!([gid]),
)
.await
.map_err(|error| {
format!(
"aria2.getPeers failed: {}",
crate::redact_sensitive_text(&error)
)
})?;
let snapshot = parse_torrent_availability(status, peers)?;
if !self.is_registered(id).await
|| !self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("Torrent lifecycle changed while reading availability".to_string());
}
Ok(snapshot)
}
/// Return a lifecycle-fenced, metadata-derived projection of Aria2's
/// per-file progress. The daemon's absolute paths and URI lists are never
/// copied across the boundary.
pub async fn get_aria2_torrent_file_progress(
&self,
id: &str,
) -> Result<crate::ipc::TorrentFileProgressSnapshot, String> {
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await {
return Err("live Torrent file progress is unavailable".to_string());
}
let payload = self
.aria2_payloads
.lock()
.await
.get(id)
.cloned()
.ok_or_else(|| "live Torrent file progress is unavailable".to_string())?;
if !payload.is_torrent {
return Err("download is not a Torrent transfer".to_string());
}
let torrent_path = payload
.torrent_path
.as_deref()
.ok_or_else(|| "live Torrent file progress is unavailable".to_string())?;
let torrent_path = crate::torrent::validate_managed_torrent_path(
&self.app_handle,
id,
torrent_path,
)?;
let bytes = tokio::fs::read(&torrent_path)
.await
.map_err(|_| "live Torrent file progress is unavailable".to_string())?;
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "live Torrent file progress is unavailable".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "live Torrent file progress is unavailable".to_string())?;
if expected_mapping.id != id
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("live Torrent file progress is unavailable".to_string());
}
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getFiles",
serde_json::json!([gid]),
)
.await
.map_err(|error| {
format!(
"aria2.getFiles failed: {}",
crate::redact_sensitive_text(&error)
)
})?;
let snapshot = parse_torrent_file_progress(result, &metadata.files)?;
let still_current = self.is_registered(id).await
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("Torrent lifecycle changed while reading file progress".to_string());
}
Ok(snapshot)
}
/// Return a bounded, lifecycle-fenced projection of Aria2's piece
/// bitfield. The raw bitfield never crosses the native boundary; the UI
/// receives only exact counts and balanced percentage buckets.
pub async fn get_aria2_torrent_piece_progress(
&self,
id: &str,
) -> Result<crate::ipc::TorrentPieceProgressSnapshot, String> {
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await {
return Err("live Torrent piece progress is unavailable".to_string());
}
let is_torrent = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent);
if !is_torrent {
return Err("download is not a Torrent transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "live Torrent piece progress is unavailable".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "live Torrent piece progress is unavailable".to_string())?;
if expected_mapping.id != id
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("live Torrent piece progress is unavailable".to_string());
}
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.tellStatus",
serde_json::json!([gid, ["bitfield", "pieceLength", "numPieces"]]),
)
.await
.map_err(|error| {
format!(
"aria2.tellStatus failed: {}",
crate::redact_sensitive_text(&error)
)
})?;
let snapshot = parse_torrent_piece_progress(result)?;
let still_current = self.is_registered(id).await
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("Torrent lifecycle changed while reading piece progress".to_string());
}
Ok(snapshot)
}
/// Pop the next task, or None if empty.
pub async fn pop_front(&self) -> Option<QueuedTask> {
self.pending.lock().await.pop_front()
}
/// Acquire a permit from the semaphore (blocks until one is available).
pub async fn acquire_permit(&self) -> Option<OwnedSemaphorePermit> {
self.semaphore.clone().acquire_owned().await.ok()
}
fn try_acquire_permit_after_retirement(&self) -> Option<OwnedSemaphorePermit> {
loop {
let permit = self.semaphore.clone().try_acquire_owned().ok()?;
if self.retire_slot_if_needed() {
permit.forget();
continue;
}
return Some(permit);
}
}
async fn acquire_permit_after_retirement(&self) -> Option<OwnedSemaphorePermit> {
loop {
let permit = self.acquire_permit().await?;
if self.retire_slot_if_needed() {
permit.forget();
continue;
}
return Some(permit);
}
}
/// Acquire a permit without attaching it to a download yet. Resume
/// workers use this while they are outside the per-download control lock;
/// the permit is parked only after the worker revalidates its epoch.
pub async fn acquire_aria2_permit_candidate(&self) -> Option<OwnedSemaphorePermit> {
self.acquire_permit_after_retirement().await
}
/// Acquire and reserve both the global slot and a queue slot for a resume
/// that may still be outside the per-download control lock. The reservation
/// is generation-stamped so a concurrent pause cannot be overwritten by a
/// late resume worker.
pub async fn acquire_aria2_permit_candidate_for_queue(
&self,
id: &str,
queue_id: &str,
lifecycle_generation: u64,
control_epoch: u64,
) -> Option<OwnedSemaphorePermit> {
loop {
if !self
.is_registered_generation_or_legacy(id, lifecycle_generation)
.await
|| self.is_aria2_retry_cancelled(id).await
|| !self.is_aria2_control_epoch_current(id, control_epoch).await
{
return None;
}
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if let Some(permit) = self
.try_reserve_queue_slot(id, queue_id, lifecycle_generation)
.await
{
if self
.is_registered_generation_or_legacy(id, lifecycle_generation)
.await
&& !self.is_aria2_retry_cancelled(id).await
&& self
.is_aria2_control_epoch_current(id, control_epoch)
.await
{
return Some(permit);
}
self.release_queue_reservation_for_generation(id, lifecycle_generation)
.await;
drop(permit);
return None;
}
notified.await;
}
}
fn retire_slot_if_needed(&self) -> bool {
let mut debt = self.slots_to_retire.load(Ordering::Relaxed);
while debt > 0 {
match self.slots_to_retire.compare_exchange_weak(
debt,
debt - 1,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return true,
Err(actual) => debt = actual,
}
}
false
}
async fn try_reserve_queue_slot(
&self,
id: &str,
queue_id: &str,
lifecycle_generation: u64,
) -> Option<OwnedSemaphorePermit> {
let _admission_gate = self.admission_gate.lock().await;
let mut ownership = self.queue_permit_ownership.lock().await;
if ownership.contains_key(id)
|| self.active_permits.lock().await.contains_key(id)
{
return None;
}
let global_target = self.target_capacity.load(Ordering::Relaxed);
let queue_limit = self
.queue_limits
.lock()
.await
.get(queue_id)
.copied()
.unwrap_or(global_target)
.min(global_target);
let queue_active = ownership
.values()
.filter(|active| active.queue_id == queue_id)
.count();
if queue_active >= queue_limit {
return None;
}
let permit = self.try_acquire_permit_after_retirement()?;
ownership.insert(
id.to_string(),
QueuePermitOwnership {
queue_id: queue_id.to_string(),
lifecycle_generation,
active: false,
},
);
Some(permit)
}
async fn release_queue_reservation_for_generation(&self, id: &str, generation: u64) {
let _admission_gate = self.admission_gate.lock().await;
let removed = self
.queue_permit_ownership
.lock()
.await
.get(id)
.is_some_and(|ownership| {
ownership.lifecycle_generation == generation && !ownership.active
});
if removed {
self.queue_permit_ownership.lock().await.remove(id);
self.notify.notify_waiters();
}
}
async fn activate_admitted_permit(
&self,
id: &str,
lifecycle_generation: u64,
permit: OwnedSemaphorePermit,
) -> bool {
let _admission_gate = self.admission_gate.lock().await;
let mut ownership = self.queue_permit_ownership.lock().await;
let owned = ownership
.get(id)
.is_some_and(|entry| entry.lifecycle_generation == lifecycle_generation && !entry.active);
let active_already_owned = self.active_permits.lock().await.contains_key(id);
if !owned || active_already_owned {
if owned {
ownership.remove(id);
self.notify.notify_waiters();
}
return false;
}
if let Some(entry) = ownership.get_mut(id) {
entry.active = true;
}
drop(ownership);
self.active_permits.lock().await.insert(id.to_string(), permit);
self.active_permit_generations
.lock()
.await
.insert(id.to_string(), lifecycle_generation);
self.sync_power_activity().await;
true
}
async fn try_admit_next_task(&self) -> Option<(OwnedSemaphorePermit, QueuedTask)> {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
if pending.is_empty() {
return None;
}
let ownership = self.queue_permit_ownership.lock().await;
let mut queue_ids = Vec::new();
let mut seen = HashSet::new();
for task in pending.iter() {
if !ownership.contains_key(&task.id) && seen.insert(task.queue_id.clone()) {
queue_ids.push(task.queue_id.clone());
}
}
if queue_ids.is_empty() {
return None;
}
let cursor = self.dispatch_cursor.lock().await.clone();
let start = cursor
.as_ref()
.and_then(|queue_id| queue_ids.iter().position(|candidate| candidate == queue_id))
.map_or(0, |position| (position + 1) % queue_ids.len());
let global_target = self.target_capacity.load(Ordering::Relaxed);
let queue_limits = self.queue_limits.lock().await;
let selected_queue = (0..queue_ids.len()).find_map(|offset| {
let queue_id = &queue_ids[(start + offset) % queue_ids.len()];
let queue_limit = queue_limits
.get(queue_id)
.copied()
.unwrap_or(global_target)
.min(global_target);
let active = ownership
.values()
.filter(|active| active.queue_id == *queue_id)
.count();
(active < queue_limit).then_some(queue_id.clone())
})?;
let task_index = pending
.iter()
.position(|task| task.queue_id == selected_queue && !ownership.contains_key(&task.id))?;
drop(queue_limits);
drop(ownership);
let permit = self.try_acquire_permit_after_retirement()?;
let task = pending.remove(task_index)?;
self.queue_permit_ownership.lock().await.insert(
task.id.clone(),
QueuePermitOwnership {
queue_id: selected_queue.clone(),
lifecycle_generation: task.lifecycle_generation,
active: false,
},
);
*self.dispatch_cursor.lock().await = Some(selected_queue);
Some((permit, task))
}
/// Park an already-acquired permit under `id`.
pub async fn park_permit(&self, id: &str, permit: OwnedSemaphorePermit) {
self.park_permit_for_queue(id, "main", 0, permit).await;
}
async fn park_permit_for_queue(
&self,
id: &str,
queue_id: &str,
lifecycle_generation: u64,
permit: OwnedSemaphorePermit,
) {
let _admission_gate = self.admission_gate.lock().await;
self.active_permits
.lock()
.await
.insert(id.to_string(), permit);
self.queue_permit_ownership.lock().await.insert(
id.to_string(),
QueuePermitOwnership {
queue_id: queue_id.to_string(),
lifecycle_generation,
active: true,
},
);
self.sync_power_activity().await;
}
/// Park a candidate only when no newer lifecycle has already claimed the
/// download. Dropping a duplicate candidate returns its slot safely.
pub async fn park_aria2_permit_if_missing(
&self,
id: &str,
permit: OwnedSemaphorePermit,
) -> bool {
self.park_aria2_permit_if_missing_for_queue(id, "main", 0, permit)
.await
}
/// Activate a queue-aware resume reservation. The caller owns the permit
/// while it revalidates the lifecycle under the per-download control lock.
pub async fn park_aria2_permit_if_missing_for_queue(
&self,
id: &str,
queue_id: &str,
lifecycle_generation: u64,
permit: OwnedSemaphorePermit,
) -> bool {
let _admission_gate = self.admission_gate.lock().await;
let registered_generation = self
.registered_lifecycle_generations
.lock()
.await
.get(id)
.copied();
let mut ownership = self.queue_permit_ownership.lock().await;
let has_matching_reservation = ownership.get(id).is_some_and(|entry| {
entry.queue_id == queue_id
&& entry.lifecycle_generation == lifecycle_generation
&& !entry.active
&& (registered_generation == Some(lifecycle_generation)
|| (registered_generation.is_none() && lifecycle_generation == 0))
});
if ownership.contains_key(id) && !has_matching_reservation {
let remove_reservation = ownership
.get(id)
.is_some_and(|entry| entry.lifecycle_generation == lifecycle_generation && !entry.active);
if remove_reservation {
ownership.remove(id);
self.notify.notify_waiters();
}
return false;
}
if ownership.get(id).is_none()
&& registered_generation.is_some_and(|current| current != lifecycle_generation)
{
return false;
}
let mut permits = self.active_permits.lock().await;
if permits.contains_key(id) {
return false;
}
permits.insert(id.to_string(), permit);
drop(permits);
if let Some(entry) = ownership.get_mut(id) {
entry.active = true;
} else {
ownership.insert(
id.to_string(),
QueuePermitOwnership {
queue_id: queue_id.to_string(),
lifecycle_generation,
active: true,
},
);
}
drop(ownership);
self.active_permit_generations
.lock()
.await
.insert(id.to_string(), lifecycle_generation);
self.active_kinds
.lock()
.await
.insert(id.to_string(), TaskKind::Aria2);
self.sync_power_activity().await;
true
}
pub async fn release_aria2_permit_candidate(&self, id: &str, lifecycle_generation: u64) {
self.release_queue_reservation_for_generation(id, lifecycle_generation)
.await;
}
pub async fn active_kind(&self, id: &str) -> Option<TaskKind> {
self.active_kinds.lock().await.get(id).cloned()
}
/// Ensure an aria2 transfer owns exactly one queue permit. Returns true
/// when this call acquired and parked the permit, false when one was
/// already parked.
pub async fn ensure_aria2_permit(&self, id: &str) -> bool {
self.ensure_aria2_permit_for_queue(id, "main").await
}
/// Ensure a paused or externally recovered aria2 transfer owns one global
/// permit and one slot in its queue. This waits without holding a
/// per-download control lock and wakes on either kind of capacity change.
pub async fn ensure_aria2_permit_for_queue(&self, id: &str, queue_id: &str) -> bool {
loop {
if self.has_active_permit(id).await
|| self.queue_permit_ownership.lock().await.contains_key(id)
{
return false;
}
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let generation = self
.registered_lifecycle_generation(id)
.await
.unwrap_or_default();
if let Some(permit) = self
.try_reserve_queue_slot(id, queue_id, generation)
.await
{
if self.park_aria2_permit_if_missing_for_queue(
id,
queue_id,
generation,
permit,
)
.await
{
self.active_kinds
.lock()
.await
.insert(id.to_string(), TaskKind::Aria2);
return true;
}
return false;
}
notified.await;
}
}
pub async fn release_permit(&self, id: &str) {
let _admission_gate = self.admission_gate.lock().await;
let queue_removed = self.queue_permit_ownership.lock().await.remove(id).is_some();
let removed = self.active_permits.lock().await.remove(id).is_some();
self.active_permit_generations.lock().await.remove(id);
self.active_kinds.lock().await.remove(id);
if removed || queue_removed {
self.notify.notify_waiters();
}
drop(_admission_gate);
if removed {
self.sync_power_activity().await;
}
}
pub(crate) async fn release_permit_for_generation(&self, id: &str, generation: u64) {
let _admission_gate = self.admission_gate.lock().await;
let removed = {
let mut permits = self.active_permits.lock().await;
let mut generations = self.active_permit_generations.lock().await;
let queue_owned = self
.queue_permit_ownership
.lock()
.await
.get(id)
.is_some_and(|ownership| ownership.lifecycle_generation == generation);
if queue_owned || generations.get(id).copied() == Some(generation) {
generations.remove(id);
let active_removed = permits.remove(id).is_some();
if queue_owned {
self.queue_permit_ownership.lock().await.remove(id);
}
active_removed || queue_owned
} else {
false
}
};
if removed {
self.active_kinds.lock().await.remove(id);
self.notify.notify_waiters();
drop(_admission_gate);
self.sync_power_activity().await;
}
}
pub async fn has_active_permit(&self, id: &str) -> bool {
self.active_permits.lock().await.contains_key(id)
}
/// Clear all permits belonging to aria2. Useful when aria2 WS connection drops.
pub async fn clear_aria2_permits(&self) {
let ids_to_fail: Vec<String> = {
let kinds = self.active_kinds.lock().await;
kinds
.iter()
.filter(|(_, kind)| matches!(kind, TaskKind::Aria2))
.map(|(id, _)| id.clone())
.collect()
};
for id in ids_to_fail {
let _control_guard = self.acquire_aria2_control(&id).await;
if matches!(self.active_kind(&id).await, Some(TaskKind::Aria2)) {
self.apply_completion_locked(
&id,
PendingOutcome::Error("Aria2 WebSocket connection lost".to_string()),
)
.await;
}
}
}
/// Number of un-acquired permits currently in the semaphore pool.
pub fn available_permits(&self) -> usize {
self.semaphore.available_permits()
}
fn emit_state(&self, id: impl Into<String>, status: DownloadStatus) {
use tauri::Emitter;
let _ = self
.app_handle
.emit("download-state", DownloadStateEvent::new(id, status));
}
/// Resize the global concurrency limit. Grow adds permits immediately;
/// shrink records a retirement debt honored lazily by the dispatcher.
pub fn set_capacity(&self, new_target: usize) {
let prev_target = self.target_capacity.swap(new_target, Ordering::Relaxed);
if new_target == prev_target {
return;
}
if new_target > prev_target {
let mut delta = new_target - prev_target;
loop {
let debt = self.slots_to_retire.load(Ordering::Relaxed);
let to_deduct = std::cmp::min(debt, delta);
if self
.slots_to_retire
.compare_exchange_weak(
debt,
debt - to_deduct,
Ordering::Relaxed,
Ordering::Relaxed,
)
.is_ok()
{
delta -= to_deduct;
break;
}
}
if delta > 0 {
self.semaphore.add_permits(delta);
}
self.notify.notify_waiters();
} else {
let delta = prev_target - new_target;
self.slots_to_retire.fetch_add(delta, Ordering::Relaxed);
self.notify.notify_waiters();
}
}
/// Test accessor for the retirement debt counter.
pub fn slots_to_retire_load(&self) -> usize {
self.slots_to_retire.load(Ordering::Relaxed)
}
/// The long-running dispatcher. One instance is spawned in setup().
/// It scans for a queue with capacity before reserving the global slot, so
/// a saturated front queue cannot block later eligible queues.
pub async fn run_dispatcher(self: Arc<Self>) {
loop {
let notified = self.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if self.try_start_waiting_seed().await {
continue;
}
if let Some((permit, task)) = self.try_admit_next_task().await {
// Admission owns the global and per-queue reservation before
// this task is spawned. Keep the dispatcher free to admit
// other work while an Aria2 addUri call is retrying or a
// control RPC is slow; dispatch_one retains the reservation
// until that lifecycle reaches its own terminal path.
let manager = Arc::clone(&self);
tauri::async_runtime::spawn(async move {
manager.dispatch_one(permit, task).await;
});
} else {
// This covers both an empty pending list and the case where
// all queue/global capacity is occupied. The notification
// future is created before inspection to close the lost-wake
// window without polling or sleeping.
notified.await;
}
}
}
async fn dispatch_one(self: Arc<Self>, permit: OwnedSemaphorePermit, task: QueuedTask) {
let id = task.id.clone();
let lifecycle_generation = task.lifecycle_generation;
// Serialize activation with pause/remove/detach. The dispatcher can
// pop a task just as a control command arrives; registering the
// permit and active kind under the same guard prevents that command
// from observing a half-started lifecycle.
let control_guard = self.acquire_aria2_control(&id).await;
if !self
.is_registered_generation(&id, lifecycle_generation)
.await
{
self.release_queue_reservation_for_generation(&id, lifecycle_generation)
.await;
drop(control_guard);
drop(permit);
return;
}
// Activate the reservation BEFORE spawning. Uniform parking:
// aria2's RPC returns instantly, so the permit must outlive the
// dispatch_one call. Media runners release on exit.
if !self
.activate_admitted_permit(&id, lifecycle_generation, permit)
.await
{
drop(control_guard);
return;
}
self.active_kinds
.lock()
.await
.insert(id.clone(), task.kind.clone());
let aria2_lifecycle_epoch = if matches!(&task.kind, TaskKind::Aria2) {
// Every backend aria2 dispatch starts a new control lifecycle.
// This invalidates retry workers left behind by a previous
// failed or cancelled lifecycle before retry cancellation is
// made reusable for the new task.
let lifecycle_epoch = self.next_aria2_control_epoch(&id).await;
self.aria2_retry_cancelled.lock().await.remove(&id);
self.aria2_payloads
.lock()
.await
.insert(id.clone(), task.payload.clone());
self.aria2_retry_strikes.lock().await.remove(&id);
Some(lifecycle_epoch)
} else {
None
};
if let Some(epoch) = aria2_lifecycle_epoch {
self.begin_aria2_dispatch(&id, epoch).await;
}
drop(control_guard);
// Media runners do not receive an Aria2 GID. Their permit is already
// active at this point, so publish their live state before spawning
// the runner; Aria2 tasks publish only after remember_gid below.
if matches!(&task.kind, TaskKind::Media) {
self.emit_state(&id, DownloadStatus::Downloading);
}
match task.kind {
TaskKind::Aria2 => {
let lifecycle_epoch = aria2_lifecycle_epoch
.expect("aria2 dispatch must initialize a control epoch");
let add_result = self.spawner.add_uri(&id, &task.payload).await;
match add_result {
Ok(gid) => {
let control_guard = self.acquire_aria2_control(&id).await;
let cancelled = self.aria2_retry_cancelled.lock().await.contains(&id);
let current_lifecycle = self
.is_aria2_control_epoch_current(&id, lifecycle_epoch)
.await
&& self.is_registered(&id).await;
if cancelled || !current_lifecycle {
drop(control_guard);
log::info!(
"aria2 dispatch cancellation [{}]: removing late gid {}",
id,
gid
);
if task.payload.is_torrent {
if let Err(error) = self
.reconcile_aria2_torrent_ownership_for_payload(
&id,
&gid,
&task.payload,
)
.await
{
log::debug!(
"aria2 dispatch cancellation [{}]: could not resolve torrent output paths for gid {}: {}",
id,
gid,
error
);
}
}
if let Err(error) = self.spawner.remove_uri(&gid).await {
log::warn!(
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
id,
gid,
error
);
}
self.ignore_aria2_gid(&gid).await;
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
if current_lifecycle {
self.clear_aria2_retry_state(&id).await;
self.release_permit(&id).await;
}
return;
}
// A queued task is not a live transfer until aria2 has
// accepted it and Firelink has installed the GID
// mapping. Emitting Downloading before this point
// lets the UI (and a concurrent Properties pause)
// act on a lifecycle that does not yet exist.
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
self.emit_state(&id, DownloadStatus::Downloading);
let install_web_seeds = buffered_outcome.is_none()
&& task.payload.is_torrent
&& !task.payload.torrent_verify_only
&& task.payload.torrent_web_seeds.is_some();
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
drop(control_guard);
if install_web_seeds {
if let Err(error) = self.install_initial_torrent_web_seeds(&id).await {
// Initial web-seed installation is a
// post-GID lifecycle step. Reacquire the
// control lock and fence the failure against
// the dispatch epoch before retiring the GID;
// an old install error must never fail a
// newer retry/resume lifecycle.
let _control_guard = self.acquire_aria2_control(&id).await;
let current_gid = self.aria2_gid_for_download(&id);
let current = self
.is_aria2_control_epoch_current(&id, lifecycle_epoch)
.await
&& self.is_registered(&id).await
&& matches!(self.active_kind(&id).await, Some(TaskKind::Aria2))
&& current_gid.as_deref().is_some_and(|gid| {
self.aria2_gid_mapping(gid).is_some_and(|mapping| {
mapping.id == id && mapping.epoch == lifecycle_epoch
})
});
if current {
if let Some(current_gid) = current_gid {
if let Err(remove_error) = self.spawner.remove_uri(&current_gid).await {
log::warn!(
"aria2 dispatch web-seed failure [{}]: could not remove gid {}: {}",
id,
current_gid,
remove_error
);
}
self.ignore_aria2_gid(&current_gid).await;
}
self.apply_completion_locked(
&id,
PendingOutcome::Error(format!(
"could not attach Torrent web seeds: {error}"
)),
)
.await;
} else {
log::info!(
"aria2 dispatch web-seed failure [{}]: ignoring stale install error after a newer lifecycle took ownership",
id
);
}
return;
}
}
if let Some(outcome) = buffered_outcome {
self.handle_aria2_event(&gid, outcome).await;
}
}
Err(error) => {
let _control_guard = self.acquire_aria2_control(&id).await;
let current_lifecycle = self
.is_aria2_control_epoch_current(&id, lifecycle_epoch)
.await
&& self.is_registered(&id).await;
if current_lifecycle {
self.next_aria2_control_epoch(&id).await;
self.cancel_aria2_retries(&id).await;
self.clear_aria2_retry_state(&id).await;
self.release_permit(&id).await;
self.release_registered_id(&id).await;
self.emit_failed(&id, error);
} else {
log::info!(
"aria2 dispatch [{}]: ignoring stale addUri failure after a newer lifecycle took ownership",
id
);
}
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
}
}
}
TaskKind::Media => {
let this = Arc::clone(&self);
let payload = task.payload.clone();
let id_for_task = id.clone();
tauri::async_runtime::spawn(async move {
let outcome = this
.spawner
.run_media(&id_for_task, &payload, lifecycle_generation)
.await;
this.finish_runner(&id_for_task, lifecycle_generation, outcome)
.await;
});
}
}
}
/// Terminal handler for non-aria2 transfers. Emits state and frees the permit.
/// Intentional cancellation is silent, but still releases backend ownership.
/// Note: `id` is the frontend download UUID, which survives indefinitely as
/// the terminal state.
async fn finish_runner(
self: Arc<Self>,
id: &str,
lifecycle_generation: u64,
outcome: Result<(), String>,
) {
// Completion and pause/remove/detach must observe one ordered
// lifecycle. Without this guard a terminal media outcome could emit
// Completed, then a racing pause could emit Paused afterward.
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered_generation(id, lifecycle_generation).await {
log::info!(
"media runner [{}]: ignoring stale terminal outcome for lifecycle {}",
id,
lifecycle_generation
);
self.release_permit_for_generation(id, lifecycle_generation).await;
return;
}
match outcome {
Ok(()) => {
self.emit_state(id, DownloadStatus::Completed);
self.release_registered_id_for_generation(id, lifecycle_generation)
.await;
}
Err(error) if error == MEDIA_RUN_CANCELLED => {
self.release_registered_id_for_generation(id, lifecycle_generation)
.await;
}
Err(error) => {
self.emit_failed(id, error);
self.release_registered_id_for_generation(id, lifecycle_generation)
.await;
}
}
self.release_permit_for_generation(id, lifecycle_generation)
.await;
}
fn emit_failed(&self, id: &str, error: String) {
use tauri::Emitter;
let _ = self
.app_handle
.emit("download-state", DownloadStateEvent::failed(id, error));
}
fn emit_paused_with_error(&self, id: &str, error: String) {
use tauri::Emitter;
let _ = self.app_handle.emit(
"download-state",
DownloadStateEvent::paused_with_error(id, error),
);
}
/// Store gid -> id and return any buffered terminal event for the caller
/// to reconcile against the correct event path. In particular, buffered
/// errors must still pass through transient retry classification.
pub async fn remember_gid(&self, id: String, gid: String) -> Option<PendingOutcome> {
let epoch = self.current_aria2_control_epoch(&id).await;
let buffered_outcome = {
let _gid_state = self.aria2_gid_state.lock().await;
let mut replaced_gids = Vec::new();
{
let mut gids = self.aria2_gids.write().unwrap();
gids.retain(|existing_gid, existing_id| {
let keep = existing_id.id != id.as_str() || existing_gid == &gid;
if !keep {
replaced_gids.push(existing_gid.clone());
log::warn!(
"aria2 gid transition [{}]: dropping stale mapping {} before storing {}",
id,
existing_gid,
gid
);
}
keep
});
gids.insert(
gid.clone(),
Aria2GidMapping {
id: id.clone(),
epoch,
},
);
}
self.unignore_aria2_gid_locked(&gid).await;
for replaced_gid in &replaced_gids {
self.ignore_aria2_gid_locked(replaced_gid).await;
}
let mut buffered = self.pending_completion.lock().await;
for replaced_gid in &replaced_gids {
buffered.remove(replaced_gid);
}
buffered.remove(&gid).map(|(_buf_id, outcome)| outcome)
};
log::info!("aria2 gid transition [{}]: mapped {}", id, gid);
buffered_outcome
}
/// Rebind an existing paused GID to the new lifecycle created by resume.
/// The GID remains stable across aria2.pause/unpause, but its previous
/// epoch must not be reused after a pause invalidated that lifecycle.
pub async fn rebind_aria2_gid_epoch(&self, id: &str, gid: &str, epoch: u64) -> bool {
let _gid_state = self.aria2_gid_state.lock().await;
let mut gids = self.aria2_gids.write().unwrap();
let Some(mapping) = gids.get_mut(gid) else {
return false;
};
if mapping.id != id {
return false;
}
mapping.epoch = epoch;
true
}
/// Apply an aria2 completion outcome: release permit + emit state.
pub async fn apply_completion(&self, id: &str, outcome: PendingOutcome) {
let _control_guard = self.acquire_aria2_control(id).await;
self.apply_completion_locked(id, outcome).await;
}
/// Apply a terminal outcome while the caller owns the download control
/// lock. Keeping the epoch transition and terminal cleanup under that
/// lock prevents an old WebSocket event from completing a newer lifecycle
/// and lets commands reconcile an Aria2 terminal status without releasing
/// the lock first.
pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) {
if matches!(&outcome, PendingOutcome::Complete) {
self.capture_torrent_verification_evidence(id).await;
}
let (verification_restore_status, verification_only, verification_observed) = {
let payloads = self.aria2_payloads.lock().await;
payloads
.get(id)
.filter(|payload| payload.torrent_verify_only)
.map(|payload| {
(
payload.torrent_verify_restore_status.clone(),
true,
payload.torrent_verified_length.is_some(),
)
})
.unwrap_or((None, false, false))
};
let outcome = match outcome {
PendingOutcome::Complete if verification_only && !verification_observed => {
PendingOutcome::Error(
"Torrent integrity verification did not produce a complete hash-check result"
.to_string(),
)
}
PendingOutcome::Seeding if self.aria2_torrent_seeding_requested(id).await => {
if !self.seed_capacity_enabled() {
// Keep a budget record even when the legacy single-pool
// mode is active. If the user enables separate seed
// capacity while this Torrent is already seeding, the
// existing seeder must be counted before admitting new
// seeders.
self.record_seed_started(id).await;
self.emit_state(id, DownloadStatus::Seeding);
return;
}
match self.admit_seed_after_completion(id).await {
SeedAdmissionOutcome::Seeding => {
self.emit_state(id, DownloadStatus::Seeding);
return;
}
SeedAdmissionOutcome::Waiting => {
let remaining = self
.aria2_payloads
.lock()
.await
.get(id)
.and_then(|payload| payload.torrent_seed_remaining);
self.emit_waiting_to_seed(id, remaining);
return;
}
SeedAdmissionOutcome::Complete => PendingOutcome::Complete,
}
}
// `onBtDownloadComplete` means that Aria2 is still seeding. If
// Firelink has no seeding policy for this payload, reconcile it
// as terminal so a daemon-side default or stale event cannot
// strand the GID and permit.
PendingOutcome::Seeding => PendingOutcome::Complete,
other => other,
};
if let Some(gid) = self.aria2_gid_for_download(id) {
if let Err(error) = self.reconcile_aria2_torrent_ownership(id, &gid).await {
log::debug!(
"aria2 torrent ownership [{}]: could not resolve files for gid {}: {}",
id,
gid,
error
);
}
}
// A terminal event invalidates every delayed retry or control worker
// from the previous lifecycle before releasing its permit.
self.next_aria2_control_epoch(id).await;
self.cancel_aria2_retries(id).await;
let torrent_removal_requested = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent && payload.torrent_remove_unselected_file);
match outcome {
PendingOutcome::Complete => {
self.forget_torrent_telemetry(id).await;
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
if torrent_removal_requested {
match crate::download_ownership::clear_torrent_removal_paths_if_absent(
&self.app_handle,
id,
) {
Ok(true) => {}
Ok(false) => {
log::warn!(
"aria2 torrent removal reservation [{}]: keeping paths reserved because Aria2 cleanup was not observed",
id
);
}
Err(error) => {
log::warn!(
"aria2 torrent removal reservation [{}]: could not verify cleanup: {}",
id,
error
);
}
}
}
self.release_registered_id(id).await;
self.release_permit(id).await;
let restored_status = if verification_only {
if verification_restore_status.as_deref() == Some("completed") {
DownloadStatus::Completed
} else {
DownloadStatus::Paused
}
} else {
match verification_restore_status.as_deref() {
Some("paused") => DownloadStatus::Paused,
Some("failed") => DownloadStatus::Failed,
Some("ready") => DownloadStatus::Ready,
Some("staged") => DownloadStatus::Staged,
Some("completed") => DownloadStatus::Completed,
_ => DownloadStatus::Completed,
}
};
self.emit_state(id, restored_status);
}
PendingOutcome::Error(error) => {
self.forget_torrent_telemetry(id).await;
if !verification_only && error.to_ascii_lowercase().contains("checksum") {
log::warn!("Checksum error detected for {}, cleaning up assets", id);
if let Ok(paths) =
crate::download_ownership::owned_paths_for_id(&self.app_handle, id)
{
for path in paths {
let _ = crate::remove_download_assets(&path, &self.app_handle).await;
}
}
}
let error = if verification_only {
format!(
"Torrent integrity verification failed; resume the Torrent to repair it: {error}"
)
} else {
error
};
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
if torrent_removal_requested {
match crate::download_ownership::clear_torrent_removal_paths_if_absent(
&self.app_handle,
id,
) {
Ok(true) => {}
Ok(false) => log::warn!(
"aria2 torrent removal reservation [{}]: keeping paths reserved because cleanup was not observed after failure",
id
),
Err(clear_error) => log::warn!(
"aria2 torrent removal reservation [{}]: could not verify cleanup after terminal failure: {}",
id,
clear_error
),
}
}
self.release_registered_id(id).await;
self.release_permit(id).await;
if verification_only {
self.emit_paused_with_error(id, error);
} else {
let safe_error = crate::redact_sensitive_text(&error);
log::error!("aria2 download {} failed: {}", id, safe_error);
self.emit_failed(id, error);
}
}
PendingOutcome::Seeding => unreachable!("seeding outcomes are normalized before terminal cleanup"),
}
}
pub async fn clear_aria2_retry_state(&self, id: &str) {
self.aria2_payloads.lock().await.remove(id);
self.aria2_retry_strikes.lock().await.remove(id);
}
pub async fn cancel_aria2_retries(&self, id: &str) {
self.aria2_retry_cancelled
.lock()
.await
.insert(id.to_string());
self.aria2_retry_cancel_notify.notify_waiters();
self.notify.notify_waiters();
}
pub async fn allow_aria2_retries(&self, id: &str) {
self.aria2_retry_cancelled.lock().await.remove(id);
}
/// A user-initiated resume starts a new retry lifecycle while retaining
/// the paused Aria2 payload/GID. Reset only the strike budget here; the
/// payload is still needed for the same-GID resume path.
pub async fn reset_aria2_retry_strikes(&self, id: &str) {
self.aria2_retry_strikes.lock().await.remove(id);
}
async fn finish_aria2_retry(&self, id: &str, gid: &str, retry_epoch: u64) {
self.release_aria2_retry_inflight(id, retry_epoch).await;
self.aria2_retrying_gids.lock().await.remove(gid);
}
async fn release_aria2_retry_inflight(&self, id: &str, retry_epoch: u64) {
let mut inflight = self.aria2_retry_inflight.lock().await;
if inflight.get(id).copied() == Some(retry_epoch) {
inflight.remove(id);
}
}
async fn ignore_aria2_gid(&self, gid: &str) {
let _gid_state = self.aria2_gid_state.lock().await;
self.ignore_aria2_gid_locked(gid).await;
}
async fn remove_stale_aria2_gid(&self, id: &str, gid: &str) {
const MAX_ATTEMPTS: usize = 3;
for attempt in 1..=MAX_ATTEMPTS {
match self.spawner.remove_uri(gid).await {
Ok(()) => {
log::info!(
"aria2 lifecycle cleanup [{}]: removed stale replacement gid {} on attempt {}",
id,
gid,
attempt
);
return;
}
Err(error) if attempt < MAX_ATTEMPTS => {
log::warn!(
"aria2 lifecycle cleanup [{}]: failed to remove stale replacement gid {} on attempt {}: {}; retrying",
id,
gid,
attempt,
error
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(error) => {
log::error!(
"aria2 lifecycle cleanup [{}]: stale replacement gid {} could not be removed after {} attempts: {}",
id,
gid,
MAX_ATTEMPTS,
error
);
}
}
}
}
async fn ignore_aria2_gid_locked(&self, gid: &str) {
const MAX_IGNORED_GIDS: usize = 1024;
let mut ignored = self.aria2_ignored_gids.lock().await;
if !ignored.iter().any(|known| known == gid) {
ignored.push_back(gid.to_string());
}
while ignored.len() > MAX_IGNORED_GIDS {
ignored.pop_front();
}
}
async fn unignore_aria2_gid_locked(&self, gid: &str) {
self.aria2_ignored_gids
.lock()
.await
.retain(|known| known != gid);
}
async fn is_aria2_gid_ignored_locked(&self, gid: &str) -> bool {
self.aria2_ignored_gids
.lock()
.await
.iter()
.any(|known| known == gid)
}
pub fn aria2_gid_for_download(&self, id: &str) -> Option<String> {
self.aria2_gids
.read()
.unwrap()
.iter()
.find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone()))
}
async fn begin_aria2_dispatch(&self, id: &str, epoch: u64) {
self.aria2_dispatch_inflight
.lock()
.await
.entry(id.to_string())
.or_default()
.insert(epoch);
}
async fn finish_aria2_dispatch(&self, id: &str, epoch: u64) {
let mut inflight = self.aria2_dispatch_inflight.lock().await;
let removed = if let Some(epochs) = inflight.get_mut(id) {
let removed = epochs.remove(&epoch);
if epochs.is_empty() {
inflight.remove(id);
}
removed
} else {
false
};
drop(inflight);
if removed {
self.aria2_dispatch_notify.notify_waiters();
}
}
pub async fn wait_for_aria2_dispatch(&self, id: &str) {
loop {
let notified = self.aria2_dispatch_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if !self
.aria2_dispatch_inflight
.lock()
.await
.contains_key(id)
{
return;
}
notified.await;
}
}
async fn reconcile_aria2_torrent_ownership_for_payload(
&self,
id: &str,
gid: &str,
payload: &SpawnPayload,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getFiles",
serde_json::json!([gid]),
)
.await?;
self.persist_aria2_torrent_ownership(id, payload, result)
}
fn persist_aria2_torrent_ownership(
&self,
id: &str,
payload: &SpawnPayload,
result: serde_json::Value,
) -> Result<(), String> {
let paths = result
.as_array()
.into_iter()
.flatten()
.filter(|file| file.get("selected").and_then(|value| value.as_str()) != Some("false"))
.filter_map(|file| file.get("path").and_then(|value| value.as_str()))
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
if paths.is_empty() {
return Err("aria2 returned no selected torrent output paths".to_string());
}
let destination = crate::resolve_path(&payload.destination, &self.app_handle);
let canonical_destination = crate::canonicalize_with_missing_components(&destination)
.ok_or_else(|| "torrent destination could not be canonicalized".to_string())?;
if paths.iter().any(|path| {
crate::canonicalize_with_missing_components(path)
.is_none_or(|path| !crate::platform::path_is_within(&path, &canonical_destination))
}) {
return Err("aria2 returned a torrent output path outside its destination".to_string());
}
let primary = if paths.len() == 1 {
paths[0].clone()
} else {
let canonical_paths = paths
.iter()
.filter_map(|path| crate::canonicalize_with_missing_components(path))
.collect::<Vec<_>>();
let mut common = canonical_paths
.first()
.and_then(|path| path.parent())
.map(std::path::Path::to_path_buf)
.ok_or_else(|| "torrent output path has no parent".to_string())?;
for path in canonical_paths.iter().skip(1) {
while !crate::platform::path_is_within(path, &common) {
let Some(parent) = common.parent() else {
return Err("torrent output paths have no common directory".to_string());
};
if parent == common {
return Err("torrent output paths have no common directory".to_string());
}
common = parent.to_path_buf();
}
}
common
};
crate::download_ownership::set_owned_paths_with_primary(
&self.app_handle,
id,
&primary,
&paths,
)
}
/// Refresh ownership from Aria2's resolved file list before a torrent
/// lifecycle is forgotten. Magnet metadata is not available when the row
/// is enqueued, so the user-provided display name is not a safe ownership
/// path until Aria2 reports the actual files.
pub async fn reconcile_aria2_torrent_ownership(
&self,
id: &str,
gid: &str,
) -> Result<(), String> {
let payload = self.aria2_payloads.lock().await.get(id).cloned();
let Some(payload) = payload.filter(|payload| payload.is_torrent) else {
return Ok(());
};
let mapping = self
.aria2_gid_mapping(gid)
.filter(|mapping| mapping.id == id)
.ok_or_else(|| "aria2 torrent ownership has no current gid mapping".to_string())?;
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getFiles",
serde_json::json!([gid]),
)
.await?;
if !self.is_current_aria2_gid_mapping(gid, &mapping)
|| !self
.is_aria2_control_epoch_current(id, mapping.epoch)
.await
{
return Err("aria2 torrent lifecycle changed while resolving output paths".to_string());
}
self.persist_aria2_torrent_ownership(id, &payload, result)
}
/// Capture the GID ownership token used by the poller. The mapping's
/// epoch must still be current when an asynchronous status snapshot is
/// emitted; otherwise a late response from an older GID can be attributed
/// to a newer lifecycle for the same download id.
pub fn aria2_gid_mapping(&self, gid: &str) -> Option<Aria2GidMapping> {
self.aria2_gids.read().unwrap().get(gid).cloned()
}
pub fn is_current_aria2_gid_mapping(
&self,
gid: &str,
expected: &Aria2GidMapping,
) -> bool {
self.aria2_gid_mapping(gid).as_ref() == Some(expected)
}
pub fn aria2_gid_mappings(&self) -> Vec<(String, String)> {
self.aria2_gids
.read()
.unwrap()
.iter()
.map(|(gid, mapping)| (gid.clone(), mapping.id.clone()))
.collect()
}
/// Recycle an active transfer's connections after the poller observes a
/// persistent connection-pool collapse or a true zero-progress stall.
/// The observed epoch must still own the GID before the refresh can act.
pub async fn refresh_aria2_connections(
self: &Arc<Self>,
id: &str,
gid: &str,
observed_epoch: u64,
) -> Result<(), String> {
let _control_guard = self.acquire_aria2_control(id).await;
if self.aria2_gid_for_download(id).as_deref() != Some(gid)
|| !self.is_registered(id).await
|| !self.has_active_permit(id).await
|| self.is_aria2_retry_cancelled(id).await
|| !self.is_aria2_control_epoch_current(id, observed_epoch).await
{
return Ok(());
}
let payload = self.aria2_payloads.lock().await.get(id).cloned();
let recreation = if let Some(payload) = payload.as_ref() {
self.spawner.recreate_uri(id, gid, payload).await?
} else {
// Older persisted rows may briefly reach recovery before their
// payload has been rebuilt. Keep the current lifecycle intact and
// use the non-destructive fallback until the payload is present.
Aria2RecreateOutcome::Refresh
};
if let Aria2RecreateOutcome::NewGid(new_gid) = recreation {
if new_gid.trim().is_empty() || new_gid == gid {
return Err(format!(
"aria2 connection recovery returned an invalid replacement gid for {gid}"
));
}
let still_current = self.is_registered(id).await
&& !self.is_aria2_retry_cancelled(id).await
&& self.is_aria2_control_epoch_current(id, observed_epoch).await
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
if !still_current {
self.ignore_aria2_gid(&new_gid).await;
drop(_control_guard);
self.remove_stale_aria2_gid(id, &new_gid).await;
log::info!(
"aria2 connection recovery [{}]: replacement gid {} became stale before rebind",
id,
new_gid
);
return Ok(());
}
let buffered_outcome = self.remember_gid(id.to_string(), new_gid.clone()).await;
log::info!(
"aria2 connection recovery [{}]: recreated gid {} from {} while retaining the lifecycle permit",
id,
new_gid,
gid
);
drop(_control_guard);
if let Some(outcome) = buffered_outcome {
self.handle_aria2_event(&new_gid, outcome).await;
}
return Ok(());
}
let outcome = match recreation {
Aria2RecreateOutcome::Complete => Aria2RefreshOutcome::Complete,
Aria2RecreateOutcome::Refresh => self.spawner.refresh_uri(gid).await?,
Aria2RecreateOutcome::Unavailable(error) => {
let still_current = self.is_registered(id).await
&& self.has_active_permit(id).await
&& !self.is_aria2_retry_cancelled(id).await
&& self.is_aria2_control_epoch_current(id, observed_epoch).await
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
if !still_current {
return Ok(());
}
self.next_aria2_control_epoch(id).await;
self.cancel_aria2_retries(id).await;
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
self.release_registered_id(id).await;
self.release_permit(id).await;
self.emit_state(id, DownloadStatus::Paused);
log::warn!(
"aria2 connection recovery [{}]: replacement job unavailable; retired stale gid {} and paused the download: {}",
id,
gid,
error
);
return Ok(());
}
Aria2RecreateOutcome::NewGid(_) => unreachable!("replacement gid handled above"),
};
let still_current = self.is_registered(id).await
&& !self.is_aria2_retry_cancelled(id).await
&& self.is_aria2_control_epoch_current(id, observed_epoch).await
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
if !still_current {
log::info!(
"aria2 connection refresh [{}]: control state changed while refreshing gid {}; leaving the newer action in charge",
id,
gid
);
return Ok(());
}
match outcome {
Aria2RefreshOutcome::Resumed => {}
Aria2RefreshOutcome::Paused => {
self.next_aria2_control_epoch(id).await;
self.cancel_aria2_retries(id).await;
self.release_permit(id).await;
self.emit_state(id, DownloadStatus::Paused);
log::warn!(
"aria2 connection refresh [{}]: gid {} remained paused; released its queue permit",
id,
gid
);
}
Aria2RefreshOutcome::Complete => {
self.apply_completion_locked(id, PendingOutcome::Complete)
.await;
log::info!(
"aria2 connection refresh [{}]: gid {} completed during recovery",
id,
gid
);
}
}
Ok(())
}
/// Remove every gid mapping for a download and discard buffered terminal
/// events for those gids. Returns the most recently encountered gid.
pub async fn forget_aria2_gid(&self, id: &str) -> Option<String> {
let _gid_state = self.aria2_gid_state.lock().await;
let removed = {
let mut gids = self.aria2_gids.write().unwrap();
let removed: Vec<String> = gids
.iter()
.filter(|(_, mapping)| mapping.id == id)
.map(|(gid, _)| gid.clone())
.collect();
for gid in &removed {
gids.remove(gid);
}
removed
};
if removed.is_empty() {
return None;
}
for gid in &removed {
self.ignore_aria2_gid_locked(gid).await;
}
let mut buffered = self.pending_completion.lock().await;
for gid in &removed {
buffered.remove(gid);
log::info!("aria2 gid transition [{}]: forgot {}", id, gid);
}
removed.last().cloned()
}
/// Intercept transient `onDownloadError` events: backoff, re-issue
/// `addUri`, and rotate the gid mapping. Permanent errors and exhausted
/// strikes fall through to a hard `Failed` state.
fn handle_aria2_download_error(
self: &Arc<Self>,
gid: String,
error: String,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
let this = Arc::clone(self);
Box::pin(async move {
this.handle_aria2_download_error_inner(&gid, error).await;
})
}
/// Resolve a WebSocket event against the GID map, or buffer it while the
/// map transition is still in flight. The state lock closes the window in
/// which an event could be inserted after remember_gid drained it.
async fn map_or_buffer_aria2_event(
&self,
gid: &str,
outcome: PendingOutcome,
) -> Option<(Aria2GidMapping, PendingOutcome)> {
let _gid_state = self.aria2_gid_state.lock().await;
if self.is_aria2_gid_ignored_locked(gid).await {
return None;
}
let mapping = {
let gids = self.aria2_gids.read().unwrap();
gids.get(gid).cloned()
};
if let Some(mapping) = mapping {
return Some((mapping, outcome));
}
self.pending_completion
.lock()
.await
.insert(gid.to_string(), (String::new(), outcome));
None
}
async fn handle_aria2_download_error_inner(self: &Arc<Self>, gid: &str, error: String) {
let Some((mapping, PendingOutcome::Error(error))) = self
.map_or_buffer_aria2_event(gid, PendingOutcome::Error(error))
.await
else {
return;
};
let _control_guard = self.acquire_aria2_control(&mapping.id).await;
let current_mapping = {
let gids = self.aria2_gids.read().unwrap();
gids.get(gid).cloned()
};
if current_mapping
.as_ref()
.is_none_or(|current| current.id != mapping.id || current.epoch != mapping.epoch)
|| !self
.is_aria2_control_epoch_current(&mapping.id, mapping.epoch)
.await
{
return;
}
let id = mapping.id;
if self.aria2_retry_cancelled.lock().await.contains(&id) {
log::info!(
"aria2 retry cancellation [{}]: ignoring error for gid {} during removal",
id,
gid
);
return;
}
if self.aria2_retrying_gids.lock().await.contains(gid) {
log::debug!(
"aria2 retry [{}]: ignoring duplicate error event for retrying gid {}",
id,
gid
);
return;
}
if self.aria2_retry_inflight.lock().await.contains_key(&id) {
log::debug!(
"aria2 retry [{}]: ignoring duplicate error event while retry handoff is in flight",
id
);
return;
}
let payload = self.aria2_payloads.lock().await.get(&id).cloned();
if payload.is_none() {
self.apply_completion_locked(&id, PendingOutcome::Error(error))
.await;
return;
}
let mut payload = payload.unwrap();
let strike = {
let mut strikes = self.aria2_retry_strikes.lock().await;
let entry = strikes.entry(id.clone()).or_insert(0);
*entry
};
let retry_action = aria2_retry_action(
&payload,
&error,
strike,
self.aria2_system_resolver_fallback_available(),
);
let resolver_fallback = retry_action == Aria2RetryAction::SystemResolverFallback;
// Switching resolver strategy is a bounded transfer repair, not an
// ordinary retry. It must still run when the user configured zero
// automatic retries, while every later failure follows the normal
// retry budget and never switches back to the first strategy.
if retry_action == Aria2RetryAction::Terminal {
self.apply_completion_locked(&id, PendingOutcome::Error(error))
.await;
return;
}
self.aria2_retrying_gids
.lock()
.await
.insert(gid.to_string());
let retry_epoch = self.current_aria2_control_epoch(&id).await;
let already_inflight = {
let mut inflight = self.aria2_retry_inflight.lock().await;
inflight.insert(id.clone(), retry_epoch).is_some()
};
if already_inflight {
self.aria2_retrying_gids.lock().await.remove(gid);
return;
}
let retry_gid = gid.to_string();
if is_aria2_range_mode_error(&error) {
log::warn!(
"aria2 range mode [{}]: server rejected bounded chunk ranges; restarting with a single connection",
id
);
payload.connections = Some(1);
if let Err(cleanup_error) = remove_incompatible_aria2_range_state(self, &id).await {
log::warn!(
"aria2 range mode [{}]: failed to remove incompatible partial state: {}",
id,
cleanup_error
);
}
self.aria2_payloads
.lock()
.await
.insert(id.clone(), payload.clone());
}
if resolver_fallback {
payload.aria2_resolver_mode = Aria2ResolverMode::System;
self.aria2_payloads
.lock()
.await
.insert(id.clone(), payload.clone());
}
let this = Arc::clone(self);
let id_for_task = id.clone();
let error_for_emit = error.clone();
tauri::async_runtime::spawn(async move {
let retry_cancel = async {
loop {
if this.is_aria2_retry_cancelled(&id_for_task).await {
break;
}
let notified = this.aria2_retry_cancel_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if this.is_aria2_retry_cancelled(&id_for_task).await {
break;
}
notified.await;
}
};
let outcome = backoff_and_emit(strike, error_for_emit, retry_cancel, |reason| {
use tauri::Emitter;
let event = if resolver_fallback {
DownloadStateEvent::retrying_with_resolver_fallback(&id_for_task, reason)
} else {
DownloadStateEvent::retrying(&id_for_task, reason)
};
let _ = this.app_handle.emit("download-state", event);
})
.await;
if outcome == BackoffOutcome::Aborted {
this.finish_aria2_retry(&id_for_task, &retry_gid, retry_epoch)
.await;
return;
}
// Serialize the payload snapshot and addUri with live speed
// changes. Without this guard, a speed update could change the
// old GID and payload just before this worker re-added a new GID
// from a stale clone, silently losing the user's limit.
let control_guard = this.acquire_aria2_control(&id_for_task).await;
let stale_before_add = !this.active_permits.lock().await.contains_key(&id_for_task)
|| this.is_aria2_retry_cancelled(&id_for_task).await
|| !this
.is_aria2_control_epoch_current(&id_for_task, retry_epoch)
.await
|| !this.is_registered(&id_for_task).await
|| this.aria2_gid_for_download(&id_for_task).as_deref() != Some(retry_gid.as_str());
let current_payload = this.aria2_payloads.lock().await.get(&id_for_task).cloned();
let Some(current_payload) = current_payload else {
drop(control_guard);
this.finish_aria2_retry(&id_for_task, &retry_gid, retry_epoch)
.await;
return;
};
if stale_before_add {
drop(control_guard);
this.finish_aria2_retry(&id_for_task, &retry_gid, retry_epoch)
.await;
return;
}
match this
.spawner
.add_uri(&id_for_task, &current_payload)
.await
{
Ok(new_gid) => {
let stale = this.is_aria2_retry_cancelled(&id_for_task).await
|| !this
.is_aria2_control_epoch_current(&id_for_task, retry_epoch)
.await
|| !this.is_registered(&id_for_task).await
|| this.aria2_gid_for_download(&id_for_task).as_deref()
!= Some(retry_gid.as_str());
if stale {
drop(control_guard);
if let Err(error) = this.spawner.remove_uri(&new_gid).await {
log::error!(
"aria2 retry cancellation [{}]: failed to remove late gid {}: {}",
id_for_task,
new_gid,
error
);
} else {
log::info!(
"aria2 retry cancellation [{}]: removed stale gid {}",
id_for_task,
new_gid
);
}
this.finish_aria2_retry(&id_for_task, &retry_gid, retry_epoch)
.await;
return;
}
if !resolver_fallback {
this.aria2_retry_strikes
.lock()
.await
.insert(id_for_task.clone(), strike + 1);
}
this.emit_state(&id_for_task, DownloadStatus::Downloading);
// Stop suppressing events for the id before exposing the
// new gid. The old gid remains marked as retrying until
// remember_gid atomically replaces its mapping, so a
// duplicate old event is still ignored while a genuine
// new-gid error is allowed through.
this.release_aria2_retry_inflight(&id_for_task, retry_epoch)
.await;
let new_gid_for_event = new_gid.clone();
let buffered_outcome = this.remember_gid(id_for_task.clone(), new_gid).await;
this.aria2_retrying_gids.lock().await.remove(&retry_gid);
drop(control_guard);
if let Some(outcome) = buffered_outcome {
this.handle_aria2_event(&new_gid_for_event, outcome).await;
}
}
Err(retry_error) => {
let stale = this.is_aria2_retry_cancelled(&id_for_task).await
|| !this
.is_aria2_control_epoch_current(&id_for_task, retry_epoch)
.await;
if !stale {
this.apply_completion_locked(
&id_for_task,
PendingOutcome::Error(retry_error),
)
.await;
}
drop(control_guard);
this.finish_aria2_retry(&id_for_task, &retry_gid, retry_epoch)
.await;
}
}
});
}
/// Entry point for the aria2 WS poller. Resolves gid -> id; if not yet
/// stored, buffers the outcome for reconciliation by remember_gid.
pub async fn handle_aria2_event(self: &Arc<Self>, gid: &str, outcome: PendingOutcome) {
if let PendingOutcome::Error(error) = outcome {
self.handle_aria2_download_error(gid.to_string(), error)
.await;
return;
}
let Some((mapping, outcome)) = self.map_or_buffer_aria2_event(gid, outcome).await else {
return;
};
let _control_guard = self.acquire_aria2_control(&mapping.id).await;
if self.aria2_retrying_gids.lock().await.contains(gid) {
return;
}
let current_mapping = {
let gids = self.aria2_gids.read().unwrap();
gids.get(gid).cloned()
};
if current_mapping
.as_ref()
.is_none_or(|current| current.id != mapping.id || current.epoch != mapping.epoch)
|| !self
.is_aria2_control_epoch_current(&mapping.id, mapping.epoch)
.await
{
return;
}
self.apply_completion_locked(&mapping.id, outcome).await;
}
/// Reorder a pending task up or down. Returns the new pending order.
/// No-op at boundaries. Does not emit (membership unchanged); the caller
/// (Tauri command) returns the order to the frontend.
pub async fn move_in_queue(
&self,
id: &str,
queue_id: &str,
direction: QueueDirection,
) -> Vec<String> {
self.move_many_in_queue(&[id.to_string()], queue_id, direction)
.await
}
/// Atomically move a selected block of pending tasks up or down. The
/// frontend uses the same block semantics for multi-selection, so keeping
/// the operation under one pending-list lock prevents partial RPC moves
/// from leaving the backend in a different order than the UI.
pub async fn move_many_in_queue(
&self,
ids: &[String],
queue_id: &str,
direction: QueueDirection,
) -> Vec<String> {
let mut pending = self.pending.lock().await;
let queue_positions = pending
.iter()
.enumerate()
.filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index))
.collect::<Vec<_>>();
let selected_positions = queue_positions
.iter()
.enumerate()
.filter_map(|(position, index)| ids.iter().any(|id| id == &pending[*index].id).then_some(position))
.collect::<Vec<_>>();
if !selected_positions.is_empty() {
let queue_tasks = queue_positions
.iter()
.map(|index| pending[*index].clone())
.collect::<Vec<_>>();
let selected_ids = ids.iter().collect::<HashSet<_>>();
let selected_tasks = queue_tasks
.iter()
.filter(|task| selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
let unselected_tasks = queue_tasks
.iter()
.filter(|task| !selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
let first_selected = *selected_positions.first().unwrap();
let last_selected = *selected_positions.last().unwrap();
let selected_count = selected_tasks.len();
let insert_index = match direction {
QueueDirection::Up => first_selected.saturating_sub(1),
QueueDirection::Down => (last_selected + 1)
.saturating_sub(selected_count)
.saturating_add(1)
.min(unselected_tasks.len()),
};
let reordered = reorder_selected_queue_tasks(&queue_tasks, ids, insert_index)
.expect("selected queue tasks were present");
for (queue_index, pending_index) in queue_positions.iter().enumerate() {
pending[*pending_index] = reordered[queue_index].clone();
}
}
pending
.iter()
.filter(|task| task.queue_id == queue_id)
.map(|task| task.id.clone())
.collect()
}
/// Atomically place a selected block at an insertion index among the
/// unselected tasks in one queue. This is the drag/drop counterpart to
/// move_many_in_queue; the index is clamped at the backend boundary so a
/// stale pointer position cannot create an invalid queue state.
pub async fn move_many_in_queue_to(
&self,
ids: &[String],
queue_id: &str,
target_index: usize,
) -> Vec<String> {
let mut pending = self.pending.lock().await;
let queue_positions = pending
.iter()
.enumerate()
.filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index))
.collect::<Vec<_>>();
let queue_tasks = queue_positions
.iter()
.map(|index| pending[*index].clone())
.collect::<Vec<_>>();
if let Some(reordered) = reorder_selected_queue_tasks_in_order(&queue_tasks, ids, target_index) {
for (queue_index, pending_index) in queue_positions.iter().enumerate() {
pending[*pending_index] = reordered[queue_index].clone();
}
}
pending
.iter()
.filter(|task| task.queue_id == queue_id)
.map(|task| task.id.clone())
.collect()
}
/// Remove a task from pending if present (used by remove_download).
/// Does NOT release a permit (the caller handles active permits via
/// release_permit if the task was already dispatched).
pub async fn remove_from_pending(&self, id: &str) -> bool {
let mut pending = self.pending.lock().await;
let before = pending.len();
pending.retain(|t| t.id != id);
let removed = pending.len() < before;
if removed {
self.notify.notify_one();
}
removed
}
pub async fn remove_from_pending_for_generation(&self, id: &str, generation: u64) -> bool {
let mut pending = self.pending.lock().await;
let before = pending.len();
pending.retain(|task| !(task.id == id && task.lifecycle_generation == generation));
let removed = pending.len() < before;
if removed {
self.notify.notify_one();
}
removed
}
}
fn automatic_retry_limit(max_tries: Option<i32>) -> usize {
max_tries.unwrap_or(MAX_RETRIES as i32).max(0) as usize
}
fn torrent_seeding_requested(payload: &SpawnPayload) -> bool {
payload.is_torrent
&& (payload
.torrent_seed_time
.is_some_and(|minutes| minutes.is_finite() && minutes > 0.0)
|| payload
.torrent_seed_ratio
.is_some_and(|ratio| ratio.is_finite() && ratio >= 0.0))
}
fn aria2_attempt_limit(max_tries: Option<i32>) -> u32 {
// Firelink owns the retry budget and performs the backoff/GID rotation.
// Keep each aria2 GID to one attempt so `max_tries` is not multiplied by
// aria2's own internal retry loop.
let _ = max_tries;
1
}
fn is_retryable_aria2_error(error: &str) -> bool {
is_transient_network_error(error) || is_aria2_range_mode_error(error)
}
fn is_aria2_not_found_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("aria2 error code 3") || lower.contains("aria2 error code 4")
}
fn is_aria2_low_speed_error(error: &str) -> bool {
error
.to_ascii_lowercase()
.contains("aria2 error code 5")
}
fn is_retryable_aria2_error_for_payload(payload: &SpawnPayload, error: &str) -> bool {
is_retryable_aria2_error(error)
|| (!payload.is_torrent
&& payload.retry_not_found_errors
&& is_aria2_not_found_error(error))
|| (!payload.is_torrent
&& payload.minimum_normal_download_speed_kib > 0
&& is_aria2_low_speed_error(error))
}
fn should_use_aria2_system_resolver_fallback(
payload: &SpawnPayload,
error: &str,
async_dns_supported: bool,
) -> bool {
async_dns_supported
&& payload.aria2_resolver_mode == Aria2ResolverMode::Automatic
&& is_aria2_name_resolution_error(error)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Aria2RetryAction {
SystemResolverFallback,
OrdinaryRetry,
Terminal,
}
fn aria2_retry_action(
payload: &SpawnPayload,
error: &str,
strike: usize,
async_dns_supported: bool,
) -> Aria2RetryAction {
if should_use_aria2_system_resolver_fallback(payload, error, async_dns_supported) {
return Aria2RetryAction::SystemResolverFallback;
}
if is_retryable_aria2_error_for_payload(payload, error)
&& strike < automatic_retry_limit(payload.max_tries)
{
Aria2RetryAction::OrdinaryRetry
} else {
Aria2RetryAction::Terminal
}
}
fn is_aria2_rpc_unavailable(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
is_transient_network_error(error)
|| lower.contains("aria2 did not become ready")
|| lower.contains("connection refused")
|| lower.contains("failed to connect")
|| lower.contains("error trying to connect")
|| lower.contains("connection closed")
|| lower.contains("connection reset")
}
fn is_aria2_range_mode_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("invalid range header")
|| lower.contains("aria2 error code 8")
|| lower.contains("errorcode=8")
}
async fn remove_incompatible_aria2_range_state<R: tauri::Runtime>(
manager: &QueueManager<R>,
id: &str,
) -> Result<(), String> {
let Some(primary_path) =
crate::download_ownership::primary_path_for_id(&manager.app_handle, id)?
else {
return Ok(());
};
crate::remove_download_assets(&primary_path, &manager.app_handle).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BoundedRangeSupport {
Supported,
Unsupported,
Unknown,
}
struct HttpTransferProbe {
final_uri: String,
range_support: BoundedRangeSupport,
credentials_allowed: bool,
}
struct PreparedNormalTransfer {
uris: Vec<String>,
connections: i32,
credentials_allowed: bool,
}
fn payload_has_credential_material(payload: &SpawnPayload) -> bool {
[
payload.username.as_deref(),
payload.password.as_deref(),
payload.cookies.as_deref(),
payload.headers.as_deref(),
]
.into_iter()
.flatten()
.any(|value| !value.trim().is_empty())
}
async fn prepare_normal_transfer(
id: &str,
payload: &SpawnPayload,
) -> Result<PreparedNormalTransfer, String> {
let credential_origin = reqwest::Url::parse(&payload.url)
.map_err(|_| "normal download has an invalid primary URL".to_string())?;
let requested =
clamp_download_connections(payload.connections.unwrap_or(DOWNLOAD_CONNECTIONS_MIN));
let mut connections = requested;
let mut uris = Vec::new();
let mut credentials_allowed = true;
for (index, uri) in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref())
.into_iter()
.enumerate()
{
if !is_http_uri(&uri) {
crate::resolve_and_validate_url_host(
&reqwest::Url::parse(&uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?,
)
.await?;
uris.push(uri);
continue;
}
match probe_bounded_range_support(&uri, payload, &credential_origin).await {
Ok(probe) => {
if index > 0
&& payload_has_credential_material(payload)
&& !probe.credentials_allowed
{
return Err(
"credentialed mirrors must use the same origin as the primary URL"
.to_string(),
);
}
credentials_allowed &= probe.credentials_allowed;
uris.push(probe.final_uri);
match probe.range_support {
BoundedRangeSupport::Unsupported if requested > 1 => {
log::warn!(
"aria2 range probe [{}]: {} does not honor bounded byte ranges; using one connection",
id,
uri_host_for_log(&uri)
);
connections = 1;
}
BoundedRangeSupport::Unknown if requested > 1 => {
log::debug!(
"aria2 range probe [{}]: {} range support unknown; keeping {} connections",
id,
uri_host_for_log(&uri),
requested
);
}
_ => {}
}
}
Err(error) => {
return Err(format!(
"normal transfer preflight failed for {}: {}",
uri_host_for_log(&uri),
crate::redact_sensitive_text(&error)
));
}
}
}
uris.dedup();
if uris.is_empty() {
return Err("normal download has no usable URI".to_string());
}
if payload_has_credential_material(payload) && !credentials_allowed {
log::warn!(
"aria2 redirect policy [{}]: stripping credentials after a cross-origin redirect",
id
);
}
Ok(PreparedNormalTransfer {
uris,
connections,
credentials_allowed,
})
}
fn is_http_uri(uri: &str) -> bool {
reqwest::Url::parse(uri)
.ok()
.is_some_and(|url| matches!(url.scheme(), "http" | "https"))
}
fn uri_host_for_log(uri: &str) -> String {
reqwest::Url::parse(uri)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| "<unknown host>".to_string())
}
fn proxy_scheme(proxy: &str) -> Option<String> {
proxy
.split_once("://")
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
}
pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, String> {
let proxy = proxy.trim();
if proxy.is_empty() {
return Ok(None);
}
if proxy.eq_ignore_ascii_case("none") {
return Ok(Some(String::new()));
}
if proxy_scheme(proxy).is_some_and(|scheme| scheme.starts_with("socks")) {
return Err(
"SOCKS system proxies are not supported for normal file downloads because aria2 only accepts HTTP/HTTPS/FTP proxy URLs. Use an HTTP proxy endpoint for normal downloads, or use media downloads where yt-dlp supports SOCKS.".to_string(),
);
}
Ok(Some(proxy.to_string()))
}
async fn probe_bounded_range_support(
uri: &str,
payload: &SpawnPayload,
credential_origin: &reqwest::Url,
) -> Result<HttpTransferProbe, String> {
crate::ensure_reqwest_crypto_provider();
let mut current = reqwest::Url::parse(uri).map_err(|error| error.to_string())?;
let mut credentials_allowed = can_forward_payload_credentials(credential_origin, &current);
for redirect_count in 0..=5 {
let (host, address) = crate::resolve_and_validate_url_host(&current).await?;
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10))
.resolve(&host, address);
if let Some(proxy) = payload
.proxy
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if proxy.eq_ignore_ascii_case("none") {
builder = builder.no_proxy();
} else {
builder =
builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?);
}
}
let client = builder.build().map_err(|error| error.to_string())?;
let request = client
.get(current.clone())
.header(reqwest::header::RANGE, "bytes=0-0")
.header(reqwest::header::ACCEPT_ENCODING, "identity");
let response = apply_payload_headers(request, payload, credentials_allowed)
.send()
.await
.map_err(|error| error.to_string())?;
if response.status().is_redirection() {
if redirect_count == 5 {
return Err("range probe redirect limit exceeded".to_string());
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| "range probe redirect has no valid Location".to_string())?;
let next = current
.join(location)
.map_err(|error| format!("invalid range probe redirect: {error}"))?;
if !matches!(next.scheme(), "http" | "https") {
return Err("range probe redirect uses an unsupported scheme".to_string());
}
credentials_allowed =
credentials_allowed && can_forward_payload_credentials(credential_origin, &next);
current = next;
continue;
}
let content_range = response
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok());
return Ok(HttpTransferProbe {
final_uri: current.to_string(),
range_support: classify_bounded_range_response(response.status(), content_range),
credentials_allowed,
});
}
Err("range probe redirect loop exhausted".to_string())
}
fn can_forward_payload_credentials(original: &reqwest::Url, current: &reqwest::Url) -> bool {
original.host() == current.host()
&& (original.port_or_known_default() == current.port_or_known_default()
|| (original.scheme() == "http"
&& current.scheme() == "https"
&& original.port_or_known_default() == Some(80)
&& current.port_or_known_default() == Some(443)))
&& (original.scheme() == current.scheme()
|| (original.scheme() == "http" && current.scheme() == "https"))
}
fn apply_payload_headers(
mut request: reqwest::RequestBuilder,
payload: &SpawnPayload,
include_credentials: bool,
) -> reqwest::RequestBuilder {
if let Some(user_agent) = payload
.user_agent
.as_deref()
.filter(|value| !value.is_empty())
{
request = request.header(reqwest::header::USER_AGENT, user_agent);
}
if include_credentials {
if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) {
request = request.header(reqwest::header::COOKIE, cookies);
}
if let Some(headers) = payload.headers.as_deref() {
for line in headers
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let Some((name, value)) = line.split_once(':') else {
continue;
};
if name.trim().eq_ignore_ascii_case("range") {
continue;
}
let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else {
continue;
};
let Ok(value) = reqwest::header::HeaderValue::from_str(value.trim()) else {
continue;
};
request = request.header(name, value);
}
}
if let Some(username) = payload
.username
.as_deref()
.filter(|value| !value.is_empty())
{
request = request.basic_auth(username, payload.password.as_deref());
}
}
request
}
fn apply_protocol_auth_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
uris: &[String],
) {
let has_http = uris.iter().any(|uri| is_http_uri(uri));
let has_ftp = uris.iter().any(|uri| {
url::Url::parse(uri)
.ok()
.is_some_and(|parsed| matches!(parsed.scheme(), "ftp" | "sftp"))
});
let has_sftp = uris.iter().any(|uri| {
url::Url::parse(uri)
.ok()
.is_some_and(|parsed| parsed.scheme() == "sftp")
});
if has_http {
if let Some(user) = &payload.username {
options.insert("http-user".to_string(), serde_json::json!(user));
}
if let Some(pass) = &payload.password {
options.insert("http-passwd".to_string(), serde_json::json!(pass));
}
}
if has_ftp {
if let Some(user) = &payload.username {
options.insert("ftp-user".to_string(), serde_json::json!(user));
}
if let Some(pass) = &payload.password {
options.insert("ftp-passwd".to_string(), serde_json::json!(pass));
}
}
if has_sftp {
if let Some(fingerprint) = &payload.sftp_host_key_md {
options.insert("ssh-host-key-md".to_string(), serde_json::json!(fingerprint));
}
}
}
fn apply_checksum_options(
options: &mut serde_json::Map<String, serde_json::Value>,
checksum: Option<&str>,
) {
if let Some(chk) = checksum {
let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') {
format!("{}={}", algo.to_ascii_lowercase(), digest)
} else {
chk.to_string()
};
options.insert("checksum".to_string(), serde_json::json!(formatted_chk));
options.insert("check-integrity".to_string(), serde_json::json!("true"));
}
}
async fn validate_aria2_transfer_network_policy(uris: &[String]) -> Result<(), String> {
for uri in uris {
let parsed = reqwest::Url::parse(uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?;
if !matches!(parsed.scheme(), "http" | "https" | "ftp" | "sftp") {
return Err("Unsupported URL scheme".to_string());
}
// This is deliberately repeated immediately before addUri/addTorrent
// so admission-time DNS is not the only policy check. Aria2 still
// resolves independently later; Firelink therefore does not claim
// that this is an IP pinning boundary.
crate::resolve_and_validate_url_host(&parsed).await?;
}
Ok(())
}
fn classify_bounded_range_response(
status: reqwest::StatusCode,
content_range: Option<&str>,
) -> BoundedRangeSupport {
if status == reqwest::StatusCode::PARTIAL_CONTENT {
return match content_range.and_then(parse_content_range_bounds) {
Some((0, 0)) => BoundedRangeSupport::Supported,
Some((0, _)) => BoundedRangeSupport::Unsupported,
Some(_) => BoundedRangeSupport::Unknown,
None => BoundedRangeSupport::Unknown,
};
}
if status.is_success() {
BoundedRangeSupport::Unsupported
} else {
BoundedRangeSupport::Unknown
}
}
fn parse_content_range_bounds(value: &str) -> Option<(u64, u64)> {
let value = value.trim();
let (unit, range) = value.split_once(' ')?;
if !unit.eq_ignore_ascii_case("bytes") {
return None;
}
let (bounds, _) = range.split_once('/')?;
let (start, end) = bounds.split_once('-')?;
Some((start.trim().parse().ok()?, end.trim().parse().ok()?))
}
/// Production spawner that delegates to the real aria2 RPC and yt-dlp runners.
pub struct ProductionSpawner {
app_handle: AppHandle<tauri::Wry>,
}
const ARIA2_MIN_SPLIT_SIZE: &str = "1M";
const ARIA2_STREAM_PIECE_SELECTOR: &str = "inorder";
const ARIA2_DEFAULT_TORRENT_MAX_PEERS: u32 = 55;
const ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT: &str = "50K";
const MAX_TORRENT_MAX_PEERS: u32 = 1000;
pub(crate) const MAX_TORRENT_STOP_TIMEOUT: u32 = 7 * 24 * 60 * 60;
pub(crate) const MAX_TORRENT_PEER_DIAGNOSTICS: usize = 128;
const MAX_TORRENT_PEER_RESPONSE: usize = 4096;
const MAX_TORRENT_TRACKERS: usize = 64;
const MAX_TORRENT_TRACKER_BYTES: usize = 16 * 1024;
fn apply_aria2_connection_options(
options: &mut serde_json::Map<String, serde_json::Value>,
connections: i32,
) {
let connections = clamp_download_connections(connections);
options.insert(
"split".to_string(),
serde_json::json!(connections.to_string()),
);
options.insert(
"max-connection-per-server".to_string(),
serde_json::json!(connections.to_string()),
);
// aria2's 20M default suppresses segmentation for files smaller than
// 40M. Keep the requested connection count useful for ordinary release
// assets while retaining a 1M lower bound to avoid tiny range requests.
options.insert(
"min-split-size".to_string(),
serde_json::json!(ARIA2_MIN_SPLIT_SIZE),
);
// Aria2's default selector deliberately reduces the number of established
// connections over time. Keep a segmented HTTP transfer replenishing its
// requested ranges so a healthy host does not degrade from (for example)
// 16 active connections to one or two after early pieces finish.
options.insert(
"stream-piece-selector".to_string(),
serde_json::json!(ARIA2_STREAM_PIECE_SELECTOR),
);
}
fn apply_aria2_normal_reliability_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
uri_count: usize,
) -> Result<(), String> {
if payload.is_torrent {
return Ok(());
}
let minimum_speed =
normalize_minimum_normal_download_speed_kib(payload.minimum_normal_download_speed_kib)?;
if minimum_speed > 0 {
options.insert(
"lowest-speed-limit".to_string(),
serde_json::json!(format!("{minimum_speed}K")),
);
}
if payload.adaptive_mirror_selection && uri_count > 1 {
options.insert(
"uri-selector".to_string(),
serde_json::json!("adaptive"),
);
}
Ok(())
}
fn apply_aria2_resolver_options(
options: &mut serde_json::Map<String, serde_json::Value>,
mode: Aria2ResolverMode,
) {
if mode == Aria2ResolverMode::System {
options.insert("async-dns".to_string(), serde_json::json!("false"));
}
}
fn should_apply_aria2_connection_options(payload: &SpawnPayload) -> bool {
!payload.is_torrent
}
fn apply_aria2_follow_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
) {
if !payload.is_torrent {
// A generic addUri can point at a .torrent or Metalink file. Aria2
// may then create a second, followed child GID, but Firelink
// currently owns exactly one GID per download. Keep that unmanaged
// child lifecycle impossible until parent/child ownership is modeled
// end to end.
options.insert(
"follow-torrent".to_string(),
serde_json::json!("false"),
);
options.insert(
"follow-metalink".to_string(),
serde_json::json!("false"),
);
}
}
fn format_aria2_torrent_number(value: f64, field: &str) -> Result<String, String> {
if !value.is_finite() || value < 0.0 {
return Err(format!("torrent {field} must be a finite non-negative number"));
}
Ok(value.to_string())
}
fn normalize_torrent_max_peers(value: Option<i64>) -> Result<Option<u32>, String> {
let Some(value) = value else {
return Ok(None);
};
if !(0..=i64::from(MAX_TORRENT_MAX_PEERS)).contains(&value) {
return Err(format!(
"torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}"
));
}
Ok(Some(value as u32))
}
fn normalize_torrent_peer_speed_limit(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
crate::normalize_speed_limit_for_aria2(value)
.map(Some)
.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())
}
pub(crate) fn normalize_torrent_stop_timeout(value: Option<u32>) -> Result<Option<u32>, String> {
let Some(value) = value else {
return Ok(None);
};
if value > MAX_TORRENT_STOP_TIMEOUT {
return Err(format!(
"torrent stall timeout must be between 0 and {MAX_TORRENT_STOP_TIMEOUT} seconds"
));
}
Ok(Some(value))
}
fn normalize_torrent_piece_priority_size(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err("torrent piece priority size must use a positive K or M value".to_string());
}
let Some((unit_start, unit)) = value.char_indices().next_back() else {
return Err("torrent piece priority size must use a positive K or M value".to_string());
};
let amount = &value[..unit_start];
let unit = unit.to_ascii_uppercase();
if !matches!(unit, 'K' | 'M') || amount.is_empty() {
return Err("torrent piece priority size must use a positive K or M value".to_string());
}
let amount = amount
.parse::<u64>()
.map_err(|_| "torrent piece priority size must use a positive K or M value".to_string())?;
if amount == 0
|| (unit == 'M' && amount > MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB)
|| (unit == 'K' && amount > MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB * 1024)
{
return Err(format!(
"torrent piece priority size must be between 1K and {}M",
MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB
));
}
Ok(format!("{amount}{unit}"))
}
pub(crate) fn normalize_torrent_prioritize_piece(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if raw.len() > 64 {
return Err("torrent piece priority is too long".to_string());
}
let mut head = None;
let mut tail = None;
for token in raw.split(',') {
let token = token.trim();
if token.is_empty() {
return Err("torrent piece priority contains an empty entry".to_string());
}
let (keyword, size) = token
.split_once('=')
.map_or((token, None), |(keyword, size)| {
(keyword.trim(), Some(size))
});
let keyword = keyword.to_ascii_lowercase();
let normalized_size = size
.map(normalize_torrent_piece_priority_size)
.transpose()?;
let normalized = normalized_size
.map(|size| format!("{keyword}={size}"))
.unwrap_or_else(|| keyword.clone());
match keyword.as_str() {
"head" => {
if head.is_some() {
return Err("torrent piece priority cannot repeat head".to_string());
}
head = Some(normalized);
}
"tail" => {
if tail.is_some() {
return Err("torrent piece priority cannot repeat tail".to_string());
}
tail = Some(normalized);
}
_ => return Err("torrent piece priority must use head and/or tail".to_string()),
}
}
let mut normalized = Vec::with_capacity(2);
if let Some(head) = head {
normalized.push(head);
}
if let Some(tail) = tail {
normalized.push(tail);
}
if normalized.is_empty() {
return Ok(None);
}
Ok(Some(normalized.join(",")))
}
fn aria2_peer_number(value: Option<&serde_json::Value>) -> u64 {
match value {
Some(serde_json::Value::String(value)) => value.parse().unwrap_or_default(),
Some(serde_json::Value::Number(value)) => value.as_u64().unwrap_or_default(),
_ => 0,
}
}
fn aria2_peer_ip(value: Option<&serde_json::Value>) -> Option<String> {
let value = value?.as_str()?.trim();
if value.is_empty() || value.len() > 64 || value.chars().any(char::is_control) {
return None;
}
value.parse::<std::net::IpAddr>().ok().map(|ip| ip.to_string())
}
fn aria2_peer_port(value: Option<&serde_json::Value>) -> Option<u16> {
match value {
Some(serde_json::Value::String(value)) => value.parse().ok(),
Some(serde_json::Value::Number(value)) => value.as_u64()?.try_into().ok(),
_ => None,
}
}
fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool {
match value {
Some(serde_json::Value::Bool(value)) => *value,
Some(serde_json::Value::String(value)) => {
value.eq_ignore_ascii_case("true") || value == "1"
}
_ => false,
}
}
fn parse_torrent_availability_decimal(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<u64, String> {
let value = object
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("aria2.tellStatus returned an invalid {field}"))?;
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!("aria2.tellStatus returned an invalid {field}"));
}
value
.parse::<u64>()
.map_err(|_| format!("aria2.tellStatus returned an invalid {field}"))
}
fn decode_torrent_availability_bitfield(
value: &str,
piece_count: u64,
) -> Result<Vec<u8>, String> {
if piece_count == 0 || piece_count > MAX_TORRENT_PIECES_FOR_PROGRESS {
return Err("Torrent availability has an unsupported piece count".to_string());
}
let byte_count = piece_count
.checked_add(7)
.and_then(|value| value.checked_div(8))
.ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?;
let expected_hex_length = byte_count
.checked_mul(2)
.ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?;
if value.len() != usize::try_from(expected_hex_length).unwrap_or(usize::MAX)
|| !value.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err("Torrent availability bitfield is malformed".to_string());
}
let mut bytes = Vec::with_capacity(byte_count as usize);
for pair in value.as_bytes().chunks_exact(2) {
let high = char::from(pair[0])
.to_digit(16)
.ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?;
let low = char::from(pair[1])
.to_digit(16)
.ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?;
bytes.push(((high << 4) | low) as u8);
}
if piece_count % 8 != 0 {
let overflow_mask = (1u8 << (8 - piece_count as u8 % 8)) - 1;
if bytes.last().is_some_and(|byte| byte & overflow_mask != 0) {
return Err("Torrent availability bitfield has overflow bits".to_string());
}
}
Ok(bytes)
}
fn torrent_availability_piece_is_set(bitfield: &[u8], index: usize) -> bool {
bitfield[index / 8] & (1 << (7 - index % 8)) != 0
}
pub(crate) fn parse_torrent_availability(
status: serde_json::Value,
peers: serde_json::Value,
) -> Result<crate::ipc::TorrentAvailabilitySnapshot, String> {
let status = status
.as_object()
.ok_or_else(|| "aria2.tellStatus returned malformed Torrent availability".to_string())?;
let piece_count = parse_torrent_availability_decimal(status, "numPieces")?;
let local_bitfield = status
.get("bitfield")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "aria2.tellStatus has no Torrent availability bitfield yet".to_string())?;
let local_bitfield = decode_torrent_availability_bitfield(local_bitfield, piece_count)?;
let peers = peers
.as_array()
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
if peers.len() > MAX_TORRENT_AVAILABILITY_PEERS {
return Err("aria2.getPeers returned too many peers for availability".to_string());
}
let mut copies = vec![0u16; piece_count as usize];
for index in 0..piece_count as usize {
if torrent_availability_piece_is_set(&local_bitfield, index) {
copies[index] = 1;
}
}
for peer in peers {
let Some(peer) = peer.as_object() else {
// Peer projections are network-derived and may be incomplete
// while Aria2 refreshes its peer table. Preserve the connected
// count but omit an unusable contribution rather than failing
// availability for the whole swarm.
continue;
};
let Some(bitfield_value) = peer.get("bitfield") else {
// Aria2 can report a connected peer before the handshake has
// supplied its piece bitfield. Keep that peer in the connected
// count, but do not let it make the whole availability snapshot
// unavailable. A present, non-string bitfield remains malformed.
continue;
};
let Some(bitfield) = bitfield_value.as_str() else {
continue;
};
let Ok(bitfield) = decode_torrent_availability_bitfield(bitfield, piece_count) else {
continue;
};
for index in 0..piece_count as usize {
if torrent_availability_piece_is_set(&bitfield, index) {
copies[index] = copies[index].saturating_add(1);
}
}
}
let minimum = copies.iter().copied().min().unwrap_or(0);
let above_minimum = copies.iter().filter(|count| **count > minimum).count();
let availability = minimum as f64 + above_minimum as f64 / piece_count as f64;
let bucket_count = piece_count.min(256) as usize;
let mut buckets = Vec::with_capacity(bucket_count);
for bucket_index in 0..bucket_count {
let start = piece_count * bucket_index as u64 / bucket_count as u64;
let end = piece_count * (bucket_index as u64 + 1) / bucket_count as u64;
let minimum_copies = copies[start as usize..end as usize]
.iter()
.copied()
.min()
.unwrap_or(0);
buckets.push(crate::ipc::TorrentAvailabilityBucket { minimum_copies });
}
Ok(crate::ipc::TorrentAvailabilitySnapshot {
piece_count,
availability,
connected_peers: peers.len().try_into().unwrap_or(u32::MAX),
buckets,
})
}
pub(crate) fn parse_torrent_peer_diagnostics(
result: serde_json::Value,
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
let peers = result
.as_array()
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
if peers.len() > MAX_TORRENT_PEER_RESPONSE {
return Err("aria2.getPeers returned too many peers".to_string());
}
if peers.iter().any(|peer| !peer.is_object()) {
return Err("aria2.getPeers returned malformed peer data".to_string());
}
let total_peers = u32::try_from(peers.len()).unwrap_or(u32::MAX);
let mut total_seeders = 0u32;
let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS));
for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) {
let peer = peer
.as_object()
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
let seeder = aria2_peer_bool(peer.get("seeder"));
if seeder {
total_seeders = total_seeders.saturating_add(1);
}
sanitized.push(crate::ipc::TorrentPeer {
ip: aria2_peer_ip(peer.get("ip")),
port: aria2_peer_port(peer.get("port")),
download_speed: aria2_peer_number(peer.get("downloadSpeed")),
upload_speed: aria2_peer_number(peer.get("uploadSpeed")),
seeder,
am_choking: aria2_peer_bool(peer.get("amChoking")),
peer_choking: aria2_peer_bool(peer.get("peerChoking")),
});
}
// Count seeders beyond the display cap without retaining any identifying
// peer data. The response is bounded by Aria2's per-Torrent peer limit,
// while the UI receives at most MAX_TORRENT_PEER_DIAGNOSTICS rows.
for peer in peers.iter().skip(MAX_TORRENT_PEER_DIAGNOSTICS) {
let peer = peer
.as_object()
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
if aria2_peer_bool(peer.get("seeder")) {
total_seeders = total_seeders.saturating_add(1);
}
}
Ok(crate::ipc::TorrentPeerDiagnostics {
total_peers,
total_seeders,
peers: sanitized,
truncated: peers.len() > MAX_TORRENT_PEER_DIAGNOSTICS,
})
}
fn parse_aria2_decimal(value: Option<&serde_json::Value>, field: &str) -> Result<u64, String> {
let value = value
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("aria2.getFiles returned an invalid {field}"))?;
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!("aria2.getFiles returned an invalid {field}"));
}
value
.parse::<u64>()
.map_err(|_| format!("aria2.getFiles returned an invalid {field}"))
}
fn parse_aria2_selected(value: Option<&serde_json::Value>) -> Result<bool, String> {
match value {
Some(serde_json::Value::String(value)) if value == "true" => Ok(true),
Some(serde_json::Value::String(value)) if value == "false" => Ok(false),
Some(serde_json::Value::Bool(value)) => Ok(*value),
_ => Err("aria2.getFiles returned an invalid selected flag".to_string()),
}
}
pub(crate) fn parse_torrent_file_progress(
result: serde_json::Value,
metadata_files: &[crate::ipc::TorrentFile],
) -> Result<crate::ipc::TorrentFileProgressSnapshot, String> {
let files = result
.as_array()
.ok_or_else(|| "aria2.getFiles returned a non-array result".to_string())?;
if files.len() != metadata_files.len() {
return Err("aria2.getFiles returned an unexpected file count".to_string());
}
let mut parsed = HashMap::<u32, crate::ipc::TorrentFileProgress>::with_capacity(files.len());
for file in files {
let object = file
.as_object()
.ok_or_else(|| "aria2.getFiles returned malformed file data".to_string())?;
let index = parse_aria2_decimal(object.get("index"), "file index")?
.try_into()
.map_err(|_| "aria2.getFiles returned an invalid file index".to_string())?;
let metadata = metadata_files
.iter()
.find(|metadata| metadata.index == index)
.ok_or_else(|| "aria2.getFiles returned an unknown file index".to_string())?;
if parsed.contains_key(&index) {
return Err("aria2.getFiles returned a duplicate file index".to_string());
}
let length = parse_aria2_decimal(object.get("length"), "file length")?;
if length != metadata.length {
return Err("aria2.getFiles returned a mismatched file length".to_string());
}
let completed_length = parse_aria2_decimal(
object.get("completedLength"),
"completed file length",
)?;
if completed_length > length {
return Err("aria2.getFiles returned over-complete file data".to_string());
}
parsed.insert(
index,
crate::ipc::TorrentFileProgress {
index,
relative_path: metadata.path.clone(),
length,
completed_length,
selected: parse_aria2_selected(object.get("selected"))?,
},
);
}
let files = metadata_files
.iter()
.map(|metadata| {
parsed
.remove(&metadata.index)
.ok_or_else(|| "aria2.getFiles returned a missing file index".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
if !parsed.is_empty() {
return Err("aria2.getFiles returned unexpected file data".to_string());
}
Ok(crate::ipc::TorrentFileProgressSnapshot { files })
}
fn parse_aria2_status_decimal(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<u64, String> {
let value = object
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("aria2.tellStatus returned an invalid {field}"))?;
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!("aria2.tellStatus returned an invalid {field}"));
}
value
.parse::<u64>()
.map_err(|_| format!("aria2.tellStatus returned an invalid {field}"))
}
fn decode_torrent_piece_bitfield(
value: &str,
num_pieces: u64,
) -> Result<Vec<u8>, String> {
if num_pieces == 0 {
return Err("aria2.tellStatus returned a non-positive piece count".to_string());
}
if num_pieces > MAX_TORRENT_PIECES_FOR_PROGRESS {
return Err("aria2.tellStatus returned an unsupported piece count".to_string());
}
let byte_count = num_pieces
.checked_add(7)
.and_then(|value| value.checked_div(8))
.ok_or_else(|| "aria2.tellStatus returned an unsupported piece count".to_string())?;
let hex_length = byte_count
.checked_mul(2)
.ok_or_else(|| "aria2.tellStatus returned an oversized bitfield".to_string())?;
if value.len() != usize::try_from(hex_length).unwrap_or(usize::MAX)
|| !value.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err("aria2.tellStatus returned an invalid piece bitfield".to_string());
}
let mut bytes = Vec::with_capacity(usize::try_from(byte_count).unwrap_or_default());
for pair in value.as_bytes().chunks_exact(2) {
let high = char::from(pair[0]).to_digit(16).ok_or_else(|| {
"aria2.tellStatus returned an invalid piece bitfield".to_string()
})?;
let low = char::from(pair[1]).to_digit(16).ok_or_else(|| {
"aria2.tellStatus returned an invalid piece bitfield".to_string()
})?;
bytes.push(((high << 4) | low) as u8);
}
let remainder = (num_pieces % 8) as u8;
if remainder != 0 {
let overflow_mask = (1u8 << (8 - remainder)) - 1;
if bytes.last().is_some_and(|byte| byte & overflow_mask != 0) {
return Err("aria2.tellStatus returned set overflow bits".to_string());
}
}
Ok(bytes)
}
fn torrent_piece_is_complete(bitfield: &[u8], index: u64) -> bool {
let byte = bitfield[(index / 8) as usize];
byte & (1 << (7 - (index % 8))) != 0
}
pub(crate) fn parse_torrent_piece_progress(
result: serde_json::Value,
) -> Result<crate::ipc::TorrentPieceProgressSnapshot, String> {
let object = result
.as_object()
.ok_or_else(|| "aria2.tellStatus returned malformed piece progress".to_string())?;
let piece_length = parse_aria2_status_decimal(object, "pieceLength")?;
let num_pieces = parse_aria2_status_decimal(object, "numPieces")?;
if piece_length == 0 {
return Err("aria2.tellStatus returned a non-positive piece length".to_string());
}
let bitfield = object
.get("bitfield")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "aria2.tellStatus has no piece bitfield yet".to_string())?;
let bitfield = decode_torrent_piece_bitfield(bitfield, num_pieces)?;
let completed_pieces = (0..num_pieces)
.filter(|index| torrent_piece_is_complete(&bitfield, *index))
.count() as u64;
let bucket_count = num_pieces.min(256) as usize;
let mut buckets = Vec::with_capacity(bucket_count);
for bucket_index in 0..bucket_count {
let start = num_pieces * bucket_index as u64 / bucket_count as u64;
let end = num_pieces * (bucket_index as u64 + 1) / bucket_count as u64;
let bucket_length = end.saturating_sub(start);
let completed = (start..end)
.filter(|index| torrent_piece_is_complete(&bitfield, *index))
.count() as u64;
let percentage = if bucket_length == 0 {
0
} else {
((completed * 100) / bucket_length).min(100) as u8
};
buckets.push(percentage);
}
Ok(crate::ipc::TorrentPieceProgressSnapshot {
piece_length,
num_pieces,
completed_pieces,
buckets,
})
}
fn normalize_torrent_tracker_list(
value: Option<&str>,
allow_wildcard: bool,
) -> Result<Option<String>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if raw.len() > MAX_TORRENT_TRACKER_BYTES {
return Err(format!(
"torrent tracker list must be at most {MAX_TORRENT_TRACKER_BYTES} bytes"
));
}
let mut trackers = Vec::new();
let mut wildcard = false;
let mut serialized_bytes = 0usize;
for line in raw.split(['\r', '\n']) {
let line = line.trim();
if line.is_empty() {
continue;
}
for token in line.split(',') {
let token = token.trim();
if token.is_empty() {
return Err("torrent tracker list contains an empty entry".to_string());
}
if token.chars().any(char::is_control) {
return Err("torrent tracker URI contains a control character".to_string());
}
if allow_wildcard && token == "*" {
if !trackers.is_empty() {
return Err(
"torrent tracker exclusion wildcard cannot be combined with tracker URLs"
.to_string(),
);
}
wildcard = true;
continue;
}
if wildcard {
return Err(
"torrent tracker exclusion wildcard cannot be combined with tracker URLs"
.to_string(),
);
}
let parsed = url::Url::parse(token)
.map_err(|_| "torrent tracker URI is invalid".to_string())?;
if !matches!(parsed.scheme(), "http" | "https" | "udp") {
return Err("torrent tracker URI must use http, https, or udp".to_string());
}
if parsed.host_str().is_none_or(str::is_empty) {
return Err("torrent tracker URI must include a host".to_string());
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("torrent tracker URI must not contain credentials".to_string());
}
if parsed.fragment().is_some() {
return Err("torrent tracker URI must not contain a fragment".to_string());
}
let normalized = parsed.to_string();
if trackers.iter().any(|tracker| tracker == &normalized) {
continue;
}
if trackers.len() >= MAX_TORRENT_TRACKERS {
return Err(format!(
"torrent tracker list must contain at most {MAX_TORRENT_TRACKERS} trackers"
));
}
serialized_bytes = serialized_bytes
.checked_add(normalized.len())
.and_then(|bytes| bytes.checked_add(if trackers.is_empty() { 0 } else { 1 }))
.ok_or_else(|| "torrent tracker list is too large".to_string())?;
if serialized_bytes > MAX_TORRENT_TRACKER_BYTES {
return Err(format!(
"torrent tracker list must be at most {MAX_TORRENT_TRACKER_BYTES} bytes"
));
}
trackers.push(normalized);
}
}
if wildcard {
return Ok(Some("*".to_string()));
}
if trackers.is_empty() {
return Ok(None);
}
Ok(Some(trackers.join(",")))
}
pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<String>, String> {
normalize_torrent_tracker_list(value, false)
}
pub(crate) fn normalize_torrent_exclude_trackers(
value: Option<&str>,
) -> Result<Option<String>, String> {
normalize_torrent_tracker_list(value, true)
}
fn normalize_torrent_tracker_timeout(value: Option<u32>, field: &str) -> Result<Option<u32>, String> {
let Some(value) = value else {
return Ok(None);
};
if !(1..=MAX_TORRENT_TRACKER_TIMEOUT).contains(&value) {
return Err(format!(
"torrent {field} must be between 1 and {MAX_TORRENT_TRACKER_TIMEOUT} seconds"
));
}
Ok(Some(value))
}
pub(crate) fn normalize_torrent_tracker_connect_timeout(
value: Option<u32>,
) -> Result<Option<u32>, String> {
normalize_torrent_tracker_timeout(value, "tracker connect timeout")
}
pub(crate) fn normalize_torrent_tracker_request_timeout(
value: Option<u32>,
) -> Result<Option<u32>, String> {
normalize_torrent_tracker_timeout(value, "tracker timeout")
}
pub(crate) fn normalize_torrent_tracker_interval(
value: Option<u32>,
) -> Result<Option<u32>, String> {
let Some(value) = value else {
return Ok(None);
};
if value > MAX_TORRENT_TRACKER_INTERVAL {
return Err(format!(
"torrent tracker interval must be between 0 and {MAX_TORRENT_TRACKER_INTERVAL} seconds"
));
}
Ok(Some(value))
}
pub(crate) fn normalize_torrent_encryption_policy(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
match raw {
"disabled" => Ok(None),
"require-crypto" => Ok(Some("require-crypto".to_string())),
"force-encryption" => Ok(Some("force-encryption".to_string())),
_ => Err(
"torrent encryption policy must be disabled, require-crypto, or force-encryption"
.to_string(),
),
}
}
fn apply_aria2_torrent_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
) -> Result<(), String> {
if !payload.is_torrent {
return Ok(());
}
if payload.torrent_verify_only {
options.insert("check-integrity".to_string(), serde_json::json!("true"));
options.insert("hash-check-only".to_string(), serde_json::json!("true"));
options.insert("seed-time".to_string(), serde_json::json!("0"));
options.insert("seed-ratio".to_string(), serde_json::json!("0"));
options.insert("bt-hash-check-seed".to_string(), serde_json::json!("false"));
options.insert("bt-seed-unverified".to_string(), serde_json::json!("false"));
return Ok(());
}
let encryption_policy =
normalize_torrent_encryption_policy(payload.torrent_encryption_policy.as_deref())?;
let (force_encryption, require_crypto, min_crypto_level) =
match encryption_policy.as_deref() {
Some("require-crypto") => (false, true, "plain"),
Some("force-encryption") => (true, true, "arc4"),
None => (false, false, "plain"),
Some(policy) => {
return Err(format!("unsupported normalized Torrent encryption policy: {policy}"));
}
};
options.insert(
"bt-force-encryption".to_string(),
serde_json::json!(force_encryption.to_string()),
);
options.insert(
"bt-require-crypto".to_string(),
serde_json::json!(require_crypto.to_string()),
);
options.insert(
"bt-min-crypto-level".to_string(),
serde_json::json!(min_crypto_level),
);
let seed_time = payload
.torrent_seed_time
.map(|value| format_aria2_torrent_number(value, "seed time"))
.transpose()?;
let seed_ratio = payload
.torrent_seed_ratio
.map(|value| format_aria2_torrent_number(value, "seed ratio"))
.transpose()?;
// Aria2 treats seed-time=0 as an explicit disable. Omit it when a ratio
// policy exists so ratio-only and unlimited-ratio policies remain active.
// With no policy, keep the explicit zero to prevent daemon defaults from
// turning a normal Torrent download into an untracked seeding lifecycle.
if seed_ratio.is_none() || payload.torrent_seed_time.is_some_and(|value| value > 0.0) {
options.insert(
"seed-time".to_string(),
serde_json::json!(seed_time.unwrap_or_else(|| "0".to_string())),
);
}
if let Some(seed_ratio) = seed_ratio {
options.insert("seed-ratio".to_string(), serde_json::json!(seed_ratio));
}
if let Some(upload_limit) = payload.torrent_upload_limit.as_deref() {
let normalized = crate::normalize_speed_limit_for_aria2(upload_limit)
.ok_or_else(|| "torrent upload limit must be greater than zero".to_string())?;
options.insert("max-upload-limit".to_string(), serde_json::json!(normalized));
}
if let Some(max_peers) = payload.torrent_max_peers {
if max_peers > MAX_TORRENT_MAX_PEERS {
return Err(format!(
"torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}"
));
}
options.insert(
"bt-max-peers".to_string(),
serde_json::json!(max_peers.to_string()),
);
}
if let Some(peer_speed_limit) = payload.torrent_peer_speed_limit.as_deref() {
let normalized = normalize_torrent_peer_speed_limit(Some(peer_speed_limit))
?.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())?;
options.insert(
"bt-request-peer-speed-limit".to_string(),
serde_json::json!(normalized),
);
}
if let Some(trackers) = normalize_torrent_trackers(payload.torrent_trackers.as_deref())? {
options.insert("bt-tracker".to_string(), serde_json::json!(trackers));
}
if let Some(trackers) =
normalize_torrent_exclude_trackers(payload.torrent_exclude_trackers.as_deref())?
{
options.insert("bt-exclude-tracker".to_string(), serde_json::json!(trackers));
}
if let Some(timeout) = normalize_torrent_tracker_connect_timeout(
payload.torrent_tracker_connect_timeout,
)? {
options.insert(
"bt-tracker-connect-timeout".to_string(),
serde_json::json!(timeout.to_string()),
);
}
if let Some(timeout) =
normalize_torrent_tracker_request_timeout(payload.torrent_tracker_timeout)?
{
options.insert(
"bt-tracker-timeout".to_string(),
serde_json::json!(timeout.to_string()),
);
}
if let Some(interval) = normalize_torrent_tracker_interval(payload.torrent_tracker_interval)? {
options.insert(
"bt-tracker-interval".to_string(),
serde_json::json!(interval.to_string()),
);
}
if let Some(stop_timeout) = normalize_torrent_stop_timeout(payload.torrent_stop_timeout)? {
options.insert(
"bt-stop-timeout".to_string(),
serde_json::json!(stop_timeout.to_string()),
);
}
if let Some(piece_priority) =
normalize_torrent_prioritize_piece(payload.torrent_prioritize_piece.as_deref())?
{
options.insert(
"bt-prioritize-piece".to_string(),
serde_json::json!(piece_priority),
);
}
if payload.torrent_remove_unselected_file {
let Some(indices) = payload.torrent_file_indices.as_deref() else {
return Err(
"removing unselected Torrent files requires selecting a subset of files"
.to_string(),
);
};
if indices.is_empty() {
return Err("torrent file selection is invalid".to_string());
}
options.insert(
"bt-remove-unselected-file".to_string(),
serde_json::json!("true"),
);
}
let allocation = normalize_torrent_file_allocation(payload.torrent_file_allocation.as_deref())?;
options.insert("file-allocation".to_string(), serde_json::json!(allocation));
if payload.torrent_check_integrity {
options.insert(
"check-integrity".to_string(),
serde_json::json!("true"),
);
options.insert(
"bt-hash-check-seed".to_string(),
serde_json::json!(torrent_seeding_requested(payload).to_string()),
);
// Do not let a daemon-wide bt-seed-unverified setting bypass the
// explicit per-download integrity request.
options.insert(
"bt-seed-unverified".to_string(),
serde_json::json!("false"),
);
}
Ok(())
}
impl ProductionSpawner {
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
Self { app_handle }
}
async fn add_transfer_rpc(
&self,
state: &crate::AppState,
method: &str,
params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
loop {
match crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
method,
params.clone(),
)
.await
{
Ok(result) => return Ok(result),
Err(error) => {
if !is_aria2_rpc_unavailable(&error) || std::time::Instant::now() >= deadline {
return Err(error);
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
}
}
async fn control_seed_rpc(
&self,
gid: &str,
method: &str,
) -> Result<Aria2SeedControlOutcome, String> {
let state = self.app_handle.state::<crate::AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = &state.aria2_secret;
let rpc_error = match crate::rpc_call(port, secret, method, serde_json::json!([gid])).await {
Ok(result) => crate::ensure_aria2_gid_result(method, gid, &result)
.err()
.map(|error| error.to_string()),
Err(error) => Some(crate::redact_sensitive_text(&error.to_string())),
};
let status = crate::aria2_download_status(port, secret, gid)
.await
.map_err(|error| {
rpc_error
.as_ref()
.map(|rpc_error| format!("{rpc_error}; status verification failed: {error}"))
.unwrap_or_else(|| format!("status verification failed: {error}"))
})?;
let outcome = match status.as_str() {
"active" | "waiting" => Aria2SeedControlOutcome::Resumed,
"paused" => Aria2SeedControlOutcome::Paused,
"complete" => Aria2SeedControlOutcome::Complete,
other => {
return Err(format!(
"aria2 {method} left gid {gid} in unhandled state {other}"
));
}
};
if let Some(error) = rpc_error {
log::warn!(
"aria2 {method} for seed gid {} returned an error after status verification: {}",
gid,
error
);
}
Ok(outcome)
}
}
#[async_trait::async_trait]
impl SidecarSpawner for ProductionSpawner {
async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result<String, String> {
let state = self.app_handle.state::<crate::AppState>();
let mut options = serde_json::Map::new();
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
if !crate::is_safe_path(&resolved_dest, &self.app_handle) {
return Err("Path traversal blocked".to_string());
}
if payload.is_torrent {
crate::torrent::validate_output_name(&payload.filename)?;
}
let proxy_value = payload
.proxy
.as_deref()
.map(aria2_all_proxy_value)
.transpose()?
.flatten();
options.insert(
"dir".to_string(),
serde_json::json!(resolved_dest.to_string_lossy().to_string()),
);
let safe_filename =
crate::download_ownership::canonical_download_filename(&payload.filename);
if !payload.is_torrent {
options.insert("out".to_string(), serde_json::json!(safe_filename));
}
let (transfer_uris, transfer_connections, credentials_allowed) = if payload.is_torrent {
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, true)
} else {
let requested = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
validate_aria2_transfer_network_policy(&requested).await?;
let prepared = prepare_normal_transfer(id, payload).await?;
(
prepared.uris,
prepared.connections,
prepared.credentials_allowed,
)
};
if should_apply_aria2_connection_options(payload) {
apply_aria2_connection_options(&mut options, transfer_connections);
}
apply_aria2_normal_reliability_options(&mut options, payload, transfer_uris.len())?;
apply_aria2_follow_options(&mut options, payload);
apply_aria2_torrent_options(&mut options, payload)?;
let mt = aria2_attempt_limit(payload.max_tries);
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
options.insert("retry-wait".to_string(), serde_json::json!("2"));
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
options.insert("timeout".to_string(), serde_json::json!("60"));
options.insert("continue".to_string(), serde_json::json!("true"));
options.insert("always-resume".to_string(), serde_json::json!("true"));
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
if let Some(speed) = payload
.speed_limit
.as_deref()
.and_then(crate::normalize_speed_limit_for_aria2)
{
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
}
if !payload.is_torrent {
if credentials_allowed {
apply_protocol_auth_options(&mut options, payload, &transfer_uris);
}
apply_checksum_options(&mut options, payload.checksum.as_deref());
}
if let Some(ua) = &payload.user_agent {
options.insert("user-agent".to_string(), serde_json::json!(ua));
}
let mut header_list = Vec::new();
if payload.is_torrent || credentials_allowed {
if let Some(cook) = &payload.cookies {
header_list.push(format!("Cookie: {}", cook));
}
if let Some(hdrs) = &payload.headers {
for line in hdrs.lines() {
if !line.trim().is_empty() {
header_list.push(line.trim().to_string());
}
}
}
}
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
}
if let Some(prox) = proxy_value {
options.insert("all-proxy".to_string(), serde_json::json!(prox));
}
apply_aria2_resolver_options(&mut options, payload.aria2_resolver_mode);
let (method, params) = if payload.is_torrent {
if let Some(path) = payload.torrent_path.as_deref() {
let path = crate::torrent::validate_managed_torrent_path(
&self.app_handle,
id,
path,
)?;
let bytes = tokio::fs::read(&path)
.await
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
let (sanitized_bytes, embedded_web_seeds) =
crate::torrent::sanitize_torrent_bytes_for_aria2(&bytes)?;
let embedded_web_seeds =
crate::filter_torrent_web_seed_destinations(&embedded_web_seeds).await?;
let metadata = crate::torrent::parse_torrent_bytes(&sanitized_bytes)?;
options.insert(
"index-out".to_string(),
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)),
);
let selected = crate::torrent::validate_selected_indices(
payload.torrent_file_indices.as_deref(),
metadata.files.len(),
)?;
if let Some(indices) = selected {
options.insert(
"select-file".to_string(),
serde_json::json!(indices.iter().map(u32::to_string).collect::<Vec<_>>().join(",")),
);
}
let encoded = base64::engine::general_purpose::STANDARD.encode(sanitized_bytes);
let mut uris = embedded_web_seeds;
if payload.torrent_web_seeds.is_none() {
uris.extend(
payload
.mirrors
.as_deref()
.map(|mirrors| crate::collect_download_uris("", Some(mirrors)))
.unwrap_or_default(),
);
uris.sort();
uris.dedup();
}
("aria2.addTorrent", serde_json::json!([encoded, uris, options]))
} else {
let parsed = url::Url::parse(&payload.url)
.map_err(|_| "invalid magnet URI".to_string())?;
if parsed.scheme() != "magnet" {
return Err("torrent transfer has no magnet URI or cached metadata".to_string());
}
let selected = crate::torrent::validate_selected_indices(
payload.torrent_file_indices.as_deref(),
usize::MAX,
)?;
if selected.is_some() {
return Err("magnet file selection requires resolved torrent metadata".to_string());
}
let magnet = crate::torrent::sanitize_magnet_uri_for_aria2(&payload.url)?;
("aria2.addUri", serde_json::json!([[magnet], options]))
}
} else {
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
("aria2.addUri", serde_json::json!([uris, options]))
};
match self.add_transfer_rpc(&state, method, &params).await {
Ok(result) => {
let gid = result.as_str().unwrap_or("").to_string();
if gid.is_empty() {
Err(format!("{method} returned an empty gid"))
} else {
log::info!("aria2 {} [{}]: created gid {}", method, id, gid);
Ok(gid)
}
}
Err(e) => {
let safe_error = crate::redact_sensitive_text(&e);
log::error!("aria2 {} [{}] failed: {}", method, id, safe_error);
Err(format!("aria2 {method} failed: {safe_error}"))
}
}
}
async fn remove_uri(&self, gid: &str) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.forceRemove",
serde_json::json!([gid]),
)
.await?;
match result.as_str() {
Some(returned_gid) if returned_gid == gid => {
crate::wait_for_aria2_stopped(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
gid,
)
.await
}
Some(returned_gid) => Err(format!(
"aria2.forceRemove returned unexpected gid {returned_gid}, expected {gid}"
)),
None => Err("aria2.forceRemove returned a non-string result".to_string()),
}
}
async fn set_download_speed_limit(
&self,
gid: &str,
limit: Option<&str>,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let limit = limit.unwrap_or("0");
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.changeOption",
serde_json::json!([gid, {"max-download-limit": limit}]),
)
.await
.map_err(|error| format!("aria2 changeOption failed for gid {gid}: {error}"))?;
match result.as_str() {
Some("OK") => Ok(()),
Some(value) => Err(format!(
"aria2.changeOption returned unexpected result {value} for gid {gid}"
)),
None => Err("aria2.changeOption returned a non-string result".to_string()),
}
}
async fn set_torrent_upload_limit(
&self,
gid: &str,
limit: Option<&str>,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let limit = limit.unwrap_or("0");
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.changeOption",
serde_json::json!([gid, {"max-upload-limit": limit}]),
)
.await
.map_err(|error| format!("aria2 changeOption failed for gid {gid}: {error}"))?;
match result.as_str() {
Some("OK") => Ok(()),
Some(value) => Err(format!(
"aria2.changeOption returned unexpected result {value} for gid {gid}"
)),
None => Err("aria2.changeOption returned a non-string result".to_string()),
}
}
async fn set_torrent_peer_options(
&self,
gid: &str,
max_peers: u32,
peer_speed_limit: &str,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.changeOption",
serde_json::json!([gid, {
"bt-max-peers": max_peers.to_string(),
"bt-request-peer-speed-limit": peer_speed_limit,
}]),
)
.await
.map_err(|error| format!("aria2.changeOption failed for gid {gid}: {error}"))?;
match result.as_str() {
Some("OK") => Ok(()),
Some(value) => Err(format!(
"aria2.changeOption returned unexpected result {value} for gid {gid}"
)),
None => Err("aria2.changeOption returned a non-string result".to_string()),
}
}
async fn pause_for_seed(
&self,
gid: &str,
) -> Result<Aria2SeedControlOutcome, String> {
self.control_seed_rpc(gid, "aria2.forcePause").await
}
async fn resume_for_seed(
&self,
gid: &str,
) -> Result<Aria2SeedControlOutcome, String> {
self.control_seed_rpc(gid, "aria2.unpause").await
}
async fn get_torrent_uris(&self, gid: &str) -> Result<Vec<String>, String> {
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getUris",
serde_json::json!([gid]),
)
.await
.map_err(|error| format!("aria2.getUris failed for gid {gid}: {}", crate::redact_sensitive_text(&error.to_string())))?;
parse_aria2_web_seed_uris(&result)
}
async fn change_torrent_uris(
&self,
gid: &str,
file_index: u32,
delete: &[String],
add: &[String],
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.changeUri",
serde_json::json!([gid, file_index, delete, add]),
)
.await
.map_err(|error| format!("aria2.changeUri failed for gid {gid}: {}", crate::redact_sensitive_text(&error.to_string())))?;
match result.as_str() {
Some("OK") => Ok(()),
Some(value) => Err(format!("aria2.changeUri returned unexpected result {value}")),
None => Err("aria2.changeUri returned a non-string result".to_string()),
}
}
async fn recreate_uri(
&self,
id: &str,
gid: &str,
payload: &SpawnPayload,
) -> Result<Aria2RecreateOutcome, String> {
let state = self.app_handle.state::<crate::AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = &state.aria2_secret;
let status = match crate::aria2_download_status(port, secret, gid).await {
Ok(status) => Some(status),
Err(error) if crate::aria2_gid_not_found(&error) => {
log::warn!(
"aria2 connection recovery [{}]: gid {} is already absent; rebuilding from the saved payload",
id,
gid
);
None
}
Err(error) => return Err(error),
};
if let Some(status) = status {
match status.as_str() {
"complete" => return Ok(Aria2RecreateOutcome::Complete),
"active" | "waiting" => {
let pause_result = crate::rpc_call(
port,
secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await;
if let Err(error) = pause_result {
match crate::aria2_download_status(port, secret, gid).await {
Ok(status) if status == "paused" => {
log::warn!(
"aria2 connection recovery [{}]: forcePause for gid {} returned an error after the daemon paused it: {}",
id,
gid,
error
);
}
Ok(status) if status == "complete" => {
return Ok(Aria2RecreateOutcome::Complete);
}
Ok(status)
if aria2_recovery_should_rebuild_after_pause_error(&status) =>
{
log::warn!(
"aria2 connection recovery [{}]: gid {} disappeared after forcePause failed; rebuilding from the saved payload: {}",
id,
gid,
error
);
}
Ok(status) => {
return Err(format!(
"failed to pause aria2 gid {gid} before recreation: {error}; daemon reports {status}"
));
}
Err(status_error) => {
return Err(format!(
"failed to pause aria2 gid {gid} before recreation: {error}; status verification failed: {status_error}"
));
}
}
}
}
"paused" => {}
"removed" => {}
other => {
return Err(format!(
"cannot recreate aria2 gid {gid} from daemon state {other}"
));
}
}
}
let remove_result = crate::rpc_call(
port,
secret,
"aria2.forceRemove",
serde_json::json!([gid]),
)
.await;
let remove_error = match remove_result {
Ok(result) => crate::ensure_aria2_gid_result("forceRemove", gid, &result)
.err()
.map(|error| error.to_string()),
Err(error) if crate::aria2_gid_not_found(&error) => None,
Err(error) => Some(error.to_string()),
};
if let Some(error) = remove_error {
match crate::aria2_download_status(port, secret, gid).await {
Ok(status) if status == "complete" => {
return Ok(Aria2RecreateOutcome::Complete);
}
Ok(status) if status == "removed" => {}
Ok(status) => {
return Err(format!(
"failed to remove aria2 gid {gid} before recreation: {error}; daemon reports {status}"
));
}
Err(status_error) if crate::aria2_gid_not_found(&status_error) => {}
Err(status_error) => {
return Err(format!(
"failed to remove aria2 gid {gid} before recreation: {error}; status verification failed: {status_error}"
));
}
}
}
match self.add_uri(id, payload).await {
Ok(new_gid) => Ok(Aria2RecreateOutcome::NewGid(new_gid)),
Err(error) => Ok(Aria2RecreateOutcome::Unavailable(error)),
}
}
async fn refresh_uri(&self, gid: &str) -> Result<Aria2RefreshOutcome, String> {
let state = self.app_handle.state::<crate::AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = &state.aria2_secret;
let pause_error = match crate::rpc_call(
port,
secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await
{
Ok(result) => crate::ensure_aria2_gid_result("forcePause", gid, &result)
.err()
.map(|error| error.to_string()),
Err(error) => Some(format!("failed to refresh aria2 gid {gid}: {error}")),
};
if let Some(error) = pause_error {
match crate::aria2_download_status(port, secret, gid).await {
Ok(status) if status == "paused" => {
log::warn!(
"aria2 connection refresh: forcePause for gid {} failed after the daemon paused it; continuing with unpause",
gid
);
}
Ok(status) if status == "complete" => {
return Ok(Aria2RefreshOutcome::Complete);
}
Ok(status) => {
return Err(format!("{error}; aria2 gid {gid} is still {status}"));
}
Err(status_error) => {
return Err(format!(
"{error}; failed to verify aria2 gid {gid}: {status_error}"
));
}
}
}
let unpause_error = match crate::rpc_call(
port,
secret,
"aria2.unpause",
serde_json::json!([gid]),
)
.await
{
Ok(result) => crate::ensure_aria2_gid_result("unpause", gid, &result)
.err()
.map(|error| error.to_string()),
Err(error) => Some(format!("failed to refresh aria2 gid {gid}: {error}")),
};
let Some(error) = unpause_error else {
return Ok(Aria2RefreshOutcome::Resumed);
};
let status = crate::aria2_download_status(port, secret, gid).await?;
match status.as_str() {
"active" | "waiting" => Ok(Aria2RefreshOutcome::Resumed),
"paused" => Ok(Aria2RefreshOutcome::Paused),
"complete" => Ok(Aria2RefreshOutcome::Complete),
_ => Err(format!(
"{error}; aria2 gid {gid} reports terminal or unknown state {status}"
)),
}
}
async fn run_media(
&self,
id: &str,
payload: &SpawnPayload,
lifecycle_generation: u64,
) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
// Serialize registration with pause/remove/detach. If the queue
// lifecycle was invalidated before this worker reached the
// coordinator, do not create a late media registration that could
// outlive the row's permit and ownership.
let control_guard = state.queue_manager.acquire_aria2_control(id).await;
if !state
.queue_manager
.is_registered_generation(id, lifecycle_generation)
.await
{
drop(control_guard);
return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string());
}
let mut cancel_rx = state
.download_coordinator
.register_media(id.to_string(), lifecycle_generation)
.await?;
drop(control_guard);
let outcome = if *cancel_rx.borrow() {
Err(crate::queue::MEDIA_RUN_CANCELLED.to_string())
} else {
crate::start_media_download_internal(
self.app_handle.clone(),
id,
payload.url.clone(),
payload.destination.clone(),
payload.filename.clone(),
payload.format_selector.clone(),
payload.connections,
payload.cookie_source.clone(),
payload.speed_limit.clone(),
payload.username.clone(),
payload.password.clone(),
payload.headers.clone(),
payload.cookies.clone(),
payload.proxy.clone(),
payload.user_agent.clone(),
payload.max_tries,
&mut cancel_rx,
)
.await
};
if let Ok(path) = outcome.as_ref() {
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, path);
if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) {
use tauri::Emitter;
let _ = self.app_handle.emit(
"download-state",
crate::ipc::DownloadStateEvent::completed_with_file(id, file_name),
);
}
}
let _ = state
.download_coordinator
.finish_media(id.to_string(), lifecycle_generation)
.await;
outcome.map(|_| ())
}
}
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueItem {
pub id: String,
pub queue_id: String,
pub url: String,
pub destination: String,
pub filename: String,
pub connections: Option<i32>,
pub speed_limit: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default)]
#[ts(optional)]
pub sftp_host_key_md: Option<String>,
pub headers: Option<String>,
pub checksum: Option<String>,
pub cookies: Option<String>,
pub mirrors: Option<String>,
pub user_agent: Option<String>,
pub max_tries: Option<i32>,
#[serde(default)]
#[ts(optional)]
pub minimum_normal_download_speed_kib: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub retry_not_found_errors: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub adaptive_mirror_selection: Option<bool>,
pub proxy: Option<String>,
pub format_selector: Option<String>,
pub cookie_source: Option<String>,
pub is_media: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub is_torrent: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_path: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_indices: Option<Vec<u32>>,
#[serde(default)]
#[ts(optional)]
pub torrent_info_hash: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_time: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_ratio: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_remaining: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_web_seeds: Option<Vec<crate::ipc::TorrentWebSeed>>,
#[serde(default)]
#[ts(optional)]
pub torrent_upload_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_max_peers: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_peer_speed_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_check_integrity: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_exclude_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_tracker_connect_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_tracker_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_tracker_interval: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_stop_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub torrent_prioritize_piece: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_remove_unselected_file: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_encryption_policy: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_allocation: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_only: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_restore_status: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
impl EnqueueItem {
pub fn into_task(self) -> QueuedTask {
let media = self.is_media.unwrap_or(false);
let kind = if media {
TaskKind::Media
} else {
TaskKind::Aria2
};
let id = self.id.clone();
QueuedTask {
id,
queue_id: self.queue_id,
kind,
lifecycle_generation: self
.lifecycle_generation
.as_deref()
.and_then(|generation| generation.parse().ok())
.unwrap_or_default(),
payload: SpawnPayload {
url: self.url,
destination: self.destination,
filename: self.filename,
connections: self.connections,
speed_limit: self.speed_limit,
username: self.username,
password: self.password,
sftp_host_key_md: self.sftp_host_key_md,
headers: self.headers,
checksum: self.checksum,
cookies: self.cookies,
mirrors: self.mirrors,
user_agent: self.user_agent,
max_tries: self.max_tries,
minimum_normal_download_speed_kib: self
.minimum_normal_download_speed_kib
.unwrap_or_default(),
retry_not_found_errors: self.retry_not_found_errors.unwrap_or(false),
adaptive_mirror_selection: self.adaptive_mirror_selection.unwrap_or(true),
proxy: self.proxy,
aria2_resolver_mode: Aria2ResolverMode::Automatic,
format_selector: self.format_selector,
cookie_source: self.cookie_source,
is_media: media,
is_torrent: self.is_torrent.unwrap_or(false),
torrent_path: self.torrent_path,
torrent_file_indices: self.torrent_file_indices,
torrent_seed_time: self.torrent_seed_remaining.or(self.torrent_seed_time),
torrent_seed_ratio: self.torrent_seed_ratio,
torrent_seed_remaining: self.torrent_seed_remaining,
torrent_web_seeds: self.torrent_web_seeds,
torrent_upload_limit: self.torrent_upload_limit,
torrent_max_peers: self.torrent_max_peers,
torrent_peer_speed_limit: self.torrent_peer_speed_limit,
torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false),
torrent_trackers: self.torrent_trackers,
torrent_exclude_trackers: self.torrent_exclude_trackers,
torrent_tracker_connect_timeout: self.torrent_tracker_connect_timeout,
torrent_tracker_timeout: self.torrent_tracker_timeout,
torrent_tracker_interval: self.torrent_tracker_interval,
torrent_stop_timeout: self.torrent_stop_timeout,
torrent_prioritize_piece: self.torrent_prioritize_piece,
torrent_remove_unselected_file: self
.torrent_remove_unselected_file
.unwrap_or(false),
torrent_encryption_policy: self.torrent_encryption_policy,
torrent_file_allocation: self.torrent_file_allocation,
torrent_verify_only: self.torrent_verify_only.unwrap_or(false),
torrent_verify_restore_status: self.torrent_verify_restore_status,
torrent_verified_length: None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestSpawner;
#[async_trait::async_trait]
impl SidecarSpawner for TestSpawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
Ok("test-gid".to_string())
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(())
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_lifecycle_generation: u64,
) -> Result<(), String> {
Ok(())
}
}
struct SeedSpawner;
#[async_trait::async_trait]
impl SidecarSpawner for SeedSpawner {
async fn add_uri(&self, id: &str, _payload: &SpawnPayload) -> Result<String, String> {
Ok(format!("gid-{id}"))
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(())
}
async fn pause_for_seed(&self, _gid: &str) -> Result<Aria2SeedControlOutcome, String> {
Ok(Aria2SeedControlOutcome::Paused)
}
async fn resume_for_seed(&self, _gid: &str) -> Result<Aria2SeedControlOutcome, String> {
Ok(Aria2SeedControlOutcome::Resumed)
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_lifecycle_generation: u64,
) -> Result<(), String> {
Ok(())
}
}
#[test]
fn aria2_connection_options_enable_requested_ranges_for_small_release_assets() {
let mut options = serde_json::Map::new();
apply_aria2_connection_options(&mut options, 16);
assert_eq!(options.get("split"), Some(&serde_json::json!("16")));
assert_eq!(
options.get("max-connection-per-server"),
Some(&serde_json::json!("16"))
);
assert_eq!(
options.get("min-split-size"),
Some(&serde_json::json!("1M"))
);
assert_eq!(
options.get("stream-piece-selector"),
Some(&serde_json::json!("inorder"))
);
}
#[test]
fn normal_reliability_options_are_bounded_and_do_not_apply_to_torrents() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
minimum_normal_download_speed_kib: 64,
adaptive_mirror_selection: true,
..SpawnPayload::default()
};
apply_aria2_normal_reliability_options(&mut options, &payload, 2).unwrap();
assert_eq!(
options.get("lowest-speed-limit"),
Some(&serde_json::json!("64K"))
);
assert_eq!(
options.get("uri-selector"),
Some(&serde_json::json!("adaptive"))
);
options.clear();
apply_aria2_normal_reliability_options(&mut options, &payload, 1).unwrap();
assert!(!options.contains_key("uri-selector"));
options.clear();
apply_aria2_normal_reliability_options(
&mut options,
&SpawnPayload {
is_torrent: true,
minimum_normal_download_speed_kib: 64,
adaptive_mirror_selection: true,
..SpawnPayload::default()
},
2,
)
.unwrap();
assert!(options.is_empty());
assert!(normalize_minimum_normal_download_speed_kib(
MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB + 1
)
.is_err());
}
#[test]
fn aria2_system_resolver_mode_is_per_transfer_and_preserves_automatic_default() {
let mut automatic = serde_json::Map::new();
apply_aria2_resolver_options(&mut automatic, Aria2ResolverMode::Automatic);
assert!(!automatic.contains_key("async-dns"));
let mut system = serde_json::Map::new();
apply_aria2_resolver_options(&mut system, Aria2ResolverMode::System);
assert_eq!(system.get("async-dns"), Some(&serde_json::json!("false")));
}
#[test]
fn resolver_state_events_are_typed_and_redacted() {
let event = DownloadStateEvent::failed(
"dns-event",
"aria2 error code 19: Name resolution failed for https://example.test/file?token=secret",
);
assert_eq!(event.error_kind, Some(crate::ipc::DownloadErrorKind::NameResolution));
assert!(event.error.as_deref().is_some_and(|error| {
error.contains("[redacted]") && !error.contains("token=secret")
}));
let retry = DownloadStateEvent::retrying_with_resolver_fallback(
"dns-event",
"aria2 error code 19: Name resolution failed",
);
assert_eq!(retry.error_kind, Some(crate::ipc::DownloadErrorKind::NameResolution));
assert_eq!(retry.resolver_fallback, Some(true));
}
#[test]
fn torrent_payloads_do_not_use_generic_connection_options() {
let torrent = SpawnPayload {
is_torrent: true,
connections: Some(16),
..Default::default()
};
let normal = SpawnPayload {
is_torrent: false,
connections: Some(16),
..Default::default()
};
let mut torrent_options = serde_json::Map::new();
if should_apply_aria2_connection_options(&torrent) {
apply_aria2_connection_options(&mut torrent_options, 16);
}
assert!(!torrent_options.contains_key("split"));
assert!(!torrent_options.contains_key("max-connection-per-server"));
let mut normal_options = serde_json::Map::new();
if should_apply_aria2_connection_options(&normal) {
apply_aria2_connection_options(&mut normal_options, 16);
}
assert_eq!(normal_options.get("split"), Some(&serde_json::json!("16")));
assert_eq!(normal_options.get("max-connection-per-server"), Some(&serde_json::json!("16")));
}
#[test]
fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() {
assert_eq!(
normalize_torrent_bind_address(Some(" 2001:db8::1 ")).unwrap(),
Some("2001:db8::1".to_string())
);
assert_eq!(normalize_torrent_bind_address(Some(" ")).unwrap(), None);
assert!(normalize_torrent_bind_address(Some("localhost")).is_err());
assert!(normalize_torrent_bind_address(Some("127.0.0.1\n--bad")).is_err());
assert_eq!(normalize_aria2_disk_cache(None).unwrap(), "16M");
assert_eq!(normalize_aria2_disk_cache(Some(" 256m ")).unwrap(), "256M");
assert_eq!(normalize_aria2_disk_cache(Some("1024K")).unwrap(), "1024K");
assert_eq!(normalize_aria2_disk_cache(Some("0")).unwrap(), "0");
assert!(normalize_aria2_disk_cache(Some("1025M")).is_err());
assert!(normalize_aria2_disk_cache(Some("16")).is_err());
assert_eq!(normalize_torrent_file_allocation(None).unwrap(), "prealloc");
assert_eq!(normalize_torrent_file_allocation(Some(" none ")).unwrap(), "none");
assert!(normalize_torrent_file_allocation(Some("truncate")).is_err());
}
#[test]
fn torrent_verification_evidence_requires_matching_lengths_but_accepts_empty_data() {
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
false,
Some(0),
Some(0),
),
Some(0)
);
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
false,
Some(100),
Some(99),
),
None
);
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
true,
Some(100),
Some(100),
),
None
);
}
#[test]
fn torrent_options_disable_seeding_when_no_policy_is_saved() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0")));
assert!(!options.contains_key("max-upload-limit"));
assert_eq!(
options.get("bt-force-encryption"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("bt-require-crypto"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("bt-min-crypto-level"),
Some(&serde_json::json!("plain"))
);
}
#[test]
fn generic_aria2_downloads_disable_followed_child_gids() {
let mut options = serde_json::Map::new();
apply_aria2_follow_options(&mut options, &SpawnPayload::default());
assert_eq!(
options.get("follow-torrent"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("follow-metalink"),
Some(&serde_json::json!("false"))
);
}
#[test]
fn explicit_torrent_downloads_do_not_override_follow_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
..Default::default()
};
apply_aria2_follow_options(&mut options, &payload);
assert!(!options.contains_key("follow-torrent"));
assert!(!options.contains_key("follow-metalink"));
}
#[test]
fn torrent_encryption_policy_maps_to_one_consistent_aria2_policy() {
let cases = [
(
Some("require-crypto"),
("false", "true", "plain"),
),
(
Some("force-encryption"),
("true", "true", "arc4"),
),
(None, ("false", "false", "plain")),
];
for (policy, expected) in cases {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_encryption_policy: policy.map(str::to_string),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-force-encryption"),
Some(&serde_json::json!(expected.0))
);
assert_eq!(
options.get("bt-require-crypto"),
Some(&serde_json::json!(expected.1))
);
assert_eq!(
options.get("bt-min-crypto-level"),
Some(&serde_json::json!(expected.2))
);
}
}
#[test]
fn torrent_encryption_policy_rejects_unknown_values() {
assert_eq!(normalize_torrent_encryption_policy(None).unwrap(), None);
assert_eq!(
normalize_torrent_encryption_policy(Some(" disabled ")).unwrap(),
None
);
assert_eq!(
normalize_torrent_encryption_policy(Some("require-crypto")).unwrap(),
Some("require-crypto".to_string())
);
assert!(normalize_torrent_encryption_policy(Some("arc4")).is_err());
assert!(normalize_torrent_encryption_policy(Some("true")).is_err());
}
#[test]
fn torrent_encryption_policy_is_not_applied_to_non_torrent_payloads() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
torrent_encryption_policy: Some("force-encryption".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("bt-force-encryption"));
assert!(!options.contains_key("bt-require-crypto"));
assert!(!options.contains_key("bt-min-crypto-level"));
}
#[test]
fn torrent_options_preserve_seed_policy_and_upload_limit() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_seed_time: Some(30.0),
torrent_seed_ratio: Some(1.5),
torrent_upload_limit: Some("2 MiB/s".to_string()),
torrent_max_peers: Some(120),
torrent_peer_speed_limit: Some("2M".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("seed-time"), Some(&serde_json::json!("30")));
assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("1.5")));
assert_eq!(
options.get("max-upload-limit"),
Some(&serde_json::json!("2M"))
);
assert_eq!(
options.get("bt-max-peers"),
Some(&serde_json::json!("120"))
);
assert_eq!(
options.get("bt-request-peer-speed-limit"),
Some(&serde_json::json!("2M"))
);
assert!(torrent_seeding_requested(&payload));
}
#[test]
fn torrent_options_support_ratio_only_seeding_without_disabling_seed_time() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_seed_ratio: Some(1.5),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("seed-time"));
assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("1.5")));
assert!(torrent_seeding_requested(&payload));
}
#[test]
fn torrent_integrity_check_does_not_enter_seeding_without_a_seed_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_check_integrity: true,
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
assert_eq!(
options.get("bt-hash-check-seed"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("bt-seed-unverified"),
Some(&serde_json::json!("false"))
);
}
#[test]
fn torrent_verification_uses_hash_only_options_and_ignores_transfer_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_verify_only: true,
torrent_trackers: Some("https://tracker.example/announce".to_string()),
torrent_seed_ratio: Some(1.0),
torrent_file_allocation: Some("none".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
assert_eq!(options.get("hash-check-only"), Some(&serde_json::json!("true")));
assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0")));
assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("0")));
assert!(!options.contains_key("bt-tracker"));
assert!(!options.contains_key("file-allocation"));
}
#[test]
fn torrent_integrity_check_preserves_an_explicit_seeding_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_check_integrity: true,
torrent_seed_ratio: Some(1.0),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
assert_eq!(
options.get("bt-hash-check-seed"),
Some(&serde_json::json!("true"))
);
assert_eq!(
options.get("bt-seed-unverified"),
Some(&serde_json::json!("false"))
);
}
#[test]
fn torrent_integrity_options_are_not_emitted_when_disabled_or_non_torrent() {
for payload in [
SpawnPayload::default(),
SpawnPayload {
is_torrent: true,
..Default::default()
},
] {
let mut options = serde_json::Map::new();
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("check-integrity"));
assert!(!options.contains_key("bt-hash-check-seed"));
assert!(!options.contains_key("bt-seed-unverified"));
}
}
#[test]
fn enqueue_item_deserializes_integrity_policy_from_frontend_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-integrity",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_check_integrity": true
}))
.expect("frontend enqueue payload should deserialize");
assert!(item.into_task().payload.torrent_check_integrity);
}
#[test]
fn enqueue_item_preserves_torrent_encryption_policy() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-encryption",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_encryption_policy": "force-encryption"
}))
.expect("frontend enqueue payload should deserialize");
assert_eq!(
item.into_task().payload.torrent_encryption_policy.as_deref(),
Some("force-encryption")
);
}
#[test]
fn enqueue_item_preserves_torrent_tracker_timing() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-tracker-timing",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_tracker_connect_timeout": 11,
"torrent_tracker_timeout": 22,
"torrent_tracker_interval": 33
}))
.expect("frontend enqueue payload should deserialize");
let payload = item.into_task().payload;
assert_eq!(payload.torrent_tracker_connect_timeout, Some(11));
assert_eq!(payload.torrent_tracker_timeout, Some(22));
assert_eq!(payload.torrent_tracker_interval, Some(33));
}
#[test]
fn torrent_network_settings_are_normalized_and_bounded() {
assert_eq!(
normalize_torrent_port_spec(Some(" 6881-6999, 7000 "), "TCP listen ports").unwrap(),
Some("6881-6999,7000".to_string())
);
assert_eq!(
normalize_torrent_external_ip(Some(" 2001:db8::1 ")).unwrap(),
Some("2001:db8::1".to_string())
);
assert_eq!(
normalize_torrent_dht_entry_point(Some("Bootstrap.Example:6881"), false).unwrap(),
Some("bootstrap.example:6881".to_string())
);
assert_eq!(
normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), true).unwrap(),
Some("[2001:db8::1]:6881".to_string())
);
assert_eq!(
normalize_torrent_dht_listen_addr6(Some("2001:db8::2")).unwrap(),
Some("2001:db8::2".to_string())
);
assert_eq!(
normalize_torrent_lpd_interface(Some("en0")).unwrap(),
Some("en0".to_string())
);
assert_eq!(
normalize_torrent_peer_id_prefix(Some("-FL-1-3-1-")).unwrap(),
Some("-FL-1-3-1-".to_string())
);
assert_eq!(
normalize_torrent_peer_agent(Some("Firelink/1.3.1")).unwrap(),
Some("Firelink/1.3.1".to_string())
);
assert!(torrent_port_spec_contains("6800-6802,6881", 6801));
assert!(!torrent_port_spec_contains("6800-6802,6881", 6803));
assert_eq!(normalize_torrent_port_spec(Some(" "), "TCP listen ports").unwrap(), None);
assert_eq!(
normalize_torrent_port_spec(Some("1024"), "TCP listen ports").unwrap(),
Some("1024".to_string())
);
}
#[test]
fn torrent_network_settings_reject_unsafe_or_malformed_values() {
for value in ["0", "1023", "65536", "7000-6999", "6881,", "6881-"] {
assert!(
normalize_torrent_port_spec(Some(value), "TCP listen ports").is_err(),
"{value}"
);
}
assert!(normalize_torrent_external_ip(Some("example.com")).is_err());
assert!(normalize_torrent_dht_entry_point(Some("example.com"), false).is_err());
assert!(normalize_torrent_dht_entry_point(Some("[2001:db8::1]:6881"), false).is_err());
assert!(normalize_torrent_dht_entry_point(Some("2001:db8::1:6881"), true).is_err());
assert!(normalize_torrent_dht_listen_addr6(Some("127.0.0.1")).is_err());
assert!(normalize_torrent_lpd_interface(Some("en0\n--bad")).is_err());
assert!(normalize_torrent_peer_id_prefix(Some("é")).is_err());
assert!(normalize_torrent_peer_id_prefix(Some("123456789012345678901")).is_err());
assert!(normalize_torrent_peer_agent(Some("agent\nname")).is_err());
assert!(normalize_torrent_peer_agent(Some(&"a".repeat(MAX_TORRENT_PEER_AGENT_LENGTH + 1))).is_err());
assert!(
normalize_torrent_port_spec(
Some(&"1".repeat(MAX_TORRENT_NETWORK_VALUE_LENGTH + 1)),
"TCP listen ports"
)
.is_err()
);
}
#[test]
fn torrent_trackers_are_normalized_and_deduplicated() {
assert_eq!(
normalize_torrent_trackers(Some(
" https://tracker.example/announce\nudp://tracker.example:6969/announce\nhttps://tracker.example/announce "
))
.unwrap(),
Some("https://tracker.example/announce,udp://tracker.example:6969/announce".to_string())
);
assert_eq!(normalize_torrent_trackers(Some(" \n\n ")).unwrap(), None);
}
#[test]
fn torrent_trackers_reject_unsafe_or_unbounded_values() {
for value in [
"ftp://tracker.example/announce",
"https://user:pass@tracker.example/announce",
"https://tracker.example/announce#fragment",
"https://tracker.example/announce,",
"https://",
] {
assert!(normalize_torrent_trackers(Some(value)).is_err(), "{value}");
}
let too_many = (0..=MAX_TORRENT_TRACKERS)
.map(|index| format!("https://tracker{index}.example/announce"))
.collect::<Vec<_>>()
.join("\n");
assert!(normalize_torrent_trackers(Some(&too_many)).is_err());
}
#[test]
fn torrent_exclude_trackers_support_wildcard_and_normalized_uris() {
assert_eq!(normalize_torrent_exclude_trackers(Some("*")).unwrap(), Some("*".to_string()));
assert_eq!(
normalize_torrent_exclude_trackers(Some(
" https://tracker.example/announce\nudp://tracker.example:6969/announce "
))
.unwrap(),
Some("https://tracker.example/announce,udp://tracker.example:6969/announce".to_string())
);
assert_eq!(normalize_torrent_exclude_trackers(Some(" \n\n ")).unwrap(), None);
}
#[test]
fn torrent_exclude_trackers_reject_unsafe_or_ambiguous_values() {
for value in [
"ftp://tracker.example/announce",
"https://user:pass@tracker.example/announce",
"https://tracker.example/announce#fragment",
"https://tracker.example/announce,*",
"*,https://tracker.example/announce",
"https://tracker.example/announce,",
] {
assert!(normalize_torrent_exclude_trackers(Some(value)).is_err(), "{value}");
}
}
#[test]
fn torrent_tracker_timing_is_bounded_and_preserves_aria2_defaults() {
assert_eq!(normalize_torrent_tracker_connect_timeout(None).unwrap(), None);
assert_eq!(
normalize_torrent_tracker_connect_timeout(Some(1)).unwrap(),
Some(1)
);
assert_eq!(
normalize_torrent_tracker_request_timeout(Some(MAX_TORRENT_TRACKER_TIMEOUT)).unwrap(),
Some(MAX_TORRENT_TRACKER_TIMEOUT)
);
assert_eq!(normalize_torrent_tracker_interval(Some(0)).unwrap(), Some(0));
assert!(normalize_torrent_tracker_connect_timeout(Some(0)).is_err());
assert!(normalize_torrent_tracker_request_timeout(Some(0)).is_err());
assert!(normalize_torrent_tracker_interval(Some(MAX_TORRENT_TRACKER_INTERVAL + 1)).is_err());
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_tracker_connect_timeout: Some(11),
torrent_tracker_timeout: Some(22),
torrent_tracker_interval: Some(33),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-tracker-connect-timeout"),
Some(&serde_json::json!("11"))
);
assert_eq!(
options.get("bt-tracker-timeout"),
Some(&serde_json::json!("22"))
);
assert_eq!(
options.get("bt-tracker-interval"),
Some(&serde_json::json!("33"))
);
}
#[test]
fn torrent_tracker_timing_is_not_applied_to_non_torrent_payloads() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
torrent_tracker_connect_timeout: Some(11),
torrent_tracker_timeout: Some(22),
torrent_tracker_interval: Some(33),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("bt-tracker-connect-timeout"));
assert!(!options.contains_key("bt-tracker-timeout"));
assert!(!options.contains_key("bt-tracker-interval"));
}
#[test]
fn torrent_max_open_files_is_bounded() {
assert_eq!(normalize_torrent_max_open_files(1).unwrap(), 1);
assert_eq!(
normalize_torrent_max_open_files(MAX_TORRENT_MAX_OPEN_FILES).unwrap(),
MAX_TORRENT_MAX_OPEN_FILES
);
assert!(normalize_torrent_max_open_files(0).is_err());
assert!(normalize_torrent_max_open_files(MAX_TORRENT_MAX_OPEN_FILES + 1).is_err());
}
#[test]
fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_trackers: Some("https://tracker.example/announce".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-tracker"),
Some(&serde_json::json!("https://tracker.example/announce"))
);
}
#[test]
fn torrent_exclude_trackers_are_emitted_as_the_aria2_option() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_exclude_trackers: Some("*".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-exclude-tracker"),
Some(&serde_json::json!("*"))
);
}
#[test]
fn torrent_stop_timeout_is_normalized_and_emitted() {
assert_eq!(normalize_torrent_stop_timeout(None).unwrap(), None);
assert_eq!(normalize_torrent_stop_timeout(Some(0)).unwrap(), Some(0));
assert_eq!(
normalize_torrent_stop_timeout(Some(MAX_TORRENT_STOP_TIMEOUT)).unwrap(),
Some(MAX_TORRENT_STOP_TIMEOUT)
);
assert!(normalize_torrent_stop_timeout(Some(MAX_TORRENT_STOP_TIMEOUT + 1)).is_err());
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_stop_timeout: Some(300),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-stop-timeout"),
Some(&serde_json::json!("300"))
);
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_stop_timeout: Some(0),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-stop-timeout"),
Some(&serde_json::json!("0"))
);
}
#[test]
fn torrent_stop_timeout_is_not_applied_to_non_torrent_payloads() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
torrent_stop_timeout: Some(300),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("bt-stop-timeout"));
}
#[test]
fn torrent_piece_priority_is_normalized_and_bounded() {
assert_eq!(
normalize_torrent_prioritize_piece(Some(" tail = 64k, HEAD ")).unwrap(),
Some("head,tail=64K".to_string())
);
assert_eq!(
normalize_torrent_prioritize_piece(Some("head=1m,tail=1024M")).unwrap(),
Some("head=1M,tail=1024M".to_string())
);
assert_eq!(normalize_torrent_prioritize_piece(Some(" ")).unwrap(), None);
for value in [
"head,head",
"tail,tail",
"middle",
"head=0K",
"tail=1G",
"head=1🙂",
"head=1K,",
] {
assert!(
normalize_torrent_prioritize_piece(Some(value)).is_err(),
"{value}"
);
}
assert!(normalize_torrent_prioritize_piece(Some("head=1025M")).is_err());
}
#[test]
fn torrent_piece_priority_is_emitted_as_the_aria2_option() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_prioritize_piece: Some("tail=2m,head".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-prioritize-piece"),
Some(&serde_json::json!("head,tail=2M"))
);
}
#[test]
fn torrent_piece_priority_is_not_applied_to_non_torrent_payloads() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
torrent_prioritize_piece: Some("head".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("bt-prioritize-piece"));
}
#[test]
fn torrent_unselected_file_removal_requires_a_non_empty_file_selection() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_remove_unselected_file: true,
torrent_file_indices: Some(vec![]),
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("file selection"));
assert!(!options.contains_key("bt-remove-unselected-file"));
}
#[test]
fn torrent_unselected_file_removal_is_emitted_only_for_selected_torrent_files() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_remove_unselected_file: true,
torrent_file_indices: Some(vec![1]),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-remove-unselected-file"),
Some(&serde_json::json!("true"))
);
}
#[test]
fn torrent_unselected_file_removal_is_not_applied_without_torrent_selection() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_remove_unselected_file: true,
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("subset"));
}
#[test]
fn torrent_peer_diagnostics_are_bounded_and_omit_identity_and_bitfields() {
let mut result = vec![serde_json::json!({
"peerId": "secret-peer-id",
"ip": "192.0.2.10",
"port": "6881",
"bitfield": "ffffffff",
"downloadSpeed": "10602",
"uploadSpeed": "6890",
"seeder": "true",
"amChoking": "false",
"peerChoking": "true"
})];
result.extend((1..MAX_TORRENT_PEER_DIAGNOSTICS + 2).map(|index| {
serde_json::json!({
"peerId": format!("peer-{index}"),
"ip": format!("192.0.2.{index}"),
"downloadSpeed": index.to_string(),
"uploadSpeed": 0,
"seeder": index == MAX_TORRENT_PEER_DIAGNOSTICS + 1,
"amChoking": false,
"peerChoking": false
})
}));
let diagnostics = parse_torrent_peer_diagnostics(serde_json::Value::Array(result)).unwrap();
assert_eq!(diagnostics.total_peers, (MAX_TORRENT_PEER_DIAGNOSTICS + 2) as u32);
assert_eq!(diagnostics.total_seeders, 2);
assert_eq!(diagnostics.peers.len(), MAX_TORRENT_PEER_DIAGNOSTICS);
assert!(diagnostics.truncated);
assert_eq!(diagnostics.peers[0].ip.as_deref(), Some("192.0.2.10"));
assert_eq!(diagnostics.peers[0].port, Some(6881));
let serialized = serde_json::to_string(&diagnostics).unwrap();
assert!(serialized.contains("192.0.2."));
assert!(!serialized.contains("\"port\":null"));
assert!(!serialized.contains("peerId"));
assert!(!serialized.contains("secret-peer-id"));
assert!(!serialized.contains("bitfield"));
}
#[test]
fn torrent_peer_diagnostics_reject_non_array_results() {
let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err();
assert!(error.contains("non-array"));
}
#[test]
fn torrent_peer_diagnostics_reject_malformed_peer_entries() {
let error = parse_torrent_peer_diagnostics(serde_json::json!([{
"seeder": true
}, "not-a-peer"]))
.unwrap_err();
assert!(error.contains("malformed"));
}
fn test_torrent_progress_metadata() -> Vec<crate::ipc::TorrentFile> {
vec![
crate::ipc::TorrentFile {
index: 1,
path: "folder/one.bin".to_string(),
length: 12,
},
crate::ipc::TorrentFile {
index: 2,
path: "folder/two.bin".to_string(),
length: 7,
},
]
}
#[test]
fn torrent_file_progress_uses_validated_metadata_and_discards_daemon_paths() {
let result = serde_json::json!([
{
"index": "2",
"path": "/private/secret/two.bin",
"uris": [{"uri": "https://secret.example/file"}],
"length": "7",
"completedLength": "3",
"selected": "false"
},
{
"index": "1",
"path": "/private/secret/one.bin",
"length": "12",
"completedLength": "12",
"selected": "true"
}
]);
let snapshot = parse_torrent_file_progress(result, &test_torrent_progress_metadata()).unwrap();
assert_eq!(snapshot.files[0].relative_path, "folder/one.bin");
assert_eq!(snapshot.files[0].completed_length, 12);
assert!(snapshot.files[0].selected);
assert_eq!(snapshot.files[1].relative_path, "folder/two.bin");
assert_eq!(snapshot.files[1].completed_length, 3);
assert!(!snapshot.files[1].selected);
let serialized = serde_json::to_string(&snapshot).unwrap();
assert!(!serialized.contains("/private/secret"));
assert!(!serialized.contains("secret.example"));
}
#[test]
fn torrent_file_progress_rejects_malformed_and_inconsistent_rows() {
let metadata = test_torrent_progress_metadata();
let cases = [
(
serde_json::json!([{
"index": "1",
"length": "not-a-number",
"completedLength": "0",
"selected": "true"
}, {
"index": "2",
"length": "7",
"completedLength": "0",
"selected": "true"
}]),
"invalid file length",
),
(
serde_json::json!([{
"index": "1",
"length": "12",
"completedLength": "13",
"selected": "true"
}, {
"index": "2",
"length": "7",
"completedLength": "0",
"selected": "true"
}]),
"over-complete",
),
(
serde_json::json!([{
"index": "1",
"length": "12",
"completedLength": "0",
"selected": "true"
}, {
"index": "1",
"length": "12",
"completedLength": "0",
"selected": "true"
}]),
"duplicate",
),
(
serde_json::json!([{
"index": "3",
"length": "12",
"completedLength": "0",
"selected": "true"
}, {
"index": "2",
"length": "7",
"completedLength": "0",
"selected": "true"
}]),
"unknown",
),
];
for (result, expected) in cases {
let error = parse_torrent_file_progress(result, &metadata).unwrap_err();
assert!(error.contains(expected), "{error}");
}
let error = parse_torrent_file_progress(serde_json::json!([]), &metadata).unwrap_err();
assert!(error.contains("file count"));
}
#[test]
fn torrent_piece_progress_decodes_high_bit_first_and_buckets_small_torrents() {
let snapshot = parse_torrent_piece_progress(serde_json::json!({
"pieceLength": "16384",
"numPieces": "10",
"bitfield": "9040"
}))
.unwrap();
assert_eq!(snapshot.piece_length, 16_384);
assert_eq!(snapshot.num_pieces, 10);
assert_eq!(snapshot.completed_pieces, 3);
assert_eq!(snapshot.buckets, vec![100, 0, 0, 100, 0, 0, 0, 0, 0, 100]);
}
#[test]
fn torrent_piece_progress_aggregates_to_at_most_256_balanced_buckets() {
let bitfield = "ff".repeat(64);
let snapshot = parse_torrent_piece_progress(serde_json::json!({
"pieceLength": "1",
"numPieces": "512",
"bitfield": bitfield
}))
.unwrap();
assert_eq!(snapshot.completed_pieces, 512);
assert_eq!(snapshot.buckets.len(), 256);
assert!(snapshot.buckets.iter().all(|value| *value == 100));
}
#[test]
fn torrent_piece_progress_rejects_missing_fields_malformed_hex_and_overflow_bits() {
let cases = [
(serde_json::json!({}), "invalid pieceLength"),
(
serde_json::json!({
"pieceLength": "16384",
"numPieces": "0",
"bitfield": ""
}),
"non-positive",
),
(
serde_json::json!({
"pieceLength": "16384",
"numPieces": "8",
"bitfield": "xz"
}),
"invalid piece bitfield",
),
(
serde_json::json!({
"pieceLength": "16384",
"numPieces": "10",
"bitfield": "904f"
}),
"overflow",
),
];
for (result, expected) in cases {
let error = parse_torrent_piece_progress(result).unwrap_err();
assert!(error.contains(expected), "{error}");
}
}
#[test]
fn torrent_network_limits_and_web_seed_normalization_are_bounded() {
assert_eq!(normalize_torrent_dht_message_timeout(1).unwrap(), 1);
assert_eq!(normalize_torrent_dht_message_timeout(600).unwrap(), 600);
assert!(normalize_torrent_dht_message_timeout(0).is_err());
assert_eq!(normalize_torrent_max_concurrent_seeds(2).unwrap(), 2);
assert!(normalize_torrent_max_concurrent_seeds(65).is_err());
let files = vec![
crate::ipc::TorrentFile {
index: 1,
path: "folder/file.bin".to_string(),
length: 3,
},
crate::ipc::TorrentFile {
index: 2,
path: "other.txt".to_string(),
length: 4,
},
];
let seeds = normalize_torrent_web_seeds(
Some(&[
crate::ipc::TorrentWebSeed {
file_index: 1,
uri: " https://mirror.example/base/ ".to_string(),
},
crate::ipc::TorrentWebSeed {
file_index: 1,
uri: "https://mirror.example/base/".to_string(),
},
]),
&files,
)
.unwrap();
assert_eq!(seeds.len(), 1);
assert_eq!(
expand_torrent_web_seeds(&seeds, &files).unwrap()[0].1,
"https://mirror.example/base/folder/file.bin"
);
assert!(normalize_torrent_web_seeds(
Some(&[crate::ipc::TorrentWebSeed {
file_index: 1,
uri: "ftp://mirror.example/file".to_string(),
}]),
&files,
)
.is_err());
assert!(normalize_torrent_web_seeds(
Some(&[crate::ipc::TorrentWebSeed {
file_index: 9,
uri: "https://mirror.example/file".to_string(),
}]),
&files,
)
.is_err());
}
#[test]
fn normal_protocol_options_use_the_protocol_specific_aria2_keys() {
let payload = SpawnPayload {
username: Some("alice".to_string()),
password: Some("secret".to_string()),
sftp_host_key_md: Some(
"sha-1=0123456789abcdef0123456789abcdef01234567".to_string(),
),
..SpawnPayload::default()
};
let mut options = serde_json::Map::new();
apply_protocol_auth_options(
&mut options,
&payload,
&["ftp://example.test/file.bin".to_string()],
);
assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice")));
assert_eq!(options.get("ftp-passwd"), Some(&serde_json::json!("secret")));
assert!(!options.contains_key("http-user"));
assert!(!options.contains_key("ssh-host-key-md"));
options.clear();
apply_protocol_auth_options(
&mut options,
&payload,
&["sftp://example.test/file.bin".to_string()],
);
assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice")));
assert_eq!(
options.get("ssh-host-key-md"),
Some(&serde_json::json!("sha-1=0123456789abcdef0123456789abcdef01234567"))
);
assert!(!options.contains_key("http-user"));
}
#[test]
fn normal_checksum_options_enable_aria2_integrity_checks() {
let mut options = serde_json::Map::new();
apply_checksum_options(&mut options, Some("SHA-256=ABCDEF"));
assert_eq!(options.get("checksum"), Some(&serde_json::json!("sha-256=ABCDEF")));
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
options.clear();
apply_checksum_options(&mut options, None);
assert!(options.is_empty());
}
#[test]
fn sftp_host_key_fingerprints_are_normalized_and_bounded() {
assert_eq!(
normalize_sftp_host_key_md(Some(
" SHA-1=0123456789ABCDEF0123456789ABCDEF01234567 ",
))
.unwrap()
.as_deref(),
Some("sha-1=0123456789abcdef0123456789abcdef01234567")
);
assert!(normalize_sftp_host_key_md(Some("sha-256=abcd")).is_err());
assert!(normalize_sftp_host_key_md(Some("md5=abcd")).is_err());
assert!(normalize_sftp_host_key_md(Some("sha-1=xyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzx")).is_err());
assert_eq!(normalize_sftp_host_key_md(Some(" ")).unwrap(), None);
}
#[test]
fn range_probe_does_not_forward_credentials_across_origins() {
crate::ensure_reqwest_crypto_provider();
let payload = SpawnPayload {
username: Some("alice".to_string()),
password: Some("secret".to_string()),
cookies: Some("session=secret".to_string()),
headers: Some("Authorization: Bearer secret\nX-Test: yes".to_string()),
..SpawnPayload::default()
};
let client = reqwest::Client::builder().build().unwrap();
let same_origin = apply_payload_headers(
client.get("https://example.test/file"),
&payload,
true,
)
.build()
.unwrap();
assert!(same_origin.headers().contains_key(reqwest::header::COOKIE));
assert!(same_origin.headers().contains_key(reqwest::header::AUTHORIZATION));
assert_eq!(same_origin.headers().get("x-test").unwrap(), "yes");
let cross_origin = apply_payload_headers(
client.get("https://cdn.example.test/file"),
&payload,
false,
)
.build()
.unwrap();
assert!(!cross_origin.headers().contains_key(reqwest::header::COOKIE));
assert!(!cross_origin.headers().contains_key(reqwest::header::AUTHORIZATION));
assert!(!cross_origin.headers().contains_key("x-test"));
}
#[test]
fn range_probe_allows_http_to_https_upgrade_but_not_cross_origin_return() {
let original = reqwest::Url::parse("http://example.test/file").unwrap();
let upgrade = reqwest::Url::parse("https://example.test/file").unwrap();
let unrelated = reqwest::Url::parse("https://cdn.example.test/file").unwrap();
assert!(can_forward_payload_credentials(&original, &original));
assert!(can_forward_payload_credentials(&original, &upgrade));
assert!(!can_forward_payload_credentials(&original, &unrelated));
assert!(!can_forward_payload_credentials(
&reqwest::Url::parse("https://example.test/file").unwrap(),
&reqwest::Url::parse("http://example.test/file").unwrap()
));
}
#[test]
fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-trackers",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_trackers": "https://tracker.example/announce",
"torrent_exclude_trackers": "*"
}))
.expect("frontend enqueue payload should deserialize");
let payload = item.into_task().payload;
assert_eq!(
payload.torrent_trackers.as_deref(),
Some("https://tracker.example/announce")
);
assert_eq!(payload.torrent_exclude_trackers.as_deref(), Some("*"));
}
#[test]
fn enqueue_item_carries_normal_reliability_policy_into_the_spawn_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "normal-reliability",
"queue_id": "main",
"url": "https://example.test/file.bin",
"destination": "/tmp/downloads",
"filename": "file.bin",
"minimum_normal_download_speed_kib": 64,
"retry_not_found_errors": true,
"adaptive_mirror_selection": false
}))
.expect("frontend enqueue payload should deserialize");
let payload = item.into_task().payload;
assert_eq!(payload.minimum_normal_download_speed_kib, 64);
assert!(payload.retry_not_found_errors);
assert!(!payload.adaptive_mirror_selection);
}
#[test]
fn enqueue_item_carries_torrent_stop_timeout_into_the_spawn_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-stop-timeout",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_stop_timeout": 300
}))
.expect("frontend enqueue payload should deserialize");
assert_eq!(
item.into_task().payload.torrent_stop_timeout,
Some(300)
);
}
#[test]
fn enqueue_item_carries_torrent_piece_priority_into_the_spawn_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-piece-priority",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_prioritize_piece": "head=2M,tail=1M"
}))
.expect("frontend enqueue payload should deserialize");
assert_eq!(
item.into_task().payload.torrent_prioritize_piece.as_deref(),
Some("head=2M,tail=1M")
);
}
#[test]
fn enqueue_item_carries_torrent_unselected_file_removal_into_the_spawn_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-remove-unselected",
"queue_id": "main",
"url": "file:///tmp/payload.torrent",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_file_indices": [1],
"torrent_remove_unselected_file": true
}))
.expect("frontend enqueue payload should deserialize");
assert!(item.into_task().payload.torrent_remove_unselected_file);
}
#[tokio::test]
async fn internally_created_enqueue_advances_past_cancelled_generation() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
manager.cancel_enqueue_generation("torrent", 4).await;
let generation = manager
.next_enqueue_generation("torrent")
.await
.expect("a fresh generation should be available");
assert_eq!(generation, 5);
manager
.reserve_enqueue_generation("torrent", generation)
.await
.expect("the fresh generation should not be rejected as stale");
assert_eq!(manager.registered_lifecycle_generation("torrent").await, Some(5));
}
#[test]
fn torrent_options_reject_invalid_seed_values() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_seed_time: Some(f64::NAN),
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("seed time"));
}
#[test]
fn torrent_options_reject_invalid_peer_values() {
assert!(normalize_torrent_max_peers(Some(-1)).is_err());
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_max_peers: Some(MAX_TORRENT_MAX_PEERS + 1),
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("maximum peers"));
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_peer_speed_limit: Some("0".to_string()),
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("peer speed limit"));
}
#[tokio::test]
async fn seeding_outcome_keeps_torrent_ownership_and_permit_live() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(
app.handle().clone(),
1,
Arc::new(TestSpawner),
);
let payload = SpawnPayload {
is_torrent: true,
torrent_seed_time: Some(30.0),
..Default::default()
};
assert!(manager.ensure_aria2_permit("torrent").await);
manager
.aria2_payloads
.lock()
.await
.insert("torrent".to_string(), payload);
manager
.remember_gid("torrent".to_string(), "test-gid".to_string())
.await;
manager
.apply_completion("torrent", PendingOutcome::Seeding)
.await;
assert_eq!(manager.aria2_gid_for_download("torrent").as_deref(), Some("test-gid"));
assert_eq!(manager.available_permits(), 0);
assert!(manager
.capture_seed_remaining("torrent")
.await
.is_some());
}
#[tokio::test]
async fn pending_torrent_reconfiguration_restores_position_and_payload() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
for id in ["first", "target", "last"] {
manager
.push(QueuedTask {
id: id.to_string(),
queue_id: "queue".to_string(),
kind: TaskKind::Aria2,
lifecycle_generation: 0,
payload: SpawnPayload {
is_torrent: true,
..Default::default()
},
})
.await
.unwrap();
}
let (index, mut task) = manager
.take_pending_task("target")
.await
.expect("target remains pending");
assert_eq!(manager.pending_order(None).await, ["first", "last"]);
task.payload.torrent_file_indices = Some(vec![2]);
manager.restore_pending_task(index, task).await;
assert_eq!(
manager.pending_order(None).await,
["first", "target", "last"]
);
let (_, restored) = manager
.take_pending_task("target")
.await
.expect("target was restored");
assert_eq!(restored.payload.torrent_file_indices, Some(vec![2]));
}
#[tokio::test]
async fn enabling_separate_seed_capacity_counts_existing_seeders() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(SeedSpawner));
assert!(manager.ensure_aria2_permit("existing").await);
manager
.aria2_payloads
.lock()
.await
.insert(
"existing".to_string(),
SpawnPayload {
is_torrent: true,
torrent_seed_time: Some(5.0),
..Default::default()
},
);
manager
.remember_gid("existing".to_string(), "gid-existing".to_string())
.await;
manager
.apply_completion("existing", PendingOutcome::Seeding)
.await;
assert!(!manager.is_seed_owner("existing"));
manager.configure_seed_capacity(true, 1);
assert!(manager.is_seed_owner("existing"));
assert!(manager.has_active_permit("existing").await);
}
#[tokio::test]
async fn separate_seed_capacity_pauses_fairly_and_rebinds_before_resume() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
1,
Arc::new(SeedSpawner),
));
manager.configure_seed_capacity(true, 1);
for id in ["first", "second"] {
manager.reserve_enqueue_generation(id, 0).await.unwrap();
assert!(manager.ensure_aria2_permit(id).await);
manager
.aria2_payloads
.lock()
.await
.insert(
id.to_string(),
SpawnPayload {
is_torrent: true,
torrent_seed_time: Some(5.0),
..Default::default()
},
);
manager
.remember_gid(id.to_string(), format!("gid-{id}"))
.await;
if id == "first" {
manager
.apply_completion(id, PendingOutcome::Seeding)
.await;
assert!(manager.is_seed_owner(id));
assert!(!manager.has_active_permit(id).await);
} else {
manager
.apply_completion(id, PendingOutcome::Seeding)
.await;
assert!(manager.is_waiting_to_seed(id));
assert!(!manager.has_active_permit(id).await);
}
}
manager.release_registered_id("first").await;
assert!(manager.try_start_waiting_seed().await);
let result = tokio::time::timeout(Duration::from_secs(1), async {
loop {
if manager.is_seed_owner("second") && !manager.is_waiting_to_seed("second") {
break;
}
tokio::task::yield_now().await;
}
})
.await;
assert!(result.is_ok(), "waiting seed did not resume after capacity was released");
assert!(!manager.has_active_permit("second").await);
assert_eq!(manager.current_aria2_control_epoch("second").await, 1);
}
#[tokio::test]
async fn unexpected_seeding_outcome_without_policy_is_reconciled_as_complete() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(
app.handle().clone(),
1,
Arc::new(TestSpawner),
);
assert!(manager.ensure_aria2_permit("torrent").await);
manager
.aria2_payloads
.lock()
.await
.insert(
"torrent".to_string(),
SpawnPayload {
is_torrent: true,
..Default::default()
},
);
manager
.apply_completion("torrent", PendingOutcome::Seeding)
.await;
assert_eq!(manager.aria2_gid_for_download("torrent"), None);
assert_eq!(manager.available_permits(), 1);
}
#[test]
fn aria2_connection_options_never_emit_zero_connections() {
let mut options = serde_json::Map::new();
apply_aria2_connection_options(&mut options, 0);
assert_eq!(options.get("split"), Some(&serde_json::json!("1")));
assert_eq!(
options.get("max-connection-per-server"),
Some(&serde_json::json!("1"))
);
}
#[test]
fn aria2_recovery_rebuilds_when_force_pause_races_with_removal() {
assert!(aria2_recovery_should_rebuild_after_pause_error("removed"));
assert!(!aria2_recovery_should_rebuild_after_pause_error("paused"));
assert!(!aria2_recovery_should_rebuild_after_pause_error("active"));
}
#[test]
fn aria2_connection_options_clamp_untrusted_connection_counts() {
let mut options = serde_json::Map::new();
apply_aria2_connection_options(&mut options, i32::MAX);
assert_eq!(options.get("split"), Some(&serde_json::json!("16")));
assert_eq!(
options.get("max-connection-per-server"),
Some(&serde_json::json!("16"))
);
}
#[test]
fn bounded_range_probe_accepts_exact_requested_byte() {
assert_eq!(
classify_bounded_range_response(
reqwest::StatusCode::PARTIAL_CONTENT,
Some("bytes 0-0/383882118"),
),
BoundedRangeSupport::Supported
);
}
#[test]
fn bounded_range_probe_accepts_case_insensitive_content_range_unit() {
assert_eq!(
classify_bounded_range_response(
reqwest::StatusCode::PARTIAL_CONTENT,
Some("Bytes 0-0/383882118"),
),
BoundedRangeSupport::Supported
);
}
#[test]
fn aria2_proxy_value_rejects_socks_proxies() {
assert_eq!(aria2_all_proxy_value("none").unwrap().as_deref(), Some(""));
assert_eq!(
aria2_all_proxy_value("http://127.0.0.1:8080")
.unwrap()
.as_deref(),
Some("http://127.0.0.1:8080")
);
assert!(aria2_all_proxy_value("socks5://127.0.0.1:1080")
.unwrap_err()
.contains("SOCKS system proxies are not supported"));
}
#[test]
fn bounded_range_probe_rejects_server_that_expands_to_end() {
assert_eq!(
classify_bounded_range_response(
reqwest::StatusCode::PARTIAL_CONTENT,
Some("bytes 0-383882117/383882118"),
),
BoundedRangeSupport::Unsupported
);
}
#[test]
fn bounded_range_probe_rejects_ignored_range_request() {
assert_eq!(
classify_bounded_range_response(reqwest::StatusCode::OK, None),
BoundedRangeSupport::Unsupported
);
}
#[test]
fn aria2_range_code_is_retryable_without_global_no_uri_retry() {
assert!(is_retryable_aria2_error(
"aria2 error code 8: No URI available."
));
assert!(!is_retryable_aria2_error("No URI available."));
}
#[test]
fn aria2_stall_timeout_outcome_is_not_automatically_retried() {
assert!(!is_retryable_aria2_error("aria2 error code 7: unfinished download"));
}
#[test]
fn optional_not_found_and_low_speed_retries_use_the_firelink_budget() {
let not_found = "aria2 error code 3: Resource not found";
let low_speed = "aria2 error code 5: Download speed is too slow";
assert_eq!(
aria2_retry_action(&SpawnPayload::default(), not_found, 0, false),
Aria2RetryAction::Terminal
);
assert_eq!(
aria2_retry_action(
&SpawnPayload {
retry_not_found_errors: true,
max_tries: Some(1),
..SpawnPayload::default()
},
not_found,
0,
false,
),
Aria2RetryAction::OrdinaryRetry
);
assert_eq!(
aria2_retry_action(
&SpawnPayload {
retry_not_found_errors: true,
max_tries: Some(1),
..SpawnPayload::default()
},
not_found,
1,
false,
),
Aria2RetryAction::Terminal
);
assert_eq!(
aria2_retry_action(&SpawnPayload::default(), low_speed, 0, false),
Aria2RetryAction::Terminal
);
assert_eq!(
aria2_retry_action(
&SpawnPayload {
minimum_normal_download_speed_kib: 1,
max_tries: Some(1),
..SpawnPayload::default()
},
low_speed,
0,
false,
),
Aria2RetryAction::OrdinaryRetry
);
assert_eq!(
aria2_retry_action(
&SpawnPayload {
is_torrent: true,
minimum_normal_download_speed_kib: 1,
retry_not_found_errors: true,
max_tries: Some(1),
..SpawnPayload::default()
},
low_speed,
0,
false,
),
Aria2RetryAction::Terminal
);
}
#[test]
fn aria2_name_resolution_error_is_retryable_for_resolver_recovery() {
let error = "aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers.";
assert!(is_retryable_aria2_error(error));
assert!(should_use_aria2_system_resolver_fallback(
&SpawnPayload::default(),
error,
true,
));
assert_eq!(
aria2_retry_action(&SpawnPayload::default(), error, 0, true),
Aria2RetryAction::SystemResolverFallback
);
assert!(!should_use_aria2_system_resolver_fallback(
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
..Default::default()
},
error,
true,
));
assert!(!should_use_aria2_system_resolver_fallback(
&SpawnPayload::default(),
error,
false,
));
assert_eq!(
aria2_retry_action(
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
max_tries: Some(0),
..Default::default()
},
error,
0,
true,
),
Aria2RetryAction::Terminal
);
assert_eq!(
aria2_retry_action(
&SpawnPayload {
aria2_resolver_mode: Aria2ResolverMode::System,
max_tries: Some(1),
..Default::default()
},
error,
0,
true,
),
Aria2RetryAction::OrdinaryRetry
);
}
#[test]
fn aria2_startup_rpc_errors_are_retryable() {
assert!(is_aria2_rpc_unavailable(
"error trying to connect: tcp connect error: Connection refused"
));
assert!(is_aria2_rpc_unavailable(
"aria2 did not become ready: connection refused"
));
assert!(!is_aria2_rpc_unavailable(
"aria2 error code 3: Resource not found"
));
}
#[test]
fn aria2_internal_attempts_do_not_multiply_firelink_retry_budget() {
assert_eq!(aria2_attempt_limit(Some(0)), 1);
assert_eq!(aria2_attempt_limit(Some(10)), 1);
}
#[test]
fn omitted_retry_limit_uses_the_shared_default_but_zero_stays_explicit() {
assert_eq!(automatic_retry_limit(None), MAX_RETRIES);
assert_eq!(automatic_retry_limit(Some(0)), 0);
assert_eq!(automatic_retry_limit(Some(2)), 2);
}
#[test]
fn torrent_availability_aggregates_local_and_peer_copies_without_exposing_bitfields() {
let snapshot = parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f0" }),
serde_json::json!([
{ "bitfield": "30", "ip": "192.0.2.1" }
]),
)
.expect("availability should parse");
assert_eq!(snapshot.piece_count, 4);
assert_eq!(snapshot.connected_peers, 1);
assert!((snapshot.availability - 1.5).abs() < f64::EPSILON);
assert_eq!(snapshot.buckets.len(), 4);
assert_eq!(snapshot.buckets[0].minimum_copies, 1);
}
#[test]
fn torrent_availability_rejects_malformed_and_overflow_bitfields() {
assert!(parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f1" }),
serde_json::json!([]),
)
.is_err());
let snapshot = parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f0" }),
serde_json::json!([{ "bitfield": "0" }]),
)
.expect("malformed peer data should be omitted");
assert_eq!(snapshot.connected_peers, 1);
assert_eq!(snapshot.availability, 1.0);
}
#[test]
fn torrent_availability_ignores_peers_before_their_bitfield_handshake() {
let snapshot = parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f0" }),
serde_json::json!([
{ "ip": "192.0.2.1" },
{ "bitfield": "30" }
]),
)
.expect("a peer without a handshake bitfield is not malformed");
assert_eq!(snapshot.connected_peers, 2);
assert!((snapshot.availability - 1.5).abs() < f64::EPSILON);
}
#[test]
fn torrent_telemetry_counts_only_monotonic_upload_deltas() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 7, start);
assert_eq!(
state.observe("gid-1", 7, Some(100), false, start),
TorrentTelemetrySnapshot {
uploaded_bytes: 0,
seeded_seconds: 0
}
);
assert_eq!(
state.observe(
"gid-1",
7,
Some(180),
false,
start + Duration::from_secs(1)
)
.uploaded_bytes,
80
);
// A daemon counter reset establishes a new baseline and contributes
// no bytes from the reset itself.
assert_eq!(
state.observe(
"gid-1",
7,
Some(12),
false,
start + Duration::from_secs(2)
)
.uploaded_bytes,
80
);
assert_eq!(
state.observe(
"gid-1",
7,
Some(20),
false,
start + Duration::from_secs(3)
)
.uploaded_bytes,
88
);
}
#[test]
fn torrent_telemetry_restarts_baseline_on_gid_or_epoch_replacement() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(500), false, start);
state.observe("gid-1", 1, Some(525), false, start + Duration::from_secs(1));
let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(2));
assert_eq!(snapshot.uploaded_bytes, 25);
assert_eq!(
state.observe("gid-2", 2, Some(9), false, start + Duration::from_secs(3))
.uploaded_bytes,
30
);
}
#[test]
fn torrent_telemetry_closes_the_previous_seed_interval_on_lifecycle_replacement() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(0), true, start);
let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(3));
assert_eq!(snapshot.seeded_seconds, 3);
assert_eq!(snapshot.uploaded_bytes, 0);
}
#[test]
fn torrent_telemetry_counts_seed_seconds_only_for_the_previous_seed_interval() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(0), true, start);
assert_eq!(
state.observe(
"gid-1",
1,
Some(10),
true,
start + Duration::from_secs(4)
)
.seeded_seconds,
4
);
assert_eq!(
state.observe(
"gid-1",
1,
Some(10),
false,
start + Duration::from_secs(9)
)
.seeded_seconds,
9
);
}
#[test]
fn torrent_telemetry_caps_long_observer_gaps() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(0), true, start);
let snapshot = state.observe(
"gid-1",
1,
Some(1),
true,
start + Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS + 3600),
);
assert_eq!(
snapshot.seeded_seconds,
MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS
);
}
}