diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c9e292..b78de10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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( diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 88f09e5..f0466e9 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -498,6 +498,10 @@ impl QueueManager { } } + 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 = { @@ -768,6 +772,22 @@ impl QueueManager { 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; diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index 406d020..6c2eb40 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -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);