feat(downloads): add adaptive mirror reliability

This commit is contained in:
NimBold
2026-08-09 04:06:05 +03:30
parent c3755ce886
commit 3b7c454ec7
23 changed files with 1381 additions and 76 deletions
+10
View File
@@ -54,6 +54,10 @@ fn default_aria2_disk_cache() -> String {
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
}
fn default_adaptive_mirror_selection() -> bool {
true
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -686,6 +690,12 @@ pub struct PersistedSettings {
pub last_custom_speed_limit_unit: String,
pub per_server_connections: i32,
pub max_automatic_retries: i32,
#[serde(default)]
pub minimum_normal_download_speed_ki_b: u32,
#[serde(default)]
pub retry_not_found_errors: bool,
#[serde(default = "default_adaptive_mirror_selection")]
pub adaptive_mirror_selection: bool,
pub show_notifications: bool,
pub play_completion_sound: bool,
#[serde(default)]
+137 -2
View File
@@ -3073,7 +3073,7 @@ fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBu
}
}
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex, RwLock};
struct Aria2DaemonGuard {
@@ -3081,6 +3081,7 @@ struct Aria2DaemonGuard {
startup_error: Mutex<Option<String>>,
last_stderr: Mutex<String>,
config_path: Mutex<Option<tempfile::TempPath>>,
shutdown_state: AtomicU8,
}
impl Aria2DaemonGuard {
@@ -3090,8 +3091,63 @@ impl Aria2DaemonGuard {
startup_error: Mutex::new(None),
last_stderr: Mutex::new(String::new()),
config_path: Mutex::new(None),
shutdown_state: AtomicU8::new(0),
}
}
fn exit_allowed(&self) -> bool {
self.shutdown_state.load(Ordering::SeqCst) == 2
}
fn begin_shutdown(&self) -> bool {
self.shutdown_state
.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
fn allow_exit(&self) {
self.shutdown_state.store(2, Ordering::SeqCst);
}
}
async fn shutdown_aria2_daemon(app_handle: tauri::AppHandle) {
let guard = app_handle.state::<Aria2DaemonGuard>();
if let Some(state) = app_handle.try_state::<AppState>() {
let port = state.aria2_port.load(Ordering::Relaxed);
if port != 0 {
let shutdown = tokio::time::timeout(
std::time::Duration::from_secs(2),
rpc_call(port, &state.aria2_secret, "aria2.shutdown", serde_json::json!([])),
)
.await;
match shutdown {
Ok(Ok(_)) => log::info!("aria2 graceful shutdown requested"),
Ok(Err(error)) => log::warn!("aria2 graceful shutdown failed: {error}"),
Err(_) => log::warn!("aria2 graceful shutdown timed out"),
}
}
}
let child = guard.child.lock().ok().and_then(|mut child| child.take());
if let Some(mut child) = child {
let _ = tokio::task::spawn_blocking(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
_ => {
let _ = child.kill();
let _ = child.wait();
return;
}
}
}
})
.await;
}
}
impl Drop for Aria2DaemonGuard {
@@ -8762,6 +8818,9 @@ async fn verify_torrent_data(
mirrors: None,
user_agent: None,
max_tries: Some(0),
minimum_normal_download_speed_kib: None,
retry_not_found_errors: None,
adaptive_mirror_selection: None,
proxy: None,
format_selector: None,
cookie_source: None,
@@ -9240,6 +9299,19 @@ fn apply_aria2_torrent_dht_options(
command.arg(format!("--dht-message-timeout={timeout}"));
}
fn apply_aria2_server_stat_options(
command: &mut std::process::Command,
path: Option<&std::path::Path>,
) {
let Some(path) = path else {
return;
};
command
.arg(format!("--server-stat-if={}", path.display()))
.arg(format!("--server-stat-of={}", path.display()))
.arg("--server-stat-timeout=86400");
}
fn apply_aria2_torrent_peer_identity_options(
command: &mut std::process::Command,
peer_id_prefix: &str,
@@ -10741,6 +10813,7 @@ mod tests {
apply_aria2_torrent_peer_discovery_options,
apply_aria2_torrent_dht_paths,
apply_aria2_torrent_dht_options,
apply_aria2_server_stat_options,
aria2_rpc_port_is_occupied,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
collect_opened_torrent_paths,
@@ -10762,6 +10835,7 @@ mod tests {
retained_torrent_id_from_persisted_record,
retained_torrent_info_hash_from_persisted_record,
merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair,
Aria2DaemonGuard,
};
#[cfg(target_os = "macos")]
use super::should_apply_dock_badge_update;
@@ -11013,6 +11087,42 @@ mod tests {
);
}
#[test]
fn aria2_adaptive_mirror_history_is_private_and_launch_scoped() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("server-stat.txt");
let mut command = std::process::Command::new("aria2c");
apply_aria2_server_stat_options(&mut command, Some(&path));
assert_eq!(
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec![
format!("--server-stat-if={}", path.display()),
format!("--server-stat-of={}", path.display()),
"--server-stat-timeout=86400".to_string(),
]
);
let mut disabled = std::process::Command::new("aria2c");
apply_aria2_server_stat_options(&mut disabled, None);
assert_eq!(disabled.get_args().count(), 0);
}
#[test]
fn aria2_shutdown_blocks_repeated_exit_requests_until_cleanup_finishes() {
let guard = Aria2DaemonGuard::new();
assert!(!guard.exit_allowed());
assert!(guard.begin_shutdown());
assert!(!guard.begin_shutdown());
assert!(!guard.exit_allowed());
guard.allow_exit();
assert!(guard.exit_allowed());
assert!(!guard.begin_shutdown());
}
#[test]
fn aria2_torrent_global_options_are_bounded_and_explicit() {
let mut command = std::process::Command::new("aria2c");
@@ -13765,6 +13875,15 @@ pub fn run() {
// conflict must fail startup; silently allowing Aria2 to fall back
// to a user-global dht.dat would escape the storage boundary.
let aria2_dht_paths = storage_layout.prepare_aria2_dht_paths()?;
let aria2_server_stat_path = match storage_layout.prepare_aria2_server_stat_path() {
Ok(path) => Some(path),
Err(error) => {
log::warn!(
"adaptive mirror history is disabled for this session: {error}"
);
None
}
};
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
log::warn!("could not remove orphaned torrent probes: {error}");
}
@@ -14045,6 +14164,10 @@ pub fn run() {
&mut cmd,
torrent_startup_settings.dht_message_timeout,
);
apply_aria2_server_stat_options(
&mut cmd,
aria2_server_stat_path.as_deref(),
);
apply_aria2_torrent_peer_discovery_options(
&mut cmd,
@@ -15008,9 +15131,21 @@ pub fn run() {
restore_main_window(app_handle);
}
}
tauri::RunEvent::ExitRequested { .. } => {
tauri::RunEvent::ExitRequested { code, api, .. } => {
let state = app_handle.state::<AppState>();
let _ = state.extension_server_shutdown.send(true);
let guard = app_handle.state::<Aria2DaemonGuard>();
if !guard.exit_allowed() {
api.prevent_exit();
if guard.begin_shutdown() {
let app = app_handle.clone();
tauri::async_runtime::spawn(async move {
shutdown_aria2_daemon(app.clone()).await;
app.state::<Aria2DaemonGuard>().allow_exit();
app.exit(code.unwrap_or(0));
});
}
}
}
_ => {}
});
+339 -71
View File
@@ -50,6 +50,16 @@ 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 {
@@ -761,6 +771,9 @@ pub struct SpawnPayload {
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.
@@ -4932,6 +4945,27 @@ 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,
@@ -4958,7 +4992,9 @@ fn aria2_retry_action(
if should_use_aria2_system_resolver_fallback(payload, error, async_dns_supported) {
return Aria2RetryAction::SystemResolverFallback;
}
if is_retryable_aria2_error(error) && strike < automatic_retry_limit(payload.max_tries) {
if is_retryable_aria2_error_for_payload(payload, error)
&& strike < automatic_retry_limit(payload.max_tries)
{
Aria2RetryAction::OrdinaryRetry
} else {
Aria2RetryAction::Terminal
@@ -5003,53 +5039,111 @@ enum BoundedRangeSupport {
Unknown,
}
async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> Result<i32, String> {
let requested = clamp_download_connections(
payload
.connections
.unwrap_or(DOWNLOAD_CONNECTIONS_MIN),
);
if requested <= 1 {
return Ok(requested);
}
struct HttpTransferProbe {
final_uri: String,
range_support: BoundedRangeSupport,
credentials_allowed: bool,
}
for uri in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) {
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).await {
Ok(BoundedRangeSupport::Unsupported) => {
log::warn!(
"aria2 range probe [{}]: {} does not honor bounded byte ranges; using one connection",
id,
uri_host_for_log(&uri)
);
return Ok(1);
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
);
}
_ => {}
}
}
Ok(BoundedRangeSupport::Supported) => {}
Ok(BoundedRangeSupport::Unknown) => {
log::debug!(
"aria2 range probe [{}]: {} range support unknown; keeping {} connections",
id,
uri_host_for_log(&uri),
requested
);
}
Err(error) if error.starts_with("SSRF blocked:") => return Err(error),
Err(error) => {
log::debug!(
"aria2 range probe [{}]: {} probe failed: {}; keeping {} connections",
id,
return Err(format!(
"normal transfer preflight failed for {}: {}",
uri_host_for_log(&uri),
error,
requested
);
crate::redact_sensitive_text(&error)
));
}
}
}
Ok(requested)
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 {
@@ -5090,12 +5184,12 @@ pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, Strin
async fn probe_bounded_range_support(
uri: &str,
payload: &SpawnPayload,
) -> Result<BoundedRangeSupport, String> {
credential_origin: &reqwest::Url,
) -> Result<HttpTransferProbe, String> {
crate::ensure_reqwest_crypto_provider();
let original = reqwest::Url::parse(uri).map_err(|error| error.to_string())?;
let mut current = original.clone();
let mut credentials_allowed = true;
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()
@@ -5112,7 +5206,8 @@ async fn probe_bounded_range_support(
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())?);
builder =
builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?);
}
}
@@ -5121,9 +5216,7 @@ async fn probe_bounded_range_support(
.get(current.clone())
.header(reqwest::header::RANGE, "bytes=0-0")
.header(reqwest::header::ACCEPT_ENCODING, "identity");
let include_credentials = credentials_allowed
&& can_forward_payload_credentials(&original, &current);
let response = apply_payload_headers(request, payload, include_credentials)
let response = apply_payload_headers(request, payload, credentials_allowed)
.send()
.await
.map_err(|error| error.to_string())?;
@@ -5143,8 +5236,8 @@ async fn probe_bounded_range_support(
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(&original, &next);
credentials_allowed =
credentials_allowed && can_forward_payload_credentials(credential_origin, &next);
current = next;
continue;
}
@@ -5153,19 +5246,17 @@ async fn probe_bounded_range_support(
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok());
return Ok(classify_bounded_range_response(
response.status(),
content_range,
));
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 {
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"
@@ -5370,6 +5461,31 @@ fn apply_aria2_connection_options(
);
}
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,
@@ -6378,18 +6494,22 @@ impl SidecarSpawner for ProductionSpawner {
if !payload.is_torrent {
options.insert("out".to_string(), serde_json::json!(safe_filename));
}
let transfer_uris = if payload.is_torrent {
Vec::new()
let (transfer_uris, transfer_connections, credentials_allowed) = if payload.is_torrent {
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, true)
} else {
crate::collect_download_uris(&payload.url, payload.mirrors.as_deref())
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 !payload.is_torrent {
validate_aria2_transfer_network_policy(&transfer_uris).await?;
}
if should_apply_aria2_connection_options(payload) {
let conn = effective_aria2_connections(id, payload).await?;
apply_aria2_connection_options(&mut options, conn);
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);
@@ -6408,20 +6528,24 @@ impl SidecarSpawner for ProductionSpawner {
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
}
if !payload.is_torrent {
apply_protocol_auth_options(&mut options, payload, &transfer_uris);
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 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 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());
}
}
}
}
@@ -6941,6 +7065,15 @@ pub struct EnqueueItem {
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>,
@@ -7055,6 +7188,11 @@ impl EnqueueItem {
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,
@@ -7168,6 +7306,47 @@ mod tests {
);
}
#[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();
@@ -8404,6 +8583,26 @@ mod tests {
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!({
@@ -8821,6 +9020,75 @@ mod tests {
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.";
+21 -1
View File
@@ -581,6 +581,11 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
settings.minimum_normal_download_speed_ki_b =
crate::queue::normalize_minimum_normal_download_speed_kib(
settings.minimum_normal_download_speed_ki_b,
)
.unwrap_or_default();
settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2(
&settings.torrent_overall_upload_limit,
)
@@ -851,6 +856,9 @@ fn default_settings() -> PersistedSettings {
last_custom_speed_limit_unit: "MB/s".to_string(),
per_server_connections: 16,
max_automatic_retries: 3,
minimum_normal_download_speed_ki_b: 0,
retry_not_found_errors: false,
adaptive_mirror_selection: true,
show_notifications: true,
play_completion_sound: false,
auto_add_clipboard_links: false,
@@ -1167,7 +1175,8 @@ mod tests {
"state": {
"maxConcurrentDownloads": 99,
"perServerConnections": -4,
"maxAutomaticRetries": 99
"maxAutomaticRetries": 99,
"minimumNormalDownloadSpeedKiB": 2000000
},
"version": 3
});
@@ -1177,6 +1186,17 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 12);
assert_eq!(settings.per_server_connections, 1);
assert_eq!(settings.max_automatic_retries, 10);
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
}
#[test]
fn normal_reliability_defaults_are_migration_safe() {
let stored = json!({ "state": {}, "version": 5 });
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
assert!(!settings.retry_not_found_errors);
assert!(settings.adaptive_mirror_selection);
}
#[test]
+135
View File
@@ -8,6 +8,8 @@ const PORTABLE_WEBVIEW_DIR: &str = "webview";
const ARIA2_DATA_DIR: &str = "aria2";
const ARIA2_DHT_FILE: &str = "dht.dat";
const ARIA2_DHT6_FILE: &str = "dht6.dat";
const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt";
const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageMode {
@@ -116,6 +118,12 @@ impl StorageLayout {
)
}
pub fn aria2_server_stat_path(&self) -> PathBuf {
self.data_dir
.join(ARIA2_DATA_DIR)
.join(ARIA2_SERVER_STAT_FILE)
}
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
/// the table contents; Firelink owns this exact location and must never
/// fall back to a user-global default when it cannot establish it.
@@ -160,6 +168,86 @@ impl StorageLayout {
Ok(self.aria2_dht_paths())
}
/// Prepare the exact cache file used by Aria2's adaptive URI selector.
/// The cache is non-authoritative: malformed or oversized contents are
/// reset to empty, while symlinks and non-files disable the cache instead
/// of allowing Aria2 to write outside Firelink's storage boundary.
pub fn prepare_aria2_server_stat_path(&self) -> Result<PathBuf, String> {
let directory = self.data_dir.join(ARIA2_DATA_DIR);
if crate::path_has_symlink_component(&directory) {
return Err("Aria2 server-stat directory contains a symlink".to_string());
}
std::fs::create_dir_all(&directory)
.map_err(|error| format!("failed to create Aria2 server-stat directory: {error}"))?;
let path = self.aria2_server_stat_path();
match std::fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err("Aria2 server-stat cache is a symlink".to_string());
}
Ok(metadata) if !metadata.is_file() => {
return Err("Aria2 server-stat cache is not a regular file".to_string());
}
Ok(metadata) => {
let valid = metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES
&& std::fs::read_to_string(&path)
.ok()
.is_some_and(|contents| aria2_server_stat_is_valid(&contents));
if !valid {
std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)
.map_err(|error| {
format!("failed to reset Aria2 server-stat cache: {error}")
})?;
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
.map_err(|error| {
format!("failed to create Aria2 server-stat cache: {error}")
})?;
}
Err(error) => {
return Err(format!(
"failed to inspect Aria2 server-stat cache: {error}"
));
}
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?;
}
Ok(path)
}
}
fn aria2_server_stat_is_valid(contents: &str) -> bool {
contents.lines().all(|line| {
let line = line.trim();
if line.is_empty() {
return true;
}
if line.chars().any(char::is_control) {
return false;
}
let fields = line
.split(',')
.filter_map(|field| field.trim().split_once('='))
.map(|(name, value)| (name.trim(), value.trim()))
.collect::<std::collections::HashMap<_, _>>();
["host", "protocol", "dl_speed", "last_updated", "status"]
.iter()
.all(|name| fields.get(name).is_some_and(|value| !value.is_empty()))
})
}
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
@@ -278,6 +366,53 @@ mod tests {
assert!(error.contains("not a directory"));
}
#[test]
fn aria2_server_stat_cache_is_private_and_recovers_from_malformed_data() {
let root = TempDir::new().unwrap();
let layout = test_layout(root.path());
layout.prepare_aria2_dht_paths().unwrap();
let path = layout.prepare_aria2_server_stat_path().unwrap();
assert_eq!(path, layout.aria2_server_stat_path());
assert_eq!(fs::read_to_string(&path).unwrap(), "");
fs::write(&path, "not an aria2 server profile\n").unwrap();
layout.prepare_aria2_server_stat_path().unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "");
let valid =
"host=mirror.example, protocol=https, dl_speed=1024, last_updated=1, status=OK\n";
fs::write(&path, valid).unwrap();
layout.prepare_aria2_server_stat_path().unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[cfg(unix)]
#[test]
fn aria2_server_stat_cache_rejects_symlink_output() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let layout = test_layout(root.path());
layout.prepare_aria2_dht_paths().unwrap();
symlink(
target.path().join("outside"),
layout.aria2_server_stat_path(),
)
.unwrap();
assert!(layout.prepare_aria2_server_stat_path().is_err());
}
#[cfg(unix)]
#[test]
fn aria2_dht_preparation_rejects_a_symlinked_directory() {