From c1202229acd0756adadcaf798d2b6233e4269d66 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 5 Aug 2026 19:28:56 +0330 Subject: [PATCH] fix(aria2): harden per-transfer DNS fallback (issue #35) --- .github/workflows/ci.yml | 2 + package.json | 1 + scripts/smoke-aria2-resolver.js | 232 +++++++++++++++++++++++++ src-tauri/src/db.rs | 45 ++++- src-tauri/src/ipc.rs | 58 ++++++- src-tauri/src/lib.rs | 19 +- src-tauri/src/queue.rs | 214 +++++++++++++++++++++-- src-tauri/src/retry.rs | 36 +++- src-tauri/tests/queue_manager.rs | 112 +++++++++++- src/bindings/DownloadErrorKind.ts | 3 + src/bindings/DownloadItem.ts | 3 +- src/bindings/DownloadStateEvent.ts | 3 +- src/components/DownloadItem.tsx | 9 +- src/components/PropertiesWindowApp.tsx | 16 +- src/i18n/catalogs/en.ts | 4 + src/i18n/catalogs/fa.ts | 4 + src/i18n/catalogs/he.ts | 4 + src/i18n/catalogs/ru.ts | 4 + src/i18n/catalogs/uk.ts | 4 + src/i18n/catalogs/zh-CN.ts | 4 + src/propertiesBridge.test.ts | 23 +++ src/propertiesBridge.ts | 8 + src/store/downloadStore.test.ts | 38 ++++ src/store/downloadStore.ts | 8 +- src/utils/downloadErrors.ts | 23 +++ src/utils/downloads.test.ts | 10 ++ src/utils/downloads.ts | 4 + 27 files changed, 864 insertions(+), 27 deletions(-) create mode 100644 scripts/smoke-aria2-resolver.js create mode 100644 src/bindings/DownloadErrorKind.ts create mode 100644 src/utils/downloadErrors.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00e4aa2..f9dd784 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,5 @@ jobs: node scripts/verify-binaries.js --staged --target ${{ matrix.target }} - name: Run Torrent process smoke run: node scripts/smoke-torrent.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} --failure-paths + - name: Run Aria2 resolver smoke + run: node scripts/smoke-aria2-resolver.js --binary src-tauri/engine-dist/${{ matrix.target }}/aria2c-${{ matrix.target }}${{ runner.os == 'Windows' && '.exe' || '' }} diff --git a/package.json b/package.json index 0593c24..343455e 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "check:updates": "node scripts/check-updates.js", "smoke:torrent": "node scripts/smoke-torrent.js", "smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths", + "smoke:aria2:resolver": "node scripts/smoke-aria2-resolver.js", "test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture", "verify:macos-signing": "node scripts/verify-macos-signing.js", "preview": "vite preview", diff --git a/scripts/smoke-aria2-resolver.js b/scripts/smoke-aria2-resolver.js new file mode 100644 index 0000000..c9c0aeb --- /dev/null +++ b/scripts/smoke-aria2-resolver.js @@ -0,0 +1,232 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()]; +const platform = { + darwin: 'apple-darwin', + linux: 'unknown-linux-gnu', + win32: 'pc-windows-msvc', +}[process.platform]; +if (!arch || !platform) { + throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`); +} +const targetTriple = `${arch}-${platform}`; +const argumentIndex = process.argv.indexOf('--binary'); +const binaryPath = path.resolve( + argumentIndex >= 0 + ? process.argv[argumentIndex + 1] + : path.join( + repoRoot, + 'src-tauri', + 'binaries', + `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`, + ), +); + +if (!fs.existsSync(binaryPath)) { + throw new Error(`Aria2 binary does not exist: ${binaryPath}`); +} + +const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); + +async function availablePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, resolve); + }); + const address = server.address(); + const port = address && typeof address !== 'string' ? address.port : undefined; + await new Promise(resolve => server.close(resolve)); + if (!port) throw new Error('Could not reserve a local port'); + return port; +} + +async function rpc(port, secret, method, params = []) { + const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: crypto.randomUUID(), + method, + params: [`token:${secret}`, ...params], + }), + signal: AbortSignal.timeout(3000), + }); + const body = await response.json(); + if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`); + if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`); + return body.result; +} + +function childExited(child) { + return child.exitCode !== null || child.signalCode !== null; +} + +async function waitForChildExit(child, timeoutMs = 3000) { + if (childExited(child)) return true; + return new Promise(resolve => { + let settled = false; + let timer; + const finish = result => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.off('exit', onExit); + resolve(result); + }; + const onExit = () => finish(true); + timer = setTimeout(() => finish(false), timeoutMs); + child.once('exit', onExit); + if (childExited(child)) finish(true); + }); +} + +async function waitForRpc(port, secret) { + const deadline = Date.now() + 10000; + let lastError; + while (Date.now() < deadline) { + try { + return await rpc(port, secret, 'aria2.getVersion'); + } catch (error) { + lastError = error; + await wait(100); + } + } + throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`); +} + +function bencode(value) { + if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]); + if (typeof value === 'string') return bencode(Buffer.from(value)); + if (typeof value === 'number') return Buffer.from(`i${value}e`); + if (value && typeof value === 'object') { + const entries = Object.entries(value).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + return Buffer.concat([ + Buffer.from('d'), + ...entries.flatMap(([key, child]) => [bencode(key), bencode(child)]), + Buffer.from('e'), + ]); + } + throw new Error(`Unsupported bencode value: ${typeof value}`); +} + +async function stop(child, port, secret) { + if (!child || childExited(child)) return; + try { + await rpc(port, secret, 'aria2.shutdown'); + } catch { + // The process may already have exited. + } + let exited = await waitForChildExit(child); + if (!exited) { + if (process.platform === 'win32') { + try { + execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + timeout: 3000, + }); + } catch { + // The process may have exited between the timeout and taskkill. + } + } else { + child.kill('SIGTERM'); + } + exited = await waitForChildExit(child); + } + if (!exited && process.platform !== 'win32') { + child.kill('SIGKILL'); + exited = await waitForChildExit(child); + } + if (!exited) { + throw new Error(`Aria2 process ${child.pid} did not exit after forced cleanup`); + } +} + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-resolver-')); +const secret = `firelink-resolver-${crypto.randomUUID()}`; +const rpcPort = await availablePort(); +const contentServer = http.createServer((_request, response) => { + response.writeHead(200, { 'content-length': '1' }); + response.end('x'); +}); +await new Promise((resolve, reject) => { + contentServer.once('error', reject); + contentServer.listen({ host: '127.0.0.1', port: 0 }, resolve); +}); +const contentPort = contentServer.address().port; +const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs'); +const environment = fs.existsSync(libraryPath) + ? { + ...process.env, + OPENSSL_MODULES: libraryPath, + ...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}), + } + : process.env; +const child = spawn(binaryPath, [ + '--enable-rpc=true', + `--rpc-listen-port=${rpcPort}`, + '--rpc-listen-all=false', + `--rpc-secret=${secret}`, + `--dir=${tempRoot}`, + '--file-allocation=none', + '--enable-dht=false', + '--console-log-level=error', + '--quiet=true', +], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }); +let stderr = ''; +child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + +try { + const version = await waitForRpc(rpcPort, secret); + const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : []; + console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`); + + const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], { + 'async-dns': 'false', + out: 'resolver-normal.bin', + }]); + const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]); + if (uriOptions['async-dns'] !== 'false') { + throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`); + } + + const torrent = bencode({ + info: { + length: 1, + name: 'resolver-torrent.bin', + pieces: Buffer.alloc(20), + 'piece length': 16384, + }, + }).toString('base64'); + const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], { + 'async-dns': 'false', + dir: tempRoot, + }]); + const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]); + if (torrentOptions['async-dns'] !== 'false') { + throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`); + } + + console.log('[PASS] Aria2 retained system-resolver mode for normal and Torrent transfers'); +} catch (error) { + const detail = stderr.trim(); + throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`); +} finally { + try { + await stop(child, rpcPort, secret); + } finally { + await new Promise(resolve => contentServer.close(resolve)); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index dac5d13..fc55dbd 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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] diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 93614ef..b677d61 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -140,6 +140,13 @@ pub struct QueueConcurrencyConfig { pub max_concurrent: Option, } +#[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, #[ts(optional)] pub last_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub last_error_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub last_resolver_fallback: Option, #[ts(optional)] pub last_try: Option, #[serde(default)] @@ -757,6 +770,12 @@ pub struct DownloadStateEvent { pub id: String, pub status: String, pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub resolver_fallback: Option, #[ts(optional)] pub file_name: Option, #[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, error: impl Into) -> 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, error: impl Into) -> 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, reason: impl Into) -> 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, + reason: impl Into, + ) -> Self { + let mut event = Self::retrying(id, reason); + event.resolver_fallback = Some(true); + event + } + + fn safe_error(error: impl Into) -> (String, Option) { + 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) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80bcef4..8477ea5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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; } diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index eaf05dc..62db1b0 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -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, pub max_tries: Option, pub proxy: Option, + /// Runtime-only resolver selection. This is never part of an enqueue or + /// persisted download payload. + pub aria2_resolver_mode: Aria2ResolverMode, pub format_selector: Option, pub cookie_source: Option, pub is_media: bool, @@ -907,6 +928,11 @@ pub struct QueueManager { /// cap as a degraded connection pool. aria2_global_speed_limit: Arc>>, + /// 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>, @@ -990,6 +1016,7 @@ impl QueueManager { 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 QueueManager { 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 QueueManager { 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 QueueManager { *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 QueueManager { .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 QueueManager { }; 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 QueueManager { .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, + 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( diff --git a/src-tauri/src/retry.rs b/src-tauri/src/retry.rs index cc4ed8d..7b607f0 100644 --- a/src-tauri/src/retry.rs +++ b/src-tauri/src/retry.rs @@ -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] diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index f8cfef0..f8cef83 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -1,6 +1,6 @@ use firelink_lib::queue::{ - Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, - SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED, + Aria2RecreateOutcome, Aria2RefreshOutcome, Aria2ResolverMode, QueueManager, QueuedTask, + SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED, }; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -27,6 +27,15 @@ struct CountingSpawner { torrent_peer_options_release: tokio::sync::Notify, add_speed_limits: std::sync::Mutex>>, add_peer_options: std::sync::Mutex, Option)>>, + add_resolver_modes: std::sync::Mutex>, + add_transfer_context: std::sync::Mutex< + Vec<( + Aria2ResolverMode, + Option, + Option, + Option, + )>, + >, block_speed_limit: std::sync::atomic::AtomicBool, speed_limit_started: tokio::sync::Notify, speed_limit_release: tokio::sync::Notify, @@ -211,6 +220,8 @@ impl CountingSpawner { torrent_peer_options_release: tokio::sync::Notify::new(), add_speed_limits: std::sync::Mutex::new(Vec::new()), add_peer_options: std::sync::Mutex::new(Vec::new()), + add_resolver_modes: std::sync::Mutex::new(Vec::new()), + add_transfer_context: std::sync::Mutex::new(Vec::new()), block_speed_limit: std::sync::atomic::AtomicBool::new(false), speed_limit_started: tokio::sync::Notify::new(), speed_limit_release: tokio::sync::Notify::new(), @@ -294,6 +305,16 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner { payload.torrent_max_peers, payload.torrent_peer_speed_limit.clone(), )); + self.add_resolver_modes + .lock() + .unwrap() + .push(payload.aria2_resolver_mode); + self.add_transfer_context.lock().unwrap().push(( + payload.aria2_resolver_mode, + payload.headers.clone(), + payload.proxy.clone(), + payload.connections, + )); Ok(format!("gid-{call}")) } async fn remove_uri(&self, _gid: &str) -> Result<(), String> { @@ -2169,6 +2190,93 @@ async fn transient_aria2_error_reissues_after_backoff() { dispatcher.abort(); } +#[tokio::test] +async fn resolver_failure_uses_one_system_fallback_without_retry_budget() { + use firelink_lib::queue::PendingOutcome; + + let (mgr, spawner) = make_manager(1); + let manager = Arc::new(mgr); + manager.set_aria2_async_dns_supported(true); + let mut task = aria2_task("resolver-fallback"); + task.payload.max_tries = Some(0); + task.payload.headers = Some("X-Test: retained".to_string()); + task.payload.proxy = Some("http://127.0.0.1:8123".to_string()); + manager.push(task).await.unwrap(); + + let dispatcher = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { manager.run_dispatcher().await }) + }; + timeout(Duration::from_secs(1), async { + loop { + if spawner.add_uri_calls.load(Ordering::SeqCst) >= 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("initial transfer should be admitted"); + + let resolver_error = PendingOutcome::Error( + "aria2 error code 19: Name resolution failed: Could not contact DNS servers".to_string(), + ); + manager + .handle_aria2_event("gid-1", resolver_error.clone()) + .await; + manager.handle_aria2_event("gid-1", resolver_error).await; + + timeout(Duration::from_secs(4), async { + loop { + if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("resolver failure should re-add once with the system resolver"); + assert_eq!( + *spawner.add_resolver_modes.lock().unwrap(), + vec![Aria2ResolverMode::Automatic, Aria2ResolverMode::System] + ); + assert_eq!( + *spawner.add_transfer_context.lock().unwrap(), + vec![ + ( + Aria2ResolverMode::Automatic, + Some("X-Test: retained".to_string()), + Some("http://127.0.0.1:8123".to_string()), + None, + ), + ( + Aria2ResolverMode::System, + Some("X-Test: retained".to_string()), + Some("http://127.0.0.1:8123".to_string()), + None, + ), + ] + ); + assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2); + + // A second resolver failure is now on the system mode. With max_tries=0 + // it must terminate instead of switching back or consuming another add. + manager + .handle_aria2_event( + "gid-2", + PendingOutcome::Error( + "aria2 error code 19: Name resolution failed: Could not contact DNS servers" + .to_string(), + ), + ) + .await; + assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2); + assert!(manager.aria2_gid_for_download("resolver-fallback").is_none()); + assert_eq!(manager.available_permits(), 1); + + dispatcher.abort(); +} + #[tokio::test] async fn duplicate_transient_events_schedule_only_one_retry_worker() { use firelink_lib::queue::PendingOutcome; diff --git a/src/bindings/DownloadErrorKind.ts b/src/bindings/DownloadErrorKind.ts new file mode 100644 index 0000000..2ce70e3 --- /dev/null +++ b/src/bindings/DownloadErrorKind.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DownloadErrorKind = "nameResolution"; diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index b3a05a0..f1635e5 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DownloadCategory } from "./DownloadCategory"; +import type { DownloadErrorKind } from "./DownloadErrorKind"; import type { DownloadStatus } from "./DownloadStatus"; import type { TorrentWebSeed } from "./TorrentWebSeed"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array, torrentWebSeedsNative?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array, torrentWebSeedsNative?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; diff --git a/src/bindings/DownloadStateEvent.ts b/src/bindings/DownloadStateEvent.ts index 177ceaf..21acf19 100644 --- a/src/bindings/DownloadStateEvent.ts +++ b/src/bindings/DownloadStateEvent.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DownloadErrorKind } from "./DownloadErrorKind"; -export type DownloadStateEvent = { id: string, status: string, error: string | null, fileName?: string, torrentSeedRemaining?: number, }; +export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, torrentSeedRemaining?: number, }; diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 1c29092..9524638 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -215,6 +215,13 @@ export const DownloadItem = React.memo(({ return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value; })(); const downloadStatusLabel = t($ => $.downloads.status[download.status]); + const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution' + ? download.status === 'retrying' && download.lastResolverFallback === true + ? t($ => $.downloads.errors.nameResolutionRetrying) + : download.status === 'failed' + ? t($ => $.downloads.errors.nameResolutionFailed) + : downloadStatusLabel + : downloadStatusLabel; const downloadedSizeLabel = sizeDisplay.totalIsEstimate ? t($ => $.downloads.size.downloadedOfApproximate, { downloaded: sizeDisplay.downloaded ?? '', @@ -349,7 +356,7 @@ export const DownloadItem = React.memo(({ ) : download.status === 'processing' ? ( downloadStatusLabel ) : ( - downloadStatusLabel + visibleErrorStatusLabel )} diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index fc014ea..6427ad3 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -896,7 +896,21 @@ export const PropertiesWindowApp = () => { {t($ => $.properties.lastTry)}{snapshot.lastTry || '—'} {t($ => $.properties.queueId)}{queuePlacement} {t($ => $.properties.resumable)}{snapshot.resumable === false ? '—' : '✓'} - {snapshot.lastError && <>{t($ => $.properties.lastError)}{snapshot.lastError}} + {snapshot.lastError && <> + {t($ => $.properties.lastError)} +
+ {snapshot.lastErrorKind === 'nameResolution' && ( +

+ {snapshot.status === 'retrying' + ? snapshot.lastResolverFallback === true + ? t($ => $.downloads.errors.nameResolutionRetrying) + : t($ => $.downloads.status.retrying) + : t($ => $.downloads.errors.nameResolutionFailed)} +

+ )} + {snapshot.lastError} +
+ } {snapshot.isMedia === true &&
{t($ => $.addDownloads.format)}{snapshot.mediaFormatSelector || '—'} diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 624f67f..6925df1 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -99,6 +99,10 @@ const common = { retrying: 'Retrying', moving: 'Moving data', }, + errors: { + nameResolutionRetrying: 'Retrying with system network resolver', + nameResolutionFailed: 'Could not resolve the server name. Check your VPN or network DNS settings.', + }, values: { processing: 'Processing...', muxing: 'Muxing...', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index d80a0e9..2b1634b 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -99,6 +99,10 @@ const fa = { retrying: 'در حال تلاش مجدد', moving: 'در حال جابه‌جایی داده', }, + errors: { + nameResolutionRetrying: 'تلاش مجدد با DNS سیستم', + nameResolutionFailed: 'نام سرور پیدا نشد. VPN یا DNS شبکه را بررسی کنید.', + }, values: { processing: 'در حال پردازش…', muxing: 'در حال ترکیب…', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index f9b5fa8..d914ff2 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -99,6 +99,10 @@ const he = { retrying: 'ניסיון חוזר', moving: 'מעביר נתונים', }, + errors: { + nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת', + nameResolutionFailed: 'לא ניתן לפתור את שם השרת. בדקו את ה‑VPN או את ה‑DNS של הרשת.', + }, values: { processing: 'מעבד…', muxing: 'ממזג…', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 73fa116..23d7d5e 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -99,6 +99,10 @@ const ru = { retrying: 'Повторная попытка', moving: 'Перемещение данных', }, + errors: { + nameResolutionRetrying: 'Повторная попытка через системный DNS', + nameResolutionFailed: 'Не удалось разрешить имя сервера. Проверьте VPN или DNS сети.', + }, values: { processing: 'Обработка…', muxing: 'Мультиплексирование…', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index d778fe8..1f77f5b 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -99,6 +99,10 @@ const uk = { retrying: 'Повторна спроба', moving: 'Переміщення даних', }, + errors: { + nameResolutionRetrying: 'Повторна спроба через системний DNS', + nameResolutionFailed: 'Не вдалося визначити ім’я сервера. Перевірте VPN або DNS мережі.', + }, values: { processing: 'Обробка…', muxing: 'Об\'єднання потоків…', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index fef3b26..cb95cfe 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -99,6 +99,10 @@ const zhCN = { retrying: '重试中', moving: '正在移动数据', }, + errors: { + nameResolutionRetrying: '正在使用系统 DNS 重试', + nameResolutionFailed: '无法解析服务器名称。请检查 VPN 或网络 DNS。', + }, values: { processing: '处理中…', muxing: '混流中…', diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index 50cfd28..c8abada 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -67,6 +67,29 @@ describe('Properties window bridge', () => { }); }); + it('adds resolver error metadata without exposing the queue-internal mode', () => { + const snapshot = sanitizePropertiesSnapshot({ + id: 'dns-1', + fileName: 'example.bin', + url: 'https://example.test/file', + status: 'failed', + category: 'Other', + dateAdded: '', + lastResolverFallback: true, + lastError: 'aria2 error code 19: Name resolution for example.test failed: Could not contact DNS servers.', + } as DownloadItem, { + theme: 'dark', + fontFamily: 'system', + appFontSize: 'standard', + listRowDensity: 'standard', + locale: 'en', + }); + + expect(snapshot.lastErrorKind).toBe('nameResolution'); + expect(snapshot.lastResolverFallback).toBe(true); + expect(snapshot).not.toHaveProperty('aria2ResolverMode'); + }); + it('projects the latest live telemetry without exposing secrets', () => { const snapshot = sanitizePropertiesSnapshot({ id: 'torrent-1', diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 0056ec8..9e768c5 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -1,11 +1,13 @@ import { emitTo } from '@tauri-apps/api/event'; import type { UnlistenFn } from '@tauri-apps/api/event'; import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; +import type { DownloadErrorKind } from './bindings/DownloadErrorKind'; import type { DownloadStatus } from './bindings/DownloadStatus'; import type { DownloadItem } from './store/useDownloadStore'; import { canPauseDownload } from './utils/downloadActions'; import type { DocumentAppearance } from './utils/documentAppearance'; import { invokeCommand as invoke } from './ipc'; +import { classifyDownloadError } from './utils/downloadErrors'; export const PROPERTIES_WINDOW_READY = 'properties-window-ready' as const; export const PROPERTIES_WINDOW_SNAPSHOT = 'properties-window-snapshot' as const; @@ -49,6 +51,8 @@ const PROPERTIES_SNAPSHOT_KEYS = [ 'queuePosition', 'hasBeenDispatched', 'lastError', + 'lastErrorKind', + 'lastResolverFallback', 'lastTry', 'isTorrent', 'torrentFileIndices', @@ -145,6 +149,8 @@ export type PropertiesSnapshotContext = { export type PropertiesSnapshot = SafePropertiesFields & { appearance: DocumentAppearance; queueName?: string; + lastErrorKind?: DownloadErrorKind; + lastResolverFallback?: boolean; activeConnections?: number; requestedConnections?: number; connectedPeers?: number; @@ -298,9 +304,11 @@ const copyWithoutSecrets = ( )), ) as SafePropertiesFields; if (item.isTorrent === true) delete safeItem.connections; + const lastErrorKind = item.lastErrorKind ?? classifyDownloadError(item.lastError); return { ...safeItem, appearance, + ...(lastErrorKind ? { lastErrorKind } : {}), ...(context?.queueName ? { queueName: context.queueName } : {}), ...(live?.progress ? { fraction: live.progress.fraction, diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts index a0ef8c3..58db6ae 100644 --- a/src/store/downloadStore.test.ts +++ b/src/store/downloadStore.test.ts @@ -125,6 +125,44 @@ describe('useDownloadProgressStore', () => { release(); }); + it('projects resolver error metadata and clears it when the lifecycle resumes', async () => { + const handlers: Record void> = {}; + vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { + handlers[event] = handler as (event: any) => void; + return Promise.resolve(vi.fn()); + }); + useDownloadStore.setState({ + downloads: [{ + id: 'resolver-error', + url: 'https://example.test/file', + fileName: 'file.bin', + status: 'downloading', + category: 'Other', + dateAdded: '', + }], + }); + + const release = await initDownloadListener(); + handlers['download-state']({ payload: { + id: 'resolver-error', + status: 'retrying', + error: 'aria2 error code 19: Name resolution failed', + errorKind: 'nameResolution', + resolverFallback: true, + } }); + expect(useDownloadStore.getState().downloads[0].lastErrorKind).toBe('nameResolution'); + expect(useDownloadStore.getState().downloads[0].lastResolverFallback).toBe(true); + + handlers['download-state']({ payload: { + id: 'resolver-error', + status: 'downloading', + error: null, + } }); + expect(useDownloadStore.getState().downloads[0].lastErrorKind).toBeUndefined(); + expect(useDownloadStore.getState().downloads[0].lastResolverFallback).toBeUndefined(); + release(); + }); + it('projects torrent seeding state and upload telemetry', async () => { const handlers: Record void> = {}; vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 4c37d25..5664a0f 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -200,7 +200,11 @@ const startDownloadListeners = async () => { ? { totalIsEstimate: progress.total_is_estimate } : {}) } : {}), - ...(payload.error ? { lastError: payload.error } : {}), + ...(payload.error ? { + lastError: payload.error, + lastErrorKind: payload.errorKind, + lastResolverFallback: payload.resolverFallback, + } : {}), ...((status === 'downloading' || status === 'verifying' || status === 'retrying') ? { lastTry: new Date().toISOString() } : {}) @@ -212,6 +216,8 @@ const startDownloadListeners = async () => { } if (!payload.error && status !== 'failed' && status !== 'retrying') { updates.lastError = undefined; + updates.lastErrorKind = undefined; + updates.lastResolverFallback = undefined; } if (payload.fileName && payload.fileName !== current.fileName) { updates.fileName = payload.fileName; diff --git a/src/utils/downloadErrors.ts b/src/utils/downloadErrors.ts new file mode 100644 index 0000000..a7a66fd --- /dev/null +++ b/src/utils/downloadErrors.ts @@ -0,0 +1,23 @@ +import type { DownloadErrorKind } from '../bindings/DownloadErrorKind'; + +/** + * Keep the renderer's presentation classification aligned with the native + * Aria2 boundary. This is intentionally narrow: only Aria2's resolver error + * or its distinctive DNS-server wording receives DNS-specific guidance. + */ +export const classifyDownloadError = (message: unknown): DownloadErrorKind | undefined => { + if (typeof message !== 'string') return undefined; + const lower = message.toLowerCase(); + if ( + lower.includes('aria2 error code 19') + || ( + lower.includes('name resolution') + && lower.includes('failed') + && lower.includes('could not contact dns') + ) + || lower.includes('could not contact dns server') + ) { + return 'nameResolution'; + } + return undefined; +}; diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 33eae91..ca3a6d6 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -50,6 +50,16 @@ describe('download persistence progress snapshots', () => { expect(persisted.totalIsEstimate).toBe(false); }); + it('does not persist live resolver error classification', () => { + const sanitized = redactDownloadForPersistence({ + ...item('failed'), + lastErrorKind: 'nameResolution', + lastResolverFallback: true, + }); + expect(sanitized.lastErrorKind).toBeUndefined(); + expect(sanitized.lastResolverFallback).toBeUndefined(); + }); + it.each(['queued', 'staged', 'retrying', 'processing'] as const)( 'keeps byte counters for %s snapshots', (status) => { diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 99c0892..143c5f3 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -577,5 +577,9 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem = for (const field of DOWNLOAD_SECRET_FIELDS) { delete copy[field]; } + // Error classification is derived from the live native state and must not + // become a persistence field or influence a new lifecycle after restart. + delete copy.lastErrorKind; + delete copy.lastResolverFallback; return copy; };