mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 03:59:09 +00:00
fix(aria2): harden per-transfer DNS fallback (issue #35)
This commit is contained in:
+44
-1
@@ -922,6 +922,7 @@ pub fn replace_downloads(
|
||||
let strings = values
|
||||
.into_iter()
|
||||
.map(|mut value| {
|
||||
remove_live_download_metadata(&mut value);
|
||||
if portable {
|
||||
remove_persisted_transfer_secrets(&mut value);
|
||||
}
|
||||
@@ -995,6 +996,7 @@ where
|
||||
if object.get("id").and_then(Value::as_str) != Some(id) {
|
||||
return Err("persisted download mutation cannot change its id".to_string());
|
||||
}
|
||||
remove_live_download_metadata(&mut value);
|
||||
if portable {
|
||||
remove_persisted_transfer_secrets(&mut value);
|
||||
}
|
||||
@@ -1023,7 +1025,17 @@ where
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn remove_live_download_metadata(value: &mut Value) {
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
// Error classifications and resolver phase are process-local
|
||||
// presentation metadata; never retain them in the persisted contract.
|
||||
object.remove("lastErrorKind");
|
||||
object.remove("lastResolverFallback");
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_persisted_transfer_secrets(value: &mut Value) {
|
||||
remove_live_download_metadata(value);
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -2469,7 +2481,8 @@ mod tests {
|
||||
"id": "torrent-1",
|
||||
"status": "paused",
|
||||
"url": "https://example.test/file",
|
||||
"password": "secret"
|
||||
"password": "secret",
|
||||
"lastErrorKind": "nameResolution"
|
||||
}])
|
||||
.to_string(),
|
||||
false,
|
||||
@@ -2488,6 +2501,36 @@ mod tests {
|
||||
assert!(saved.get("password").is_none());
|
||||
assert_eq!(saved["status"], "failed");
|
||||
assert_eq!(saved["resumable"], false);
|
||||
assert!(saved.get("lastErrorKind").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_download_mutation_drops_live_error_metadata_in_standard_mode() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let state = init_at_path(temp.path()).unwrap();
|
||||
let mut connection = state.lock().unwrap();
|
||||
replace_downloads(
|
||||
&mut connection,
|
||||
&json!([{
|
||||
"id": "download-live-metadata",
|
||||
"status": "paused"
|
||||
}])
|
||||
.to_string(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
mutate_download(&mut connection, "download-live-metadata", false, |object| {
|
||||
object.insert("status".to_string(), json!("queued"));
|
||||
object.insert("lastErrorKind".to_string(), json!("nameResolution"));
|
||||
object.insert("lastResolverFallback".to_string(), json!(true));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
assert!(saved.get("lastErrorKind").is_none());
|
||||
assert!(saved.get("lastResolverFallback").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+55
-3
@@ -140,6 +140,13 @@ pub struct QueueConcurrencyConfig {
|
||||
pub max_concurrent: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum DownloadErrorKind {
|
||||
NameResolution,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -198,6 +205,12 @@ pub struct DownloadItem {
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub last_error_kind: Option<DownloadErrorKind>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub last_resolver_fallback: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_try: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -757,6 +770,12 @@ pub struct DownloadStateEvent {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub error_kind: Option<DownloadErrorKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub resolver_fallback: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub file_name: Option<String>,
|
||||
#[ts(optional)]
|
||||
@@ -769,26 +788,34 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: status.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
let (error, error_kind) = Self::safe_error(error);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Failed.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
error: Some(error),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paused_with_error(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
let (error, error_kind) = Self::safe_error(error);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
error: Some(error),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
@@ -799,6 +826,8 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
}
|
||||
@@ -809,6 +838,8 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Completed.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: Some(file_name.into()),
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
@@ -817,10 +848,13 @@ impl DownloadStateEvent {
|
||||
/// Transient retry state. Carries the human-readable reason so the UI can
|
||||
/// surface "network dropped, retrying in 5s…". The slot is still held.
|
||||
pub fn retrying(id: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
let (reason, error_kind) = Self::safe_error(reason);
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Retrying.as_str().to_string(),
|
||||
error: Some(reason.into()),
|
||||
error: Some(reason),
|
||||
error_kind,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
@@ -831,8 +865,26 @@ impl DownloadStateEvent {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
|
||||
error: None,
|
||||
error_kind: None,
|
||||
resolver_fallback: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retrying_with_resolver_fallback(
|
||||
id: impl Into<String>,
|
||||
reason: impl Into<String>,
|
||||
) -> Self {
|
||||
let mut event = Self::retrying(id, reason);
|
||||
event.resolver_fallback = Some(true);
|
||||
event
|
||||
}
|
||||
|
||||
fn safe_error(error: impl Into<String>) -> (String, Option<DownloadErrorKind>) {
|
||||
let error = crate::redact_sensitive_text(&error.into());
|
||||
let error_kind = crate::retry::is_aria2_name_resolution_error(&error)
|
||||
.then_some(DownloadErrorKind::NameResolution);
|
||||
(error, error_kind)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -13539,6 +13539,7 @@ pub fn run() {
|
||||
});
|
||||
|
||||
let queue_manager_poll = Arc::clone(&queue_manager);
|
||||
let queue_manager_capability = Arc::clone(&queue_manager);
|
||||
|
||||
app.manage(AppState {
|
||||
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
|
||||
@@ -13798,7 +13799,23 @@ pub fn run() {
|
||||
match rpc_call(attempt_port, &aria2_secret_clone, "aria2.getVersion", serde_json::json!([])).await {
|
||||
Ok(ver) => {
|
||||
let v = ver.get("version").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
log::info!("aria2 daemon ready (version {}) on port {}", v, attempt_port);
|
||||
let async_dns_supported = ver
|
||||
.get("enabledFeatures")
|
||||
.and_then(|features| features.as_array())
|
||||
.map(|features| {
|
||||
features.iter().any(|feature| {
|
||||
feature.as_str() == Some("Async DNS")
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
queue_manager_capability
|
||||
.set_aria2_async_dns_supported(async_dns_supported);
|
||||
log::info!(
|
||||
"aria2 daemon ready (version {}) on port {} (async DNS: {})",
|
||||
v,
|
||||
attempt_port,
|
||||
async_dns_supported
|
||||
);
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
|
||||
+200
-14
@@ -1,14 +1,17 @@
|
||||
use base64::Engine as _;
|
||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||
use crate::power::PowerManager;
|
||||
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
|
||||
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::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Manager};
|
||||
@@ -705,6 +708,21 @@ enum SeedAdmissionOutcome {
|
||||
|
||||
/// 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,
|
||||
@@ -721,6 +739,9 @@ pub struct SpawnPayload {
|
||||
pub user_agent: Option<String>,
|
||||
pub max_tries: Option<i32>,
|
||||
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,
|
||||
@@ -907,6 +928,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
/// 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>>,
|
||||
|
||||
@@ -990,6 +1016,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
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()),
|
||||
@@ -1009,6 +1036,15 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
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
|
||||
@@ -3897,7 +3933,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
if verification_only {
|
||||
self.emit_paused_with_error(id, error);
|
||||
} else {
|
||||
log::error!("aria2 download {} failed: {}", id, error);
|
||||
let safe_error = crate::redact_sensitive_text(&error);
|
||||
log::error!("aria2 download {} failed: {}", id, safe_error);
|
||||
self.emit_failed(id, error);
|
||||
}
|
||||
}
|
||||
@@ -4468,9 +4505,18 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
*entry
|
||||
};
|
||||
|
||||
let transient = is_retryable_aria2_error(&error);
|
||||
let strikes_left = strike < automatic_retry_limit(payload.max_tries);
|
||||
if !(transient && strikes_left) {
|
||||
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;
|
||||
@@ -4510,6 +4556,14 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.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();
|
||||
@@ -4530,10 +4584,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
};
|
||||
let outcome = backoff_and_emit(strike, error_for_emit, retry_cancel, |reason| {
|
||||
use tauri::Emitter;
|
||||
let _ = this.app_handle.emit(
|
||||
"download-state",
|
||||
DownloadStateEvent::retrying(&id_for_task, reason),
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -4602,10 +4658,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
this.aria2_retry_strikes
|
||||
.lock()
|
||||
.await
|
||||
.insert(id_for_task.clone(), strike + 1);
|
||||
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
|
||||
@@ -4836,6 +4894,39 @@ fn is_retryable_aria2_error(error: &str) -> bool {
|
||||
is_transient_network_error(error) || is_aria2_range_mode_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(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)
|
||||
@@ -5120,6 +5211,15 @@ fn apply_aria2_connection_options(
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -6174,6 +6274,7 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
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() {
|
||||
@@ -6789,6 +6890,7 @@ impl EnqueueItem {
|
||||
user_agent: self.user_agent,
|
||||
max_tries: self.max_tries,
|
||||
proxy: self.proxy,
|
||||
aria2_resolver_mode: Aria2ResolverMode::Automatic,
|
||||
format_selector: self.format_selector,
|
||||
cookie_source: self.cookie_source,
|
||||
is_media: media,
|
||||
@@ -6900,6 +7002,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -8390,6 +8522,60 @@ mod tests {
|
||||
assert!(!is_retryable_aria2_error("aria2 error code 7: unfinished download"));
|
||||
}
|
||||
|
||||
#[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(
|
||||
|
||||
+35
-1
@@ -50,6 +50,18 @@ pub const BACKOFF_SCHEDULE_429: [Duration; 3] = [
|
||||
/// fall through to a hard `Failed`. Three strikes matches the schedule length.
|
||||
pub const MAX_RETRIES: usize = BACKOFF_SCHEDULE.len();
|
||||
|
||||
/// Detect Aria2's name-resolution failure without treating arbitrary DNS-like
|
||||
/// text as a resolver failure. The numeric code is the authoritative signal;
|
||||
/// the message forms cover older/alternate Aria2 wrappers that omit it.
|
||||
pub fn is_aria2_name_resolution_error(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("aria2 error code 19")
|
||||
|| (lower.contains("name resolution")
|
||||
&& lower.contains("failed")
|
||||
&& lower.contains("could not contact dns"))
|
||||
|| lower.contains("could not contact dns server")
|
||||
}
|
||||
|
||||
/// Resolve the backoff delay for a 0-based strike. Strikes at or beyond the
|
||||
/// schedule length clamp to the longest slot (10s) rather than panicking, so a
|
||||
/// mis-sized loop degrades gracefully instead of aborting the worker.
|
||||
@@ -122,9 +134,13 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_aria2_name_resolution_error(message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let m = message.to_ascii_lowercase();
|
||||
|
||||
const TRANSIENT: [&str; 34] = [
|
||||
const TRANSIENT: [&str; 36] = [
|
||||
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
|
||||
"timed out",
|
||||
"timeout",
|
||||
@@ -140,6 +156,8 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
"connection aborted",
|
||||
"error sending request", // reqwest wrapper for connect/send failures
|
||||
"dns error", // transient resolver failures
|
||||
"name resolution", // aria2 name-resolution failures
|
||||
"could not contact dns", // aria2 c-ares resolver failures
|
||||
"protocol error", // aria2 read/protocol failures after a link drop
|
||||
"tls handshake failure",
|
||||
"ssl/tls handshake failure",
|
||||
@@ -326,6 +344,22 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_aria2_name_resolution_failures_precisely() {
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers."
|
||||
));
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"Name resolution for example.test failed: Could not contact DNS server"
|
||||
));
|
||||
assert!(is_aria2_name_resolution_error(
|
||||
"aria2 error code 19: connection refused"
|
||||
));
|
||||
assert!(!is_aria2_name_resolution_error(
|
||||
"aria2 error code 8: No URI available"
|
||||
));
|
||||
}
|
||||
|
||||
// --- transient classification: negative cases -------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user