fix(downloads): harden aria2 recovery and resume lifecycle

This commit is contained in:
NimBold
2026-07-13 00:35:09 +03:30
parent dad5b7bc5e
commit 9805c9288a
12 changed files with 412 additions and 26 deletions
+2
View File
@@ -110,6 +110,8 @@ pub struct DownloadItem {
pub has_been_dispatched: Option<bool>,
#[ts(optional)]
pub last_error: Option<String>,
#[ts(optional)]
pub last_try: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+204 -8
View File
@@ -3746,6 +3746,80 @@ async fn aria2_download_status(port: u16, secret: &str, gid: &str) -> Result<Str
.ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}"))
}
async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) {
let state = app_handle.state::<AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = state.aria2_secret.clone();
let mappings = state.queue_manager.aria2_gid_mappings();
for (gid, id) in mappings {
let status = match rpc_call(
port,
&secret,
"aria2.tellStatus",
serde_json::json!([gid, ["status", "errorCode", "errorMessage"]]),
)
.await
{
Ok(status) => status,
Err(error) => {
log::debug!(
"aria2 reconnect reconciliation [{}]: could not query gid {}: {}",
id,
gid,
error
);
continue;
}
};
let status_name = status.get("status").and_then(|value| value.as_str());
let outcome = match status_name {
Some("complete") => Some(crate::queue::PendingOutcome::Complete),
Some("error") | Some("removed") => {
let error_code = status
.get("errorCode")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
let error_message = status
.get("errorMessage")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.unwrap_or("aria2 download ended while the event channel was disconnected");
Some(crate::queue::PendingOutcome::Error(match error_code {
Some(code) => format!("aria2 error code {code}: {error_message}"),
None => error_message.to_string(),
}))
}
_ => None,
};
if let Some(outcome) = outcome {
state.queue_manager.handle_aria2_event(&gid, outcome).await;
}
}
}
async fn aria2_daemon_is_reachable(port: u16, secret: &str) -> bool {
for attempt in 0..2 {
if rpc_call(
port,
secret,
"aria2.getVersion",
serde_json::json!([]),
)
.await
.is_ok()
{
return true;
}
if attempt == 0 {
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
false
}
fn aria2_gid_not_found(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("gid") && lower.contains("not found")
@@ -4713,6 +4787,7 @@ async fn clear_logs(app_handle: tauri::AppHandle) -> Result<(), String> {
async fn export_logs(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
destination: Option<String>,
) -> Result<String, String> {
let mut output = format!(
"Firelink support logs\nVersion: {}\nOS: {} {}\nArchitecture: {}\nGenerated: {}\n\n",
@@ -4773,6 +4848,16 @@ async fn export_logs(
}
output.push('\n');
}
if let Some(destination) = destination {
let destination = std::path::PathBuf::from(destination.trim());
if destination.file_name().is_none() {
return Err("log export destination must be a file path".to_string());
}
tokio::fs::write(&destination, &output)
.await
.map_err(|error| format!("failed to write exported logs to '{}': {error}", destination.display()))?;
}
Ok(output)
}
@@ -5959,16 +6044,31 @@ pub fn run() {
let mut ws_retries = 0;
loop {
if ws_retries > 10 {
log::error!("Max WebSocket reconnection attempts reached. aria2 integration is disabled.");
if ws_retries == 10 {
log::error!("Aria2 WebSocket is still unavailable after repeated reconnection attempts; continuing to retry.");
let guard = app_handle_bg.state::<Aria2DaemonGuard>();
*guard.startup_error.lock().unwrap() = Some("Max WebSocket reconnection attempts reached.".to_string());
break;
// Keep retrying. The daemon or local WebSocket can
// recover after a prolonged outage; permanently
// stopping this task strands active transfers.
ws_retries = 0;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let ws_url = format!("ws://127.0.0.1:{}/jsonrpc", ws_port);
if let Ok((ws_stream, _)) = tokio_tungstenite::connect_async(&ws_url).await {
ws_retries = 0; // reset on success
if let Ok(mut startup_error) = app_handle_bg
.state::<Aria2DaemonGuard>()
.startup_error
.lock()
{
if startup_error.as_deref()
== Some("Max WebSocket reconnection attempts reached.")
{
*startup_error = None;
}
}
reconcile_aria2_downloads(&app_handle_bg).await;
use futures_util::StreamExt;
let (_, mut read) = ws_stream.split();
while let Some(msg) = read.next().await {
@@ -6025,7 +6125,11 @@ pub fn run() {
}
}
ws_retries += 1;
app_handle_bg.state::<AppState>().queue_manager.clear_aria2_permits().await;
let state = app_handle_bg.state::<AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
if !aria2_daemon_is_reachable(port, &state.aria2_secret).await {
state.queue_manager.clear_aria2_permits().await;
}
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
}
});
@@ -6035,19 +6139,99 @@ pub fn run() {
let poll_secret = aria2_secret.clone();
let poll_mgr = Arc::clone(&queue_manager_poll);
tauri::async_runtime::spawn(async move {
#[derive(Default)]
struct Aria2ConnectionObservation {
gid: String,
saw_multiple_connections: bool,
degraded_since: Option<Instant>,
no_progress_since: Option<Instant>,
refreshed: bool,
last_completed: u64,
}
const RECOVERY_DELAY: Duration = Duration::from_secs(30);
const MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024;
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
let mut observations: HashMap<String, Aria2ConnectionObservation> = HashMap::new();
loop {
interval.tick().await;
let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "errorMessage"]]);
let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "connections", "errorMessage"]]);
if let Ok(active_list) = rpc_call(poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellActive", params).await {
if let Some(active_arr) = active_list.as_array() {
let mut seen_ids = std::collections::HashSet::new();
for status_info in active_arr {
let gid = status_info.get("gid").and_then(|s| s.as_str()).unwrap_or("");
let id = poll_mgr.aria2_gids.read().unwrap().get(gid).cloned();
if let Some(id) = id {
seen_ids.insert(id.clone());
let status = status_info.get("status").and_then(|value| value.as_str()).unwrap_or("");
let total = status_info.get("totalLength").and_then(|s| s.as_str()).unwrap_or("0").parse::<u64>().unwrap_or(0);
let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::<u64>().unwrap_or(0);
let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::<f64>().unwrap_or(0.0);
let active_connections = status_info.get("connections").and_then(|s| s.as_str()).unwrap_or("0").parse::<i32>().unwrap_or(0);
let requested_connections = poll_mgr
.aria2_requested_connections(&id)
.await
.unwrap_or(1)
.max(1);
let now = Instant::now();
let observation = observations.entry(id.clone()).or_default();
if observation.gid != gid {
*observation = Aria2ConnectionObservation {
gid: gid.to_string(),
last_completed: completed,
..Default::default()
};
}
let remaining = total.saturating_sub(completed);
let mut should_refresh = false;
if status == "active" && total > completed {
if completed > observation.last_completed {
observation.no_progress_since = None;
} else if speed_bytes <= 0.0 {
let stalled_since = observation.no_progress_since.get_or_insert(now);
if now.duration_since(*stalled_since) >= RECOVERY_DELAY
&& !observation.refreshed
{
observation.refreshed = true;
should_refresh = true;
}
} else {
observation.no_progress_since = None;
}
if requested_connections > 1 && active_connections > 1 {
observation.saw_multiple_connections = true;
observation.degraded_since = None;
if !should_refresh {
observation.refreshed = false;
}
} else if requested_connections > 1
&& observation.saw_multiple_connections
&& active_connections <= 1
&& remaining >= MIN_REMAINING_FOR_CONNECTION_RECOVERY
{
let degraded_since = observation.degraded_since.get_or_insert(now);
if now.duration_since(*degraded_since) >= RECOVERY_DELAY
&& !observation.refreshed
{
observation.refreshed = true;
should_refresh = true;
log::warn!(
"aria2 connection pool degraded [{}]: gid {} has {} connection(s), requested {}; refreshing",
id,
gid,
active_connections,
requested_connections
);
}
}
} else {
observation.degraded_since = None;
observation.no_progress_since = None;
}
observation.last_completed = completed;
let fraction = if total > 0 { completed as f64 / total as f64 } else { 0.0 };
let speed = crate::download::format_speed(speed_bytes);
@@ -6064,15 +6248,27 @@ pub fn run() {
use tauri::Emitter;
let _ = app_handle_poll.emit("download-progress", DownloadProgressEvent {
id,
id: id.clone(),
fraction,
speed,
eta,
size,
size_is_final: false,
});
size_is_final: false,
});
if should_refresh {
if let Err(error) = poll_mgr.refresh_aria2_connections(&id, gid).await {
log::warn!(
"aria2 connection refresh [{}] for gid {} failed: {}",
id,
gid,
error
);
}
}
}
}
observations.retain(|id, _| seen_ids.contains(id));
}
}
}
+102
View File
@@ -76,6 +76,18 @@ pub trait SidecarSpawner: Send + Sync + 'static {
/// cancellation.
async fn remove_uri(&self, gid: &str) -> Result<(), String>;
/// Recycle the connections for an active aria2 transfer without changing
/// its gid or releasing its queue permit. Production uses forcePause /
/// unpause; test spawners can leave this unsupported.
async fn refresh_uri(&self, _gid: &str) -> Result<(), String> {
Err("aria2 connection refresh is unavailable".to_string())
}
/// Leave an aria2 transfer paused after a refresh races with a user pause.
async fn pause_uri(&self, _gid: &str) -> Result<(), String> {
Err("aria2 pause is unavailable".to_string())
}
/// Run a media download to completion. The permit is parked for the full
/// duration; release is handled by QueueManager on the runner's exit.
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
@@ -297,6 +309,15 @@ impl<R: tauri::Runtime> QueueManager<R> {
== epoch
}
pub async fn current_aria2_control_epoch(&self, id: &str) -> u64 {
self.aria2_control_epochs
.lock()
.await
.get(id)
.copied()
.unwrap_or_default()
}
pub async fn is_aria2_retry_cancelled(&self, id: &str) -> bool {
self.aria2_retry_cancelled.lock().await.contains(id)
}
@@ -305,6 +326,14 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.aria2_retry_strikes.lock().await.contains_key(id)
}
pub async fn aria2_requested_connections(&self, id: &str) -> Option<i32> {
self.aria2_payloads
.lock()
.await
.get(id)
.and_then(|payload| payload.connections)
}
/// Pop the next task, or None if empty.
pub async fn pop_front(&self) -> Option<QueuedTask> {
self.pending.lock().await.pop_front()
@@ -659,6 +688,39 @@ impl<R: tauri::Runtime> QueueManager<R> {
.find_map(|(gid, download_id)| (download_id == id).then(|| gid.clone()))
}
pub fn aria2_gid_mappings(&self) -> Vec<(String, String)> {
self.aria2_gids
.read()
.unwrap()
.iter()
.map(|(gid, id)| (gid.clone(), id.clone()))
.collect()
}
/// Recycle an active transfer's connections after the poller observes a
/// persistent connection-pool collapse or a true zero-progress stall.
/// The transfer keeps its gid, partial file, and queue permit.
pub async fn refresh_aria2_connections(&self, id: &str, gid: &str) -> Result<(), String> {
if self.aria2_gid_for_download(id).as_deref() != Some(gid)
|| !self.is_registered(id).await
|| self.is_aria2_retry_cancelled(id).await
{
return Ok(());
}
let epoch = self.current_aria2_control_epoch(id).await;
self.spawner.refresh_uri(gid).await?;
let still_current = self.is_registered(id).await
&& !self.is_aria2_retry_cancelled(id).await
&& self.is_aria2_control_epoch_current(id, epoch).await
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
if !still_current {
let _ = self.spawner.pause_uri(gid).await;
}
Ok(())
}
/// Remove every gid mapping for a download and discard buffered terminal
/// events for those gids. Returns the most recently encountered gid.
pub async fn forget_aria2_gid(&self, id: &str) -> Option<String> {
@@ -1250,6 +1312,8 @@ impl SidecarSpawner for ProductionSpawner {
let mt = aria2_attempt_limit(payload.max_tries);
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
options.insert("retry-wait".to_string(), serde_json::json!("2"));
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
options.insert("timeout".to_string(), serde_json::json!("60"));
options.insert("continue".to_string(), serde_json::json!("true"));
options.insert("always-resume".to_string(), serde_json::json!("true"));
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
@@ -1332,6 +1396,44 @@ impl SidecarSpawner for ProductionSpawner {
}
}
async fn refresh_uri(&self, gid: &str) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = &state.aria2_secret;
let paused = crate::rpc_call(
port,
secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await
.map_err(|error| format!("failed to refresh aria2 gid {gid}: {error}"))?;
crate::ensure_aria2_gid_result("forcePause", gid, &paused)?;
let resumed = crate::rpc_call(
port,
secret,
"aria2.unpause",
serde_json::json!([gid]),
)
.await
.map_err(|error| format!("failed to refresh aria2 gid {gid}: {error}"))?;
crate::ensure_aria2_gid_result("unpause", gid, &resumed)
}
async fn pause_uri(&self, gid: &str) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await
.map_err(|error| format!("failed to pause aria2 gid {gid}: {error}"))?;
crate::ensure_aria2_gid_result("forcePause", gid, &result)
}
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let mut cancel_rx = state
+10 -1
View File
@@ -97,7 +97,7 @@ pub fn is_transient_network_error(message: &str) -> bool {
let m = message.to_ascii_lowercase();
const TRANSIENT: [&str; 35] = [
const TRANSIENT: [&str; 38] = [
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
"timed out",
"timeout",
@@ -113,6 +113,9 @@ pub fn is_transient_network_error(message: &str) -> bool {
"connection aborted",
"error sending request", // reqwest wrapper for connect/send failures
"dns error", // transient resolver failures
"protocol error", // aria2 read/protocol failures after a link drop
"tls handshake failure",
"ssl/tls handshake failure",
// HTTP-level transient
"http 408",
"request timeout",
@@ -275,6 +278,12 @@ mod tests {
assert!(is_transient_network_error("The response status is not successful. status=503"));
assert!(is_transient_network_error("The response status is not successful. status=502"));
assert!(is_transient_network_error("Invalid range header. Request: 106954752-361758719/383882118, Response: 106954752-383882117/383882118"));
assert!(is_transient_network_error(
"aria2 error code 1: Failed to receive data, cause: protocol error"
));
assert!(is_transient_network_error(
"SSL/TLS handshake failure: protocol error"
));
}
// --- transient classification: negative cases -------------------------
+4 -2
View File
@@ -534,7 +534,9 @@ async fn transient_aria2_error_reissues_after_backoff() {
manager
.handle_aria2_event(
"gid-1",
PendingOutcome::Error("Timeout.".to_string()),
PendingOutcome::Error(
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
),
)
.await;
@@ -552,7 +554,7 @@ async fn transient_aria2_error_reissues_after_backoff() {
manager
.handle_aria2_event(
"gid-2",
PendingOutcome::Error("Timeout.".to_string()),
PendingOutcome::Error("SSL/TLS handshake failure: protocol error".to_string()),
)
.await;
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, 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, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, 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, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
+1 -3
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { attachLogger, setLogPaused, initLogger } from '../utils/logger';
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
import { WindowDragRegion } from './WindowDragRegion';
@@ -162,8 +161,7 @@ export default function LogsView() {
filters: [{ name: 'Log Files', extensions: ['log'] }],
});
if (!path) return;
const logsContent = await invoke('export_logs', {});
await writeTextFile(path, logsContent);
await invoke('export_logs', { destination: path });
addToast({ message: 'Support logs exported', variant: 'success' });
} catch (e) {
console.error('Export failed:', e);
+9 -1
View File
@@ -13,6 +13,14 @@ import {
type LoginMode = 'matching' | 'custom' | 'none';
const formatLastTry = (value?: string): string => {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime())
? '-'
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
};
export const PropertiesModal = () => {
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
@@ -204,7 +212,7 @@ export const PropertiesModal = () => {
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
+1 -1
View File
@@ -71,7 +71,7 @@ type CommandMap = {
args: { baseFolder: string; subfolders: Record<string, string> };
result: void;
};
export_logs: { args: Record<string, never>; result: string };
export_logs: { args: { destination?: string }; result: string };
read_logs: { args: { limit: number }; result: string[] };
clear_logs: { args: undefined; result: void };
toggle_log_pause: { args: { pause: boolean }; result: void };
+4 -1
View File
@@ -89,7 +89,10 @@ const startDownloadListeners = async () => {
const updates: Partial<DownloadItem> = {
status,
...(progress ? { fraction: progress.fraction } : {}),
...(payload.error ? { lastError: payload.error } : {})
...(payload.error ? { lastError: payload.error } : {}),
...((status === 'downloading' || status === 'retrying')
? { lastTry: new Date().toISOString() }
: {})
};
if (!payload.error && status !== 'failed' && status !== 'retrying') {
updates.lastError = undefined;
+59 -6
View File
@@ -347,26 +347,79 @@ describe('useDownloadStore', () => {
});
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
let enqueueGeneration: string | undefined;
useDownloadStore.setState({
downloads: [
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
{ id: 'resume-generation', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
] as any[],
backendRegisteredIds: new Set(['1']),
backendRegisteredIds: new Set(['resume-generation']),
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string, args?: unknown) => {
if (cmd === 'resume_download') return false; // Not resumable
if (cmd === 'get_pending_order') return ['1'];
if (cmd === 'enqueue_download') {
enqueueGeneration = (args as { item: { lifecycle_generation: string } }).item.lifecycle_generation;
return { id: 'resume-generation', filename: 'f1' };
}
if (cmd === 'get_pending_order') return ['resume-generation'];
return undefined;
});
await useDownloadStore.getState().resumeDownload('1');
await useDownloadStore.getState().resumeDownload('resume-generation');
// It should have called resume_download, then unregistered, then enqueue_download
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
expect(enqueueGeneration).toBe('1');
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
});
it('does not re-enqueue when the existing resume RPC fails', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'resume-rpc-error', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
] as any[],
backendRegisteredIds: new Set(['resume-rpc-error']),
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'resume_download') throw new Error('aria2 RPC unavailable');
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload('resume-rpc-error')).resolves.toBe(false);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'enqueue_download')
).toBe(false);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
lastTry: expect.any(String),
});
});
it('cleans an accepted backend enqueue when queue reconciliation fails', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'enqueue-reconcile-error', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'enqueue_download') return { id: 'enqueue-reconcile-error', filename: 'f1' };
if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
return undefined;
});
await expect(useDownloadStore.getState().startQueue('MAIN')).resolves.toEqual([]);
expect(
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'remove_download')
).toBe(true);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'failed',
lastError: 'queue state unavailable',
});
});
+15 -2
View File
@@ -90,6 +90,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
const promise = (async () => {
let lifecycleGeneration: bigint | null = null;
let backendAccepted = false;
try {
const state = useDownloadStore.getState();
const item = state.downloads.find(d => d.id === id);
@@ -139,7 +140,11 @@ export async function dispatchItem(id: string): Promise<boolean> {
lifecycle_generation: lifecycleGeneration.toString(),
};
useDownloadStore.getState().updateDownload(id, {
lastTry: new Date().toISOString()
});
const accepted = await invoke('enqueue_download', { item: enqueueItem });
backendAccepted = true;
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
await removeStaleBackendDispatch(id);
return false;
@@ -164,6 +169,9 @@ export async function dispatchItem(id: string): Promise<boolean> {
return true;
} catch (e) {
console.error(`Failed to dispatch ${id}:`, e);
if (backendAccepted && lifecycleGeneration !== null) {
await removeStaleBackendDispatch(id);
}
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
useDownloadStore.getState().updateDownload(id, {
status: 'failed',
@@ -783,14 +791,19 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
status: 'queued',
speed: '-',
eta: '-',
queuePosition: maxPos + 1
queuePosition: maxPos + 1,
lastTry: new Date().toISOString()
});
const resumedExisting = await invoke('resume_download', { id }).catch(() => false);
const resumedExisting = await invoke('resume_download', { id });
let dispatchSucceeded = resumedExisting;
if (!dispatchSucceeded) {
get().unregisterBackendIds([id]);
// A terminal aria2 gid is intentionally re-enqueued as a new
// lifecycle. Advance and cancel the old generation before dispatching
// so QueueManager does not reject the legitimate user retry as stale.
await invalidateAndWaitForDispatch(id);
dispatchSucceeded = await dispatchItem(id);
}