mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 12:07:54 +00:00
fix(aria2): harden per-transfer DNS fallback (issue #35)
This commit is contained in:
@@ -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.
|
||||
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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -99,6 +99,10 @@ const fa = {
|
||||
retrying: 'در حال تلاش مجدد',
|
||||
moving: 'در حال جابهجایی داده',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'تلاش مجدد با DNS سیستم',
|
||||
nameResolutionFailed: 'نام سرور پیدا نشد. VPN یا DNS شبکه را بررسی کنید.',
|
||||
},
|
||||
values: {
|
||||
processing: 'در حال پردازش…',
|
||||
muxing: 'در حال ترکیب…',
|
||||
|
||||
@@ -99,6 +99,10 @@ const he = {
|
||||
retrying: 'ניסיון חוזר',
|
||||
moving: 'מעביר נתונים',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'מנסה שוב באמצעות פותר השמות של המערכת',
|
||||
nameResolutionFailed: 'לא ניתן לפתור את שם השרת. בדקו את ה‑VPN או את ה‑DNS של הרשת.',
|
||||
},
|
||||
values: {
|
||||
processing: 'מעבד…',
|
||||
muxing: 'ממזג…',
|
||||
|
||||
@@ -99,6 +99,10 @@ const ru = {
|
||||
retrying: 'Повторная попытка',
|
||||
moving: 'Перемещение данных',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'Повторная попытка через системный DNS',
|
||||
nameResolutionFailed: 'Не удалось разрешить имя сервера. Проверьте VPN или DNS сети.',
|
||||
},
|
||||
values: {
|
||||
processing: 'Обработка…',
|
||||
muxing: 'Мультиплексирование…',
|
||||
|
||||
@@ -99,6 +99,10 @@ const uk = {
|
||||
retrying: 'Повторна спроба',
|
||||
moving: 'Переміщення даних',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: 'Повторна спроба через системний DNS',
|
||||
nameResolutionFailed: 'Не вдалося визначити ім’я сервера. Перевірте VPN або DNS мережі.',
|
||||
},
|
||||
values: {
|
||||
processing: 'Обробка…',
|
||||
muxing: 'Об\'єднання потоків…',
|
||||
|
||||
@@ -99,6 +99,10 @@ const zhCN = {
|
||||
retrying: '重试中',
|
||||
moving: '正在移动数据',
|
||||
},
|
||||
errors: {
|
||||
nameResolutionRetrying: '正在使用系统 DNS 重试',
|
||||
nameResolutionFailed: '无法解析服务器名称。请检查 VPN 或网络 DNS。',
|
||||
},
|
||||
values: {
|
||||
processing: '处理中…',
|
||||
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', () => {
|
||||
const snapshot = sanitizePropertiesSnapshot({
|
||||
id: 'torrent-1',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user