mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-04 15:05:22 +00:00
fix(aria2): harden per-transfer DNS fallback (issue #35)
This commit is contained in:
@@ -80,3 +80,5 @@ jobs:
|
|||||||
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
|
||||||
- name: Run Torrent process smoke
|
- 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
|
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' || '' }}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"check:updates": "node scripts/check-updates.js",
|
"check:updates": "node scripts/check-updates.js",
|
||||||
"smoke:torrent": "node scripts/smoke-torrent.js",
|
"smoke:torrent": "node scripts/smoke-torrent.js",
|
||||||
"smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths",
|
"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",
|
"test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture",
|
||||||
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
"verify:macos-signing": "node scripts/verify-macos-signing.js",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-1
@@ -922,6 +922,7 @@ pub fn replace_downloads(
|
|||||||
let strings = values
|
let strings = values
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|mut value| {
|
.map(|mut value| {
|
||||||
|
remove_live_download_metadata(&mut value);
|
||||||
if portable {
|
if portable {
|
||||||
remove_persisted_transfer_secrets(&mut value);
|
remove_persisted_transfer_secrets(&mut value);
|
||||||
}
|
}
|
||||||
@@ -995,6 +996,7 @@ where
|
|||||||
if object.get("id").and_then(Value::as_str) != Some(id) {
|
if object.get("id").and_then(Value::as_str) != Some(id) {
|
||||||
return Err("persisted download mutation cannot change its id".to_string());
|
return Err("persisted download mutation cannot change its id".to_string());
|
||||||
}
|
}
|
||||||
|
remove_live_download_metadata(&mut value);
|
||||||
if portable {
|
if portable {
|
||||||
remove_persisted_transfer_secrets(&mut value);
|
remove_persisted_transfer_secrets(&mut value);
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1025,17 @@ where
|
|||||||
Ok(result)
|
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) {
|
fn remove_persisted_transfer_secrets(value: &mut Value) {
|
||||||
|
remove_live_download_metadata(value);
|
||||||
let Some(object) = value.as_object_mut() else {
|
let Some(object) = value.as_object_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -2469,7 +2481,8 @@ mod tests {
|
|||||||
"id": "torrent-1",
|
"id": "torrent-1",
|
||||||
"status": "paused",
|
"status": "paused",
|
||||||
"url": "https://example.test/file",
|
"url": "https://example.test/file",
|
||||||
"password": "secret"
|
"password": "secret",
|
||||||
|
"lastErrorKind": "nameResolution"
|
||||||
}])
|
}])
|
||||||
.to_string(),
|
.to_string(),
|
||||||
false,
|
false,
|
||||||
@@ -2488,6 +2501,36 @@ mod tests {
|
|||||||
assert!(saved.get("password").is_none());
|
assert!(saved.get("password").is_none());
|
||||||
assert_eq!(saved["status"], "failed");
|
assert_eq!(saved["status"], "failed");
|
||||||
assert_eq!(saved["resumable"], false);
|
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]
|
#[test]
|
||||||
|
|||||||
+55
-3
@@ -140,6 +140,13 @@ pub struct QueueConcurrencyConfig {
|
|||||||
pub max_concurrent: Option<usize>,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
@@ -198,6 +205,12 @@ pub struct DownloadItem {
|
|||||||
pub has_been_dispatched: Option<bool>,
|
pub has_been_dispatched: Option<bool>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub last_error: Option<String>,
|
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)]
|
#[ts(optional)]
|
||||||
pub last_try: Option<String>,
|
pub last_try: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -757,6 +770,12 @@ pub struct DownloadStateEvent {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub error: Option<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)]
|
#[ts(optional)]
|
||||||
pub file_name: Option<String>,
|
pub file_name: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
@@ -769,26 +788,34 @@ impl DownloadStateEvent {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: status.as_str().to_string(),
|
status: status.as_str().to_string(),
|
||||||
error: None,
|
error: None,
|
||||||
|
error_kind: None,
|
||||||
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
|
pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||||
|
let (error, error_kind) = Self::safe_error(error);
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: DownloadStatus::Failed.as_str().to_string(),
|
status: DownloadStatus::Failed.as_str().to_string(),
|
||||||
error: Some(error.into()),
|
error: Some(error),
|
||||||
|
error_kind,
|
||||||
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn paused_with_error(id: impl Into<String>, error: impl Into<String>) -> Self {
|
pub fn paused_with_error(id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||||
|
let (error, error_kind) = Self::safe_error(error);
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: DownloadStatus::Paused.as_str().to_string(),
|
status: DownloadStatus::Paused.as_str().to_string(),
|
||||||
error: Some(error.into()),
|
error: Some(error),
|
||||||
|
error_kind,
|
||||||
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
@@ -799,6 +826,8 @@ impl DownloadStateEvent {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: DownloadStatus::Paused.as_str().to_string(),
|
status: DownloadStatus::Paused.as_str().to_string(),
|
||||||
error: None,
|
error: None,
|
||||||
|
error_kind: None,
|
||||||
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
torrent_seed_remaining: remaining,
|
torrent_seed_remaining: remaining,
|
||||||
}
|
}
|
||||||
@@ -809,6 +838,8 @@ impl DownloadStateEvent {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: DownloadStatus::Completed.as_str().to_string(),
|
status: DownloadStatus::Completed.as_str().to_string(),
|
||||||
error: None,
|
error: None,
|
||||||
|
error_kind: None,
|
||||||
|
resolver_fallback: None,
|
||||||
file_name: Some(file_name.into()),
|
file_name: Some(file_name.into()),
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
@@ -817,10 +848,13 @@ impl DownloadStateEvent {
|
|||||||
/// Transient retry state. Carries the human-readable reason so the UI can
|
/// Transient retry state. Carries the human-readable reason so the UI can
|
||||||
/// surface "network dropped, retrying in 5s…". The slot is still held.
|
/// surface "network dropped, retrying in 5s…". The slot is still held.
|
||||||
pub fn retrying(id: impl Into<String>, reason: impl Into<String>) -> Self {
|
pub fn retrying(id: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||||
|
let (reason, error_kind) = Self::safe_error(reason);
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: DownloadStatus::Retrying.as_str().to_string(),
|
status: DownloadStatus::Retrying.as_str().to_string(),
|
||||||
error: Some(reason.into()),
|
error: Some(reason),
|
||||||
|
error_kind,
|
||||||
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
torrent_seed_remaining: None,
|
torrent_seed_remaining: None,
|
||||||
}
|
}
|
||||||
@@ -831,8 +865,26 @@ impl DownloadStateEvent {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
|
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
|
||||||
error: None,
|
error: None,
|
||||||
|
error_kind: None,
|
||||||
|
resolver_fallback: None,
|
||||||
file_name: None,
|
file_name: None,
|
||||||
torrent_seed_remaining: remaining,
|
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_poll = Arc::clone(&queue_manager);
|
||||||
|
let queue_manager_capability = Arc::clone(&queue_manager);
|
||||||
|
|
||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
|
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 {
|
match rpc_call(attempt_port, &aria2_secret_clone, "aria2.getVersion", serde_json::json!([])).await {
|
||||||
Ok(ver) => {
|
Ok(ver) => {
|
||||||
let v = ver.get("version").and_then(|v| v.as_str()).unwrap_or("unknown");
|
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;
|
ready = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-14
@@ -1,14 +1,17 @@
|
|||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||||
use crate::power::PowerManager;
|
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 log;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
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::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
@@ -705,6 +708,21 @@ enum SeedAdmissionOutcome {
|
|||||||
|
|
||||||
/// Args mirroring start_download / start_media_download. Kept untyped-loose
|
/// Args mirroring start_download / start_media_download. Kept untyped-loose
|
||||||
/// (String/Option) to match the existing command signatures exactly.
|
/// (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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct SpawnPayload {
|
pub struct SpawnPayload {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -721,6 +739,9 @@ pub struct SpawnPayload {
|
|||||||
pub user_agent: Option<String>,
|
pub user_agent: Option<String>,
|
||||||
pub max_tries: Option<i32>,
|
pub max_tries: Option<i32>,
|
||||||
pub proxy: Option<String>,
|
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 format_selector: Option<String>,
|
||||||
pub cookie_source: Option<String>,
|
pub cookie_source: Option<String>,
|
||||||
pub is_media: bool,
|
pub is_media: bool,
|
||||||
@@ -907,6 +928,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
|||||||
/// cap as a degraded connection pool.
|
/// cap as a degraded connection pool.
|
||||||
aria2_global_speed_limit: Arc<StdMutex<Option<String>>>,
|
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.
|
/// 0-based transient-error strike counter per aria2 download id.
|
||||||
aria2_retry_strikes: Mutex<HashMap<String, usize>>,
|
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_inflight: Mutex::new(HashMap::new()),
|
||||||
aria2_dispatch_notify: Notify::new(),
|
aria2_dispatch_notify: Notify::new(),
|
||||||
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
|
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_strikes: Mutex::new(HashMap::new()),
|
||||||
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
||||||
aria2_retry_inflight: Mutex::new(HashMap::new()),
|
aria2_retry_inflight: Mutex::new(HashMap::new()),
|
||||||
@@ -1009,6 +1036,15 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
Arc::clone(&self.power_manager)
|
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
|
/// Accept one lifecycle-fenced Aria2 status sample and return Firelink's
|
||||||
/// monotonic lifetime counters. Poller callers must already have checked
|
/// monotonic lifetime counters. Poller callers must already have checked
|
||||||
/// the mapping; the key and epoch checks here provide a second fence at
|
/// 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 {
|
if verification_only {
|
||||||
self.emit_paused_with_error(id, error);
|
self.emit_paused_with_error(id, error);
|
||||||
} else {
|
} 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);
|
self.emit_failed(id, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4468,9 +4505,18 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
*entry
|
*entry
|
||||||
};
|
};
|
||||||
|
|
||||||
let transient = is_retryable_aria2_error(&error);
|
let retry_action = aria2_retry_action(
|
||||||
let strikes_left = strike < automatic_retry_limit(payload.max_tries);
|
&payload,
|
||||||
if !(transient && strikes_left) {
|
&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))
|
self.apply_completion_locked(&id, PendingOutcome::Error(error))
|
||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
@@ -4510,6 +4556,14 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.insert(id.clone(), payload.clone());
|
.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 this = Arc::clone(self);
|
||||||
let id_for_task = id.clone();
|
let id_for_task = id.clone();
|
||||||
let error_for_emit = error.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| {
|
let outcome = backoff_and_emit(strike, error_for_emit, retry_cancel, |reason| {
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
let _ = this.app_handle.emit(
|
let event = if resolver_fallback {
|
||||||
"download-state",
|
DownloadStateEvent::retrying_with_resolver_fallback(&id_for_task, reason)
|
||||||
DownloadStateEvent::retrying(&id_for_task, reason),
|
} else {
|
||||||
);
|
DownloadStateEvent::retrying(&id_for_task, reason)
|
||||||
|
};
|
||||||
|
let _ = this.app_handle.emit("download-state", event);
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -4602,10 +4658,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.aria2_retry_strikes
|
if !resolver_fallback {
|
||||||
.lock()
|
this.aria2_retry_strikes
|
||||||
.await
|
.lock()
|
||||||
.insert(id_for_task.clone(), strike + 1);
|
.await
|
||||||
|
.insert(id_for_task.clone(), strike + 1);
|
||||||
|
}
|
||||||
this.emit_state(&id_for_task, DownloadStatus::Downloading);
|
this.emit_state(&id_for_task, DownloadStatus::Downloading);
|
||||||
// Stop suppressing events for the id before exposing the
|
// Stop suppressing events for the id before exposing the
|
||||||
// new gid. The old gid remains marked as retrying until
|
// 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)
|
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 {
|
fn is_aria2_rpc_unavailable(error: &str) -> bool {
|
||||||
let lower = error.to_ascii_lowercase();
|
let lower = error.to_ascii_lowercase();
|
||||||
is_transient_network_error(error)
|
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 {
|
fn should_apply_aria2_connection_options(payload: &SpawnPayload) -> bool {
|
||||||
!payload.is_torrent
|
!payload.is_torrent
|
||||||
}
|
}
|
||||||
@@ -6174,6 +6274,7 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
if let Some(prox) = proxy_value {
|
if let Some(prox) = proxy_value {
|
||||||
options.insert("all-proxy".to_string(), serde_json::json!(prox));
|
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 {
|
let (method, params) = if payload.is_torrent {
|
||||||
if let Some(path) = payload.torrent_path.as_deref() {
|
if let Some(path) = payload.torrent_path.as_deref() {
|
||||||
@@ -6789,6 +6890,7 @@ impl EnqueueItem {
|
|||||||
user_agent: self.user_agent,
|
user_agent: self.user_agent,
|
||||||
max_tries: self.max_tries,
|
max_tries: self.max_tries,
|
||||||
proxy: self.proxy,
|
proxy: self.proxy,
|
||||||
|
aria2_resolver_mode: Aria2ResolverMode::Automatic,
|
||||||
format_selector: self.format_selector,
|
format_selector: self.format_selector,
|
||||||
cookie_source: self.cookie_source,
|
cookie_source: self.cookie_source,
|
||||||
is_media: media,
|
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]
|
#[test]
|
||||||
fn torrent_payloads_do_not_use_generic_connection_options() {
|
fn torrent_payloads_do_not_use_generic_connection_options() {
|
||||||
let torrent = SpawnPayload {
|
let torrent = SpawnPayload {
|
||||||
@@ -8390,6 +8522,60 @@ mod tests {
|
|||||||
assert!(!is_retryable_aria2_error("aria2 error code 7: unfinished download"));
|
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]
|
#[test]
|
||||||
fn aria2_startup_rpc_errors_are_retryable() {
|
fn aria2_startup_rpc_errors_are_retryable() {
|
||||||
assert!(is_aria2_rpc_unavailable(
|
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.
|
/// fall through to a hard `Failed`. Three strikes matches the schedule length.
|
||||||
pub const MAX_RETRIES: usize = BACKOFF_SCHEDULE.len();
|
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
|
/// 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
|
/// schedule length clamp to the longest slot (10s) rather than panicking, so a
|
||||||
/// mis-sized loop degrades gracefully instead of aborting the worker.
|
/// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if is_aria2_name_resolution_error(message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
let m = message.to_ascii_lowercase();
|
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
|
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
|
||||||
"timed out",
|
"timed out",
|
||||||
"timeout",
|
"timeout",
|
||||||
@@ -140,6 +156,8 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
|||||||
"connection aborted",
|
"connection aborted",
|
||||||
"error sending request", // reqwest wrapper for connect/send failures
|
"error sending request", // reqwest wrapper for connect/send failures
|
||||||
"dns error", // transient resolver 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
|
"protocol error", // aria2 read/protocol failures after a link drop
|
||||||
"tls handshake failure",
|
"tls handshake failure",
|
||||||
"ssl/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 -------------------------
|
// --- transient classification: negative cases -------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use firelink_lib::queue::{
|
use firelink_lib::queue::{
|
||||||
Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
|
Aria2RecreateOutcome, Aria2RefreshOutcome, Aria2ResolverMode, QueueManager, QueuedTask,
|
||||||
SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -27,6 +27,15 @@ struct CountingSpawner {
|
|||||||
torrent_peer_options_release: tokio::sync::Notify,
|
torrent_peer_options_release: tokio::sync::Notify,
|
||||||
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
|
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
|
||||||
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
|
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
|
||||||
|
add_resolver_modes: std::sync::Mutex<Vec<Aria2ResolverMode>>,
|
||||||
|
add_transfer_context: std::sync::Mutex<
|
||||||
|
Vec<(
|
||||||
|
Aria2ResolverMode,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i32>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
block_speed_limit: std::sync::atomic::AtomicBool,
|
block_speed_limit: std::sync::atomic::AtomicBool,
|
||||||
speed_limit_started: tokio::sync::Notify,
|
speed_limit_started: tokio::sync::Notify,
|
||||||
speed_limit_release: tokio::sync::Notify,
|
speed_limit_release: tokio::sync::Notify,
|
||||||
@@ -211,6 +220,8 @@ impl CountingSpawner {
|
|||||||
torrent_peer_options_release: tokio::sync::Notify::new(),
|
torrent_peer_options_release: tokio::sync::Notify::new(),
|
||||||
add_speed_limits: std::sync::Mutex::new(Vec::new()),
|
add_speed_limits: std::sync::Mutex::new(Vec::new()),
|
||||||
add_peer_options: 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),
|
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
|
||||||
speed_limit_started: tokio::sync::Notify::new(),
|
speed_limit_started: tokio::sync::Notify::new(),
|
||||||
speed_limit_release: 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_max_peers,
|
||||||
payload.torrent_peer_speed_limit.clone(),
|
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}"))
|
Ok(format!("gid-{call}"))
|
||||||
}
|
}
|
||||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||||
@@ -2169,6 +2190,93 @@ async fn transient_aria2_error_reissues_after_backoff() {
|
|||||||
dispatcher.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn duplicate_transient_events_schedule_only_one_retry_worker() {
|
async fn duplicate_transient_events_schedule_only_one_retry_worker() {
|
||||||
use firelink_lib::queue::PendingOutcome;
|
use firelink_lib::queue::PendingOutcome;
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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 { DownloadCategory } from "./DownloadCategory";
|
||||||
|
import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||||
import type { DownloadStatus } from "./DownloadStatus";
|
import type { DownloadStatus } from "./DownloadStatus";
|
||||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
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<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, 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<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, 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, };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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, };
|
||||||
|
|||||||
@@ -215,6 +215,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||||
})();
|
})();
|
||||||
const downloadStatusLabel = t($ => $.downloads.status[download.status]);
|
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
|
const downloadedSizeLabel = sizeDisplay.totalIsEstimate
|
||||||
? t($ => $.downloads.size.downloadedOfApproximate, {
|
? t($ => $.downloads.size.downloadedOfApproximate, {
|
||||||
downloaded: sizeDisplay.downloaded ?? '',
|
downloaded: sizeDisplay.downloaded ?? '',
|
||||||
@@ -349,7 +356,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
) : download.status === 'processing' ? (
|
) : download.status === 'processing' ? (
|
||||||
downloadStatusLabel
|
downloadStatusLabel
|
||||||
) : (
|
) : (
|
||||||
downloadStatusLabel
|
visibleErrorStatusLabel
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -896,7 +896,21 @@ export const PropertiesWindowApp = () => {
|
|||||||
<span className="text-text-muted">{t($ => $.properties.lastTry)}</span><span dir="ltr">{snapshot.lastTry || '—'}</span>
|
<span className="text-text-muted">{t($ => $.properties.lastTry)}</span><span dir="ltr">{snapshot.lastTry || '—'}</span>
|
||||||
<span className="text-text-muted">{t($ => $.properties.queueId)}</span><span title={queuePlacement}>{queuePlacement}</span>
|
<span className="text-text-muted">{t($ => $.properties.queueId)}</span><span title={queuePlacement}>{queuePlacement}</span>
|
||||||
<span className="text-text-muted">{t($ => $.properties.resumable)}</span><span>{snapshot.resumable === false ? '—' : '✓'}</span>
|
<span className="text-text-muted">{t($ => $.properties.resumable)}</span><span>{snapshot.resumable === false ? '—' : '✓'}</span>
|
||||||
{snapshot.lastError && <><span className="text-text-muted">{t($ => $.properties.lastError)}</span><span className="break-words text-red-300">{snapshot.lastError}</span></>}
|
{snapshot.lastError && <>
|
||||||
|
<span className="text-text-muted">{t($ => $.properties.lastError)}</span>
|
||||||
|
<div className="space-y-1 break-words text-red-300">
|
||||||
|
{snapshot.lastErrorKind === 'nameResolution' && (
|
||||||
|
<p className="font-medium text-text-primary">
|
||||||
|
{snapshot.status === 'retrying'
|
||||||
|
? snapshot.lastResolverFallback === true
|
||||||
|
? t($ => $.downloads.errors.nameResolutionRetrying)
|
||||||
|
: t($ => $.downloads.status.retrying)
|
||||||
|
: t($ => $.downloads.errors.nameResolutionFailed)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span>{snapshot.lastError}</span>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
{snapshot.isMedia === true && <div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
{snapshot.isMedia === true && <div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||||
<span className="text-text-muted">{t($ => $.addDownloads.format)}</span><span className="break-all font-mono">{snapshot.mediaFormatSelector || '—'}</span>
|
<span className="text-text-muted">{t($ => $.addDownloads.format)}</span><span className="break-all font-mono">{snapshot.mediaFormatSelector || '—'}</span>
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const common = {
|
|||||||
retrying: 'Retrying',
|
retrying: 'Retrying',
|
||||||
moving: 'Moving data',
|
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: {
|
values: {
|
||||||
processing: 'Processing...',
|
processing: 'Processing...',
|
||||||
muxing: 'Muxing...',
|
muxing: 'Muxing...',
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const fa = {
|
|||||||
retrying: 'در حال تلاش مجدد',
|
retrying: 'در حال تلاش مجدد',
|
||||||
moving: 'در حال جابهجایی داده',
|
moving: 'در حال جابهجایی داده',
|
||||||
},
|
},
|
||||||
|
errors: {
|
||||||
|
nameResolutionRetrying: 'تلاش مجدد با DNS سیستم',
|
||||||
|
nameResolutionFailed: 'نام سرور پیدا نشد. VPN یا DNS شبکه را بررسی کنید.',
|
||||||
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'در حال پردازش…',
|
processing: 'در حال پردازش…',
|
||||||
muxing: 'در حال ترکیب…',
|
muxing: 'در حال ترکیب…',
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const he = {
|
|||||||
retrying: 'ניסיון חוזר',
|
retrying: 'ניסיון חוזר',
|
||||||
moving: 'מעביר נתונים',
|
moving: 'מעביר נתונים',
|
||||||
},
|
},
|
||||||
|
errors: {
|
||||||
|
nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת',
|
||||||
|
nameResolutionFailed: 'לא ניתן לפתור את שם השרת. בדקו את ה‑VPN או את ה‑DNS של הרשת.',
|
||||||
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'מעבד…',
|
processing: 'מעבד…',
|
||||||
muxing: 'ממזג…',
|
muxing: 'ממזג…',
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const ru = {
|
|||||||
retrying: 'Повторная попытка',
|
retrying: 'Повторная попытка',
|
||||||
moving: 'Перемещение данных',
|
moving: 'Перемещение данных',
|
||||||
},
|
},
|
||||||
|
errors: {
|
||||||
|
nameResolutionRetrying: 'Повторная попытка через системный DNS',
|
||||||
|
nameResolutionFailed: 'Не удалось разрешить имя сервера. Проверьте VPN или DNS сети.',
|
||||||
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'Обработка…',
|
processing: 'Обработка…',
|
||||||
muxing: 'Мультиплексирование…',
|
muxing: 'Мультиплексирование…',
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const uk = {
|
|||||||
retrying: 'Повторна спроба',
|
retrying: 'Повторна спроба',
|
||||||
moving: 'Переміщення даних',
|
moving: 'Переміщення даних',
|
||||||
},
|
},
|
||||||
|
errors: {
|
||||||
|
nameResolutionRetrying: 'Повторна спроба через системний DNS',
|
||||||
|
nameResolutionFailed: 'Не вдалося визначити ім’я сервера. Перевірте VPN або DNS мережі.',
|
||||||
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'Обробка…',
|
processing: 'Обробка…',
|
||||||
muxing: 'Об\'єднання потоків…',
|
muxing: 'Об\'єднання потоків…',
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const zhCN = {
|
|||||||
retrying: '重试中',
|
retrying: '重试中',
|
||||||
moving: '正在移动数据',
|
moving: '正在移动数据',
|
||||||
},
|
},
|
||||||
|
errors: {
|
||||||
|
nameResolutionRetrying: '正在使用系统 DNS 重试',
|
||||||
|
nameResolutionFailed: '无法解析服务器名称。请检查 VPN 或网络 DNS。',
|
||||||
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: '处理中…',
|
processing: '处理中…',
|
||||||
muxing: '混流中…',
|
muxing: '混流中…',
|
||||||
|
|||||||
@@ -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', () => {
|
it('projects the latest live telemetry without exposing secrets', () => {
|
||||||
const snapshot = sanitizePropertiesSnapshot({
|
const snapshot = sanitizePropertiesSnapshot({
|
||||||
id: 'torrent-1',
|
id: 'torrent-1',
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { emitTo } from '@tauri-apps/api/event';
|
import { emitTo } from '@tauri-apps/api/event';
|
||||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||||
|
import type { DownloadErrorKind } from './bindings/DownloadErrorKind';
|
||||||
import type { DownloadStatus } from './bindings/DownloadStatus';
|
import type { DownloadStatus } from './bindings/DownloadStatus';
|
||||||
import type { DownloadItem } from './store/useDownloadStore';
|
import type { DownloadItem } from './store/useDownloadStore';
|
||||||
import { canPauseDownload } from './utils/downloadActions';
|
import { canPauseDownload } from './utils/downloadActions';
|
||||||
import type { DocumentAppearance } from './utils/documentAppearance';
|
import type { DocumentAppearance } from './utils/documentAppearance';
|
||||||
import { invokeCommand as invoke } from './ipc';
|
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_READY = 'properties-window-ready' as const;
|
||||||
export const PROPERTIES_WINDOW_SNAPSHOT = 'properties-window-snapshot' as const;
|
export const PROPERTIES_WINDOW_SNAPSHOT = 'properties-window-snapshot' as const;
|
||||||
@@ -49,6 +51,8 @@ const PROPERTIES_SNAPSHOT_KEYS = [
|
|||||||
'queuePosition',
|
'queuePosition',
|
||||||
'hasBeenDispatched',
|
'hasBeenDispatched',
|
||||||
'lastError',
|
'lastError',
|
||||||
|
'lastErrorKind',
|
||||||
|
'lastResolverFallback',
|
||||||
'lastTry',
|
'lastTry',
|
||||||
'isTorrent',
|
'isTorrent',
|
||||||
'torrentFileIndices',
|
'torrentFileIndices',
|
||||||
@@ -145,6 +149,8 @@ export type PropertiesSnapshotContext = {
|
|||||||
export type PropertiesSnapshot = SafePropertiesFields & {
|
export type PropertiesSnapshot = SafePropertiesFields & {
|
||||||
appearance: DocumentAppearance;
|
appearance: DocumentAppearance;
|
||||||
queueName?: string;
|
queueName?: string;
|
||||||
|
lastErrorKind?: DownloadErrorKind;
|
||||||
|
lastResolverFallback?: boolean;
|
||||||
activeConnections?: number;
|
activeConnections?: number;
|
||||||
requestedConnections?: number;
|
requestedConnections?: number;
|
||||||
connectedPeers?: number;
|
connectedPeers?: number;
|
||||||
@@ -298,9 +304,11 @@ const copyWithoutSecrets = (
|
|||||||
)),
|
)),
|
||||||
) as SafePropertiesFields;
|
) as SafePropertiesFields;
|
||||||
if (item.isTorrent === true) delete safeItem.connections;
|
if (item.isTorrent === true) delete safeItem.connections;
|
||||||
|
const lastErrorKind = item.lastErrorKind ?? classifyDownloadError(item.lastError);
|
||||||
return {
|
return {
|
||||||
...safeItem,
|
...safeItem,
|
||||||
appearance,
|
appearance,
|
||||||
|
...(lastErrorKind ? { lastErrorKind } : {}),
|
||||||
...(context?.queueName ? { queueName: context.queueName } : {}),
|
...(context?.queueName ? { queueName: context.queueName } : {}),
|
||||||
...(live?.progress ? {
|
...(live?.progress ? {
|
||||||
fraction: live.progress.fraction,
|
fraction: live.progress.fraction,
|
||||||
|
|||||||
@@ -125,6 +125,44 @@ describe('useDownloadProgressStore', () => {
|
|||||||
release();
|
release();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('projects resolver error metadata and clears it when the lifecycle resumes', async () => {
|
||||||
|
const handlers: Record<string, (event: any) => 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 () => {
|
it('projects torrent seeding state and upload telemetry', async () => {
|
||||||
const handlers: Record<string, (event: any) => void> = {};
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
|||||||
@@ -200,7 +200,11 @@ const startDownloadListeners = async () => {
|
|||||||
? { totalIsEstimate: progress.total_is_estimate }
|
? { 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')
|
...((status === 'downloading' || status === 'verifying' || status === 'retrying')
|
||||||
? { lastTry: new Date().toISOString() }
|
? { lastTry: new Date().toISOString() }
|
||||||
: {})
|
: {})
|
||||||
@@ -212,6 +216,8 @@ const startDownloadListeners = async () => {
|
|||||||
}
|
}
|
||||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||||
updates.lastError = undefined;
|
updates.lastError = undefined;
|
||||||
|
updates.lastErrorKind = undefined;
|
||||||
|
updates.lastResolverFallback = undefined;
|
||||||
}
|
}
|
||||||
if (payload.fileName && payload.fileName !== current.fileName) {
|
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||||
updates.fileName = payload.fileName;
|
updates.fileName = payload.fileName;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -50,6 +50,16 @@ describe('download persistence progress snapshots', () => {
|
|||||||
expect(persisted.totalIsEstimate).toBe(false);
|
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)(
|
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
||||||
'keeps byte counters for %s snapshots',
|
'keeps byte counters for %s snapshots',
|
||||||
(status) => {
|
(status) => {
|
||||||
|
|||||||
@@ -577,5 +577,9 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem =
|
|||||||
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
||||||
delete copy[field];
|
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;
|
return copy;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user