fix(properties): harden resume lifecycle

This commit is contained in:
NimBold
2026-08-04 19:37:25 +03:30
parent 3905b3ed89
commit 135ba75a69
7 changed files with 318 additions and 24 deletions
+131 -8
View File
@@ -4873,6 +4873,9 @@ async fn resume_download(
)
.await;
if !parked {
queue_manager
.release_aria2_permit_candidate(&id_clone, lifecycle_generation)
.await;
log::warn!(
"aria2 resume [{}]: permit ownership was not established before unpause; leaving gid {} paused",
id_clone,
@@ -4881,13 +4884,6 @@ async fn resume_download(
return;
}
}
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
&id_clone,
crate::ipc::DownloadStatus::Downloading,
),
);
let unpause_error = match rpc_call(
aria2_port,
&aria2_secret,
@@ -4902,8 +4898,31 @@ async fn resume_download(
Err(error) => Some(format!("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 {
match verify_aria2_resume_status(aria2_port, &aria2_secret, &gid_clone).await {
Ok(status) if matches!(status.as_str(), "active" | "waiting") => {
let still_current = queue_manager
.is_aria2_control_epoch_current(&id_clone, control_epoch)
.await
&& queue_manager.aria2_gid_for_download(&id_clone).as_deref()
== Some(gid_clone.as_str());
if !still_current {
let _ = rpc_call(
aria2_port,
&aria2_secret,
"aria2.forcePause",
serde_json::json!([gid_clone]),
)
.await;
return;
}
use tauri::Emitter;
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
&id_clone,
crate::ipc::DownloadStatus::Downloading,
),
);
log::warn!(
"aria2 resume [{}]: {} but daemon reports gid {} as {}; retaining permit",
id_clone,
@@ -4985,6 +5004,79 @@ async fn resume_download(
}
}
}
// A successful unpause RPC is not itself the postcondition:
// aria2 may still report the GID as paused, complete, or
// otherwise unavailable. Verify the daemon state before
// publishing Downloading to the renderer.
let status_after_unpause = match verify_aria2_resume_status(
aria2_port,
&aria2_secret,
&gid_clone,
)
.await
{
Ok(status) => status,
Err(error) => {
log::error!(
"aria2 resume [{}]: unpause succeeded but gid {} could not be verified: {}; retaining permit",
id_clone,
gid_clone,
error
);
return;
}
};
match status_after_unpause.as_str() {
"active" | "waiting" => {}
"complete" => {
queue_manager
.apply_completion_locked(
&id_clone,
crate::queue::PendingOutcome::Complete,
)
.await;
return;
}
"error" | "removed" => {
let terminal_error = format!(
"aria2 resume left gid {gid_clone} in terminal state {status_after_unpause}"
);
queue_manager
.apply_completion_locked(
&id_clone,
crate::queue::PendingOutcome::Error(terminal_error),
)
.await;
return;
}
"paused" => {
queue_manager.next_aria2_control_epoch(&id_clone).await;
queue_manager.cancel_aria2_retries(&id_clone).await;
queue_manager.release_permit(&id_clone).await;
let error = "aria2 kept the download paused after resume".to_string();
log::error!(
"aria2 resume [{}]: {}; gid {} remains paused",
id_clone,
error,
gid_clone
);
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::paused_with_error(&id_clone, error),
);
return;
}
other => {
log::error!(
"aria2 resume [{}]: unpause left gid {} in unexpected state {}; retaining permit",
id_clone,
gid_clone,
other
);
return;
}
}
let current_epoch = queue_manager
.is_aria2_control_epoch_current(&id_clone, control_epoch)
.await;
@@ -5002,6 +5094,14 @@ async fn resume_download(
.await;
return;
}
use tauri::Emitter;
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
&id_clone,
crate::ipc::DownloadStatus::Downloading,
),
);
log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone);
});
Ok(true)
@@ -5067,6 +5167,9 @@ async fn resume_download(
)
.await;
if !parked && !queue_manager.has_active_permit(&id_clone).await {
queue_manager
.release_aria2_permit_candidate(&id_clone, lifecycle_generation)
.await;
return;
}
}
@@ -5521,6 +5624,26 @@ 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}"))
}
/// Verify a retained-GID resume against Aria2's actual state. `unpause` can
/// return before a paused GID becomes observable as active, and a transient
/// tellStatus failure must not turn a successful resume into a false failure.
/// Retry only that ambiguous observation; terminal and active states return
/// immediately and remain authoritative.
async fn verify_aria2_resume_status(port: u16, secret: &str, gid: &str) -> Result<String, String> {
let mut last_observation = Err(format!("aria2 resume status for gid {gid} was not observed"));
for attempt in 0..4u32 {
last_observation = match aria2_download_status(port, secret, gid).await {
Ok(status) if status != "paused" => return Ok(status),
Ok(status) => Ok(status),
Err(error) => Err(error),
};
if attempt < 3 {
tokio::time::sleep(Duration::from_millis(25 * (1_u64 << attempt))).await;
}
}
last_observation
}
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);
+13 -1
View File
@@ -3421,9 +3421,15 @@ impl<R: tauri::Runtime> QueueManager<R> {
if let Some(epoch) = aria2_lifecycle_epoch {
self.begin_aria2_dispatch(&id, epoch).await;
}
self.emit_state(&id, DownloadStatus::Downloading);
drop(control_guard);
// Media runners do not receive an Aria2 GID. Their permit is already
// active at this point, so publish their live state before spawning
// the runner; Aria2 tasks publish only after remember_gid below.
if matches!(&task.kind, TaskKind::Media) {
self.emit_state(&id, DownloadStatus::Downloading);
}
match task.kind {
TaskKind::Aria2 => {
let lifecycle_epoch = aria2_lifecycle_epoch
@@ -3477,7 +3483,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
return;
}
// A queued task is not a live transfer until aria2 has
// accepted it and Firelink has installed the GID
// mapping. Emitting Downloading before this point
// lets the UI (and a concurrent Properties pause)
// act on a lifecycle that does not yet exist.
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
self.emit_state(&id, DownloadStatus::Downloading);
let install_web_seeds = buffered_outcome.is_none()
&& task.payload.is_torrent
&& !task.payload.torrent_verify_only
+57
View File
@@ -1782,6 +1782,7 @@ async fn media_terminal_error_emits_failed_without_completed() {
tokio::time::sleep(Duration::from_millis(100)).await;
let statuses = emitted_statuses(&event_rx);
assert!(statuses.iter().any(|status| status == "downloading"));
assert!(statuses.iter().any(|status| status == "failed"));
assert!(!statuses.iter().any(|status| status == "completed"));
assert_eq!(manager.available_permits(), 1);
@@ -1841,6 +1842,62 @@ async fn aria2_permit_survives_rpc_return() {
handle.abort();
}
#[tokio::test]
async fn aria2_does_not_emit_downloading_before_gid_mapping() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let (gid_started_tx, gid_started_rx) = tokio::sync::oneshot::channel();
let spawner = Arc::new(DelayedAria2Spawner::new(gid_started_tx));
let (event_tx, event_rx) = std::sync::mpsc::channel();
app.handle().listen("download-state", move |event| {
let _ = event_tx.send(event.payload().to_string());
});
let manager = Arc::new(QueueManager::test_new(app.handle().clone(), 1, spawner));
manager.push(aria2_task("delayed-start")).await.unwrap();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
gid_started_rx
.await
.expect("add_uri should begin before the delayed GID is returned");
let early_statuses = emitted_statuses(&event_rx);
assert!(
!early_statuses.iter().any(|status| status == "downloading"),
"a queued task must not be reported as downloading before its GID is mapped"
);
timeout(Duration::from_secs(1), async {
loop {
if manager.aria2_gid_for_download("delayed-start").is_some() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the delayed GID should eventually be mapped");
timeout(Duration::from_secs(1), async {
loop {
if emitted_statuses(&event_rx)
.iter()
.any(|status| status == "downloading")
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the transfer should become downloading only after its GID is owned");
manager.release_permit("delayed-start").await;
dispatcher.abort();
}
#[tokio::test]
async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_mapping() {
let app = mock_builder()