fix(downloads): harden aria2 resume lifecycle

This commit is contained in:
NimBold
2026-07-13 04:40:39 +03:30
parent df85a77987
commit c78a72a8d7
3 changed files with 127 additions and 18 deletions
+73 -18
View File
@@ -3399,7 +3399,7 @@ async fn resume_download(
drop(control_guard);
tauri::async_runtime::spawn(async move {
let acquired = queue_manager.ensure_aria2_permit(&id_clone).await;
if !acquired {
if !acquired && !queue_manager.has_active_permit(&id_clone).await {
return;
}
let _control_guard = queue_manager.acquire_aria2_control(&id_clone).await;
@@ -3414,6 +3414,18 @@ async fn resume_download(
queue_manager.release_permit(&id_clone).await;
return;
}
if !queue_manager
.rebind_aria2_gid_epoch(&id_clone, &gid_clone, control_epoch)
.await
{
log::warn!(
"aria2 resume [{}]: gid {} disappeared before lifecycle rebind",
id_clone,
gid_clone
);
queue_manager.release_permit(&id_clone).await;
return;
}
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
@@ -3421,7 +3433,7 @@ async fn resume_download(
crate::ipc::DownloadStatus::Downloading,
),
);
let result = match rpc_call(
let unpause_error = match rpc_call(
aria2_port,
&aria2_secret,
"aria2.unpause",
@@ -3429,23 +3441,66 @@ async fn resume_download(
)
.await
{
Ok(result) => result,
Err(error) => {
queue_manager.release_permit(&id_clone).await;
log::error!("failed to resume aria2 gid {}: {}", gid_clone, error);
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
&id_clone,
crate::ipc::DownloadStatus::Failed,
),
);
return;
}
Ok(result) => ensure_aria2_gid_result("unpause", &gid_clone, &result)
.err()
.map(|error| error.to_string()),
Err(error) => Some(format!("failed to resume aria2 gid {gid_clone}: {error}")),
};
if let Err(error) = ensure_aria2_gid_result("unpause", &gid_clone, &result) {
queue_manager.release_permit(&id_clone).await;
log::error!("failed to resume aria2 gid {}: {}", gid_clone, error);
if let Some(unpause_error) = unpause_error {
match aria2_download_status(aria2_port, &aria2_secret, &gid_clone).await {
Ok(status) if matches!(status.as_str(), "active" | "waiting") => {
log::warn!(
"aria2 resume [{}]: {} but daemon reports gid {} as {}; retaining permit",
id_clone,
unpause_error,
gid_clone,
status
);
return;
}
Ok(status) if status == "complete" => {
log::info!(
"aria2 resume [{}]: {} but daemon reports gid {} complete; reconciling completion",
id_clone,
unpause_error,
gid_clone
);
queue_manager
.apply_completion_locked(
&id_clone,
crate::queue::PendingOutcome::Complete,
)
.await;
return;
}
Ok(status) => {
queue_manager.release_permit(&id_clone).await;
log::error!(
"aria2 resume [{}]: {}; daemon reports gid {} as {}",
id_clone,
unpause_error,
gid_clone,
status
);
}
Err(status_error) => {
log::error!(
"aria2 resume [{}]: {}; could not verify gid {} after the RPC failure: {}; retaining permit",
id_clone,
unpause_error,
gid_clone,
status_error
);
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
&id_clone,
crate::ipc::DownloadStatus::Failed,
),
);
return;
}
}
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
+20
View File
@@ -498,6 +498,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
}
pub async fn has_active_permit(&self, id: &str) -> bool {
self.active_permits.lock().await.contains_key(id)
}
/// Clear all permits belonging to aria2. Useful when aria2 WS connection drops.
pub async fn clear_aria2_permits(&self) {
let ids_to_fail: Vec<String> = {
@@ -768,6 +772,22 @@ impl<R: tauri::Runtime> QueueManager<R> {
buffered_outcome
}
/// Rebind an existing paused GID to the new lifecycle created by resume.
/// The GID remains stable across aria2.pause/unpause, but its previous
/// epoch must not be reused after a pause invalidated that lifecycle.
pub async fn rebind_aria2_gid_epoch(&self, id: &str, gid: &str, epoch: u64) -> bool {
let _gid_state = self.aria2_gid_state.lock().await;
let mut gids = self.aria2_gids.write().unwrap();
let Some(mapping) = gids.get_mut(gid) else {
return false;
};
if mapping.id != id {
return false;
}
mapping.epoch = epoch;
true
}
/// Apply an aria2 completion outcome: release permit + emit state.
pub async fn apply_completion(&self, id: &str, outcome: PendingOutcome) {
let _control_guard = self.acquire_aria2_control(id).await;
+34
View File
@@ -273,6 +273,40 @@ async fn stale_terminal_event_cannot_complete_a_newer_control_epoch() {
manager.release_registered_id("stale-event").await;
}
#[tokio::test]
async fn resumed_gid_rebinds_to_the_new_control_epoch() {
use firelink_lib::queue::PendingOutcome;
let (mgr, _spawner) = make_manager(1);
let manager = Arc::new(mgr);
manager.push(aria2_task("resumed-gid")).await.unwrap();
let permit = manager.acquire_permit().await.expect("permit");
manager.park_permit("resumed-gid", permit).await;
manager
.remember_gid("resumed-gid".to_string(), "gid-resumed".to_string())
.await;
let resume_epoch = manager.next_aria2_control_epoch("resumed-gid").await;
manager
.handle_aria2_event("gid-resumed", PendingOutcome::Complete)
.await;
assert_eq!(
manager.available_permits(),
0,
"the old GID epoch must not complete the resumed lifecycle"
);
assert!(manager
.rebind_aria2_gid_epoch("resumed-gid", "gid-resumed", resume_epoch)
.await);
manager
.handle_aria2_event("gid-resumed", PendingOutcome::Complete)
.await;
assert_eq!(manager.available_permits(), 1);
assert!(manager.aria2_gid_for_download("resumed-gid").is_none());
}
#[tokio::test]
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
let (mgr, _spawner) = make_manager(1);