fix(aria2): harden per-transfer DNS fallback (issue #35)

This commit is contained in:
NimBold
2026-08-05 19:28:56 +03:30
parent 4a83ac97c7
commit c1202229ac
27 changed files with 864 additions and 27 deletions
+3
View File
@@ -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";
+2 -1
View File
@@ -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<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, };
+2 -1
View File
@@ -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, };
+8 -1
View File
@@ -215,6 +215,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
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<DownloadItemProps>(({
) : download.status === 'processing' ? (
downloadStatusLabel
) : (
downloadStatusLabel
visibleErrorStatusLabel
)}
</span>
</div>
+15 -1
View File
@@ -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.queueId)}</span><span title={queuePlacement}>{queuePlacement}</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>
{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>
+4
View File
@@ -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...',
+4
View File
@@ -99,6 +99,10 @@ const fa = {
retrying: 'در حال تلاش مجدد',
moving: 'در حال جابه‌جایی داده',
},
errors: {
nameResolutionRetrying: 'تلاش مجدد با DNS سیستم',
nameResolutionFailed: 'نام سرور پیدا نشد. VPN یا DNS شبکه را بررسی کنید.',
},
values: {
processing: 'در حال پردازش…',
muxing: 'در حال ترکیب…',
+4
View File
@@ -99,6 +99,10 @@ const he = {
retrying: 'ניסיון חוזר',
moving: 'מעביר נתונים',
},
errors: {
nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת',
nameResolutionFailed: 'לא ניתן לפתור את שם השרת. בדקו את ה‑VPN או את ה‑DNS של הרשת.',
},
values: {
processing: 'מעבד…',
muxing: 'ממזג…',
+4
View File
@@ -99,6 +99,10 @@ const ru = {
retrying: 'Повторная попытка',
moving: 'Перемещение данных',
},
errors: {
nameResolutionRetrying: 'Повторная попытка через системный DNS',
nameResolutionFailed: 'Не удалось разрешить имя сервера. Проверьте VPN или DNS сети.',
},
values: {
processing: 'Обработка…',
muxing: 'Мультиплексирование…',
+4
View File
@@ -99,6 +99,10 @@ const uk = {
retrying: 'Повторна спроба',
moving: 'Переміщення даних',
},
errors: {
nameResolutionRetrying: 'Повторна спроба через системний DNS',
nameResolutionFailed: 'Не вдалося визначити ім’я сервера. Перевірте VPN або DNS мережі.',
},
values: {
processing: 'Обробка…',
muxing: 'Об\'єднання потоків…',
+4
View File
@@ -99,6 +99,10 @@ const zhCN = {
retrying: '重试中',
moving: '正在移动数据',
},
errors: {
nameResolutionRetrying: '正在使用系统 DNS 重试',
nameResolutionFailed: '无法解析服务器名称。请检查 VPN 或网络 DNS。',
},
values: {
processing: '处理中…',
muxing: '混流中…',
+23
View File
@@ -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',
+8
View File
@@ -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,
+38
View File
@@ -125,6 +125,44 @@ describe('useDownloadProgressStore', () => {
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 () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
+7 -1
View File
@@ -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;
+23
View File
@@ -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;
};
+10
View File
@@ -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) => {
+4
View File
@@ -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;
};