mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(downloads): manage aria2 lifecycle correctly
This commit is contained in:
+237
-25
@@ -1396,28 +1396,60 @@ async fn pause_download(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
println!("pause_download called for id: {}", id);
|
||||
log::info!("pause_download called for id: {}", id);
|
||||
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
state.queue_manager.remove_from_pending(&id).await;
|
||||
|
||||
// Emit the paused state.
|
||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
|
||||
let status = aria2_download_status(
|
||||
state.aria2_port,
|
||||
&state.aria2_secret,
|
||||
gid,
|
||||
)
|
||||
.await?;
|
||||
match status.as_str() {
|
||||
"paused" => {
|
||||
log::info!("aria2 pause [{}]: gid {} was already paused", id, gid);
|
||||
}
|
||||
"active" | "waiting" => {
|
||||
let result = rpc_call(
|
||||
state.aria2_port,
|
||||
&state.aria2_secret,
|
||||
"aria2.forcePause",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("failed to pause aria2 gid {gid}: {error}"))?;
|
||||
ensure_aria2_gid_result("forcePause", gid, &result)?;
|
||||
log::info!("aria2 pause [{}]: gid {} paused", id, gid);
|
||||
}
|
||||
terminal => {
|
||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||
state.queue_manager.forget_aria2_gid(&id).await;
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
return Err(format!(
|
||||
"cannot pause aria2 gid {gid} in terminal state {terminal}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
|
||||
);
|
||||
|
||||
let gid = state.queue_manager.aria2_gids.read().unwrap()
|
||||
.iter()
|
||||
.find(|(_, v)| **v == id)
|
||||
.map(|(k, _)| k.clone());
|
||||
if let Some(g) = gid {
|
||||
if !g.starts_with("native:") {
|
||||
let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.pause", serde_json::json!([g])).await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
let _ = state
|
||||
.download_coordinator
|
||||
@@ -1431,6 +1463,84 @@ async fn pause_download(
|
||||
media_result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn resume_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else {
|
||||
log::info!("aria2 resume [{}]: no mapped gid; re-enqueue is permitted", id);
|
||||
return Ok(false);
|
||||
};
|
||||
if gid.starts_with("native:") {
|
||||
state.queue_manager.forget_aria2_gid(&id).await;
|
||||
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let status = aria2_download_status(state.aria2_port, &state.aria2_secret, &gid).await?;
|
||||
match status.as_str() {
|
||||
"paused" => {
|
||||
let acquired = state.queue_manager.ensure_aria2_permit(&id).await;
|
||||
let result = match rpc_call(
|
||||
state.aria2_port,
|
||||
&state.aria2_secret,
|
||||
"aria2.unpause",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
if acquired {
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
}
|
||||
return Err(format!("failed to resume aria2 gid {gid}: {error}"));
|
||||
}
|
||||
};
|
||||
if let Err(error) = ensure_aria2_gid_result("unpause", &gid, &result) {
|
||||
if acquired {
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
log::info!("aria2 resume [{}]: unpaused gid {}", id, gid);
|
||||
}
|
||||
"active" | "waiting" => {
|
||||
state.queue_manager.ensure_aria2_permit(&id).await;
|
||||
log::info!(
|
||||
"aria2 resume [{}]: gid {} already {}; no duplicate job created",
|
||||
id,
|
||||
gid,
|
||||
status
|
||||
);
|
||||
}
|
||||
"complete" | "error" | "removed" => {
|
||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||
state.queue_manager.forget_aria2_gid(&id).await;
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
log::info!(
|
||||
"aria2 resume [{}]: gid {} is {}; re-enqueue is permitted",
|
||||
id,
|
||||
gid,
|
||||
status
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
other => {
|
||||
return Err(format!("aria2 gid {gid} returned unknown status {other}"));
|
||||
}
|
||||
}
|
||||
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Downloading),
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_download(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1438,28 +1548,50 @@ async fn remove_download(
|
||||
id: String,
|
||||
filepath: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
println!("remove_download called for id: {}", id);
|
||||
log::info!("remove_download called for id: {}", id);
|
||||
|
||||
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||
state.queue_manager.remove_from_pending(&id).await;
|
||||
|
||||
state.queue_manager.cancel_aria2_retries(&id).await;
|
||||
let retry_add_guard = state.queue_manager.lock_aria2_retry_add().await;
|
||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
|
||||
let removal_result = async {
|
||||
force_remove_aria2_gid(state.aria2_port, &state.aria2_secret, gid).await?;
|
||||
wait_for_aria2_stopped(state.aria2_port, &state.aria2_secret, gid).await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = removal_result {
|
||||
state.queue_manager.allow_aria2_retries(&id).await;
|
||||
return Err(error);
|
||||
}
|
||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||
state.queue_manager.forget_aria2_gid(&id).await;
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
log::info!("aria2 remove [{}]: gid {} stopped and forgotten", id, gid);
|
||||
} else {
|
||||
drop(retry_add_guard);
|
||||
if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::Cancel(download_id))
|
||||
.await?;
|
||||
}
|
||||
state.download_coordinator.pause_media(id.clone()).await?;
|
||||
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
}
|
||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||
state.queue_manager.forget_aria2_gid(&id).await;
|
||||
}
|
||||
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
|
||||
);
|
||||
|
||||
if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::Cancel(download_id))
|
||||
.await?;
|
||||
}
|
||||
state.download_coordinator.pause_media(id.clone()).await?;
|
||||
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
}
|
||||
|
||||
if let Some(path) = filepath {
|
||||
if !path.is_empty() {
|
||||
let p = std::path::Path::new(&path);
|
||||
@@ -1479,6 +1611,86 @@ async fn remove_download(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_aria2_gid_result(
|
||||
method: &str,
|
||||
expected_gid: &str,
|
||||
result: &serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
match result.as_str() {
|
||||
Some(returned_gid) if returned_gid == expected_gid => Ok(()),
|
||||
Some(returned_gid) => Err(format!(
|
||||
"aria2.{method} returned unexpected gid {returned_gid}, expected {expected_gid}"
|
||||
)),
|
||||
None => Err(format!("aria2.{method} returned a non-string result")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn aria2_download_status(port: u16, secret: &str, gid: &str) -> Result<String, String> {
|
||||
let result = rpc_call(
|
||||
port,
|
||||
secret,
|
||||
"aria2.tellStatus",
|
||||
serde_json::json!([gid, ["status"]]),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("failed to query aria2 gid {gid}: {error}"))?;
|
||||
result
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}"))
|
||||
}
|
||||
|
||||
fn aria2_gid_not_found(error: &str) -> bool {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
lower.contains("gid") && lower.contains("not found")
|
||||
}
|
||||
|
||||
async fn force_remove_aria2_gid(port: u16, secret: &str, gid: &str) -> Result<(), String> {
|
||||
match rpc_call(
|
||||
port,
|
||||
secret,
|
||||
"aria2.forceRemove",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => ensure_aria2_gid_result("forceRemove", gid, &result),
|
||||
Err(error) if aria2_gid_not_found(&error) => {
|
||||
log::info!("aria2 forceRemove: gid {} was already absent", gid);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => match aria2_download_status(port, secret, gid).await {
|
||||
Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => {
|
||||
log::info!(
|
||||
"aria2 forceRemove: gid {} raced to terminal state {}",
|
||||
gid,
|
||||
status
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("failed to remove aria2 gid {gid}: {error}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<(), String> {
|
||||
for _ in 0..30 {
|
||||
match aria2_download_status(port, secret, gid).await {
|
||||
Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => {
|
||||
return Ok(());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if aria2_gid_not_found(&error) => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(format!(
|
||||
"aria2 gid {gid} did not stop within 3 seconds after forceRemove"
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -2279,7 +2491,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder,
|
||||
pause_download, fetch_metadata, fetch_media_metadata,
|
||||
pause_download, resume_download, fetch_metadata, fetch_media_metadata,
|
||||
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
||||
request_automation_permission, open_automation_settings,
|
||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||
|
||||
+182
-3
@@ -1,7 +1,7 @@
|
||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||
use crate::retry::{BackoffOutcome, MAX_RETRIES, backoff_and_emit, is_transient_network_error};
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -72,6 +72,10 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
/// permit is already parked before this is called).
|
||||
async fn add_uri(&self, id: &str, payload: &SpawnPayload) -> Result<String, String>;
|
||||
|
||||
/// Force-remove an aria2 gid created by a retry that raced with user
|
||||
/// cancellation.
|
||||
async fn remove_uri(&self, gid: &str) -> Result<(), 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>;
|
||||
@@ -103,6 +107,13 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
/// 0-based transient-error strike counter per aria2 download id.
|
||||
aria2_retry_strikes: Mutex<HashMap<String, usize>>,
|
||||
|
||||
/// Download ids whose aria2 retry loop must not create another job.
|
||||
aria2_retry_cancelled: Mutex<HashSet<String>>,
|
||||
|
||||
/// Serializes retry addUri with remove so a late retry cannot escape
|
||||
/// cancellation and continue writing after deletion.
|
||||
aria2_retry_add_lock: Mutex<()>,
|
||||
|
||||
spawner: Arc<dyn SidecarSpawner>,
|
||||
app_handle: AppHandle<R>,
|
||||
}
|
||||
@@ -136,6 +147,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||
aria2_payloads: Mutex::new(HashMap::new()),
|
||||
aria2_retry_strikes: Mutex::new(HashMap::new()),
|
||||
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
||||
aria2_retry_add_lock: Mutex::new(()),
|
||||
spawner,
|
||||
app_handle,
|
||||
}
|
||||
@@ -185,6 +198,30 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
self.active_kinds.lock().await.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Ensure an aria2 transfer owns exactly one queue permit. Returns true
|
||||
/// when this call acquired and parked the permit, false when one was
|
||||
/// already parked.
|
||||
pub async fn ensure_aria2_permit(&self, id: &str) -> bool {
|
||||
if self.active_permits.lock().await.contains_key(id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let permit = self.acquire_permit().await;
|
||||
let mut permits = self.active_permits.lock().await;
|
||||
if permits.contains_key(id) {
|
||||
drop(permits);
|
||||
drop(permit);
|
||||
return false;
|
||||
}
|
||||
permits.insert(id.to_string(), permit);
|
||||
drop(permits);
|
||||
self.active_kinds
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.to_string(), TaskKind::Aria2);
|
||||
true
|
||||
}
|
||||
|
||||
/// Release the permit parked under `id`, if any. Idempotent. Wakes the
|
||||
/// dispatcher so a freed slot is claimed promptly.
|
||||
pub async fn release_permit(&self, id: &str) {
|
||||
@@ -311,6 +348,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
|
||||
match task.kind {
|
||||
TaskKind::Aria2 => {
|
||||
self.aria2_retry_cancelled.lock().await.remove(&id);
|
||||
self.aria2_payloads
|
||||
.lock()
|
||||
.await
|
||||
@@ -378,8 +416,21 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
pub async fn remember_gid(&self, id: String, gid: String) {
|
||||
{
|
||||
let mut gids = self.aria2_gids.write().unwrap();
|
||||
gids.retain(|existing_gid, existing_id| {
|
||||
let keep = existing_id != &id || existing_gid == &gid;
|
||||
if !keep {
|
||||
log::warn!(
|
||||
"aria2 gid transition [{}]: dropping stale mapping {} before storing {}",
|
||||
id,
|
||||
existing_gid,
|
||||
gid
|
||||
);
|
||||
}
|
||||
keep
|
||||
});
|
||||
gids.insert(gid.clone(), id.clone());
|
||||
}
|
||||
log::info!("aria2 gid transition [{}]: mapped {}", id, gid);
|
||||
let buffered = self.pending_completion.lock().await.remove(&gid);
|
||||
if let Some((_buf_id, outcome)) = buffered {
|
||||
self.apply_completion(&id, outcome).await;
|
||||
@@ -391,27 +442,86 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
match outcome {
|
||||
PendingOutcome::Complete => {
|
||||
self.clear_aria2_retry_state(id).await;
|
||||
self.forget_aria2_gid(id).await;
|
||||
self.emit_state(id, DownloadStatus::Completed);
|
||||
}
|
||||
PendingOutcome::Error(error) => {
|
||||
self.clear_aria2_retry_state(id).await;
|
||||
self.forget_aria2_gid(id).await;
|
||||
self.emit_failed(id, error);
|
||||
}
|
||||
}
|
||||
self.release_permit(id).await;
|
||||
}
|
||||
|
||||
async fn clear_aria2_retry_state(&self, id: &str) {
|
||||
pub async fn clear_aria2_retry_state(&self, id: &str) {
|
||||
self.aria2_payloads.lock().await.remove(id);
|
||||
self.aria2_retry_strikes.lock().await.remove(id);
|
||||
}
|
||||
|
||||
pub async fn cancel_aria2_retries(&self, id: &str) {
|
||||
self.aria2_retry_cancelled
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.to_string());
|
||||
}
|
||||
|
||||
pub async fn allow_aria2_retries(&self, id: &str) {
|
||||
self.aria2_retry_cancelled.lock().await.remove(id);
|
||||
}
|
||||
|
||||
pub async fn lock_aria2_retry_add(&self) -> tokio::sync::MutexGuard<'_, ()> {
|
||||
self.aria2_retry_add_lock.lock().await
|
||||
}
|
||||
|
||||
pub fn aria2_gid_for_download(&self, id: &str) -> Option<String> {
|
||||
self.aria2_gids
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|(gid, download_id)| (download_id == id).then(|| gid.clone()))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let removed = {
|
||||
let mut gids = self.aria2_gids.write().unwrap();
|
||||
let removed: Vec<String> = gids
|
||||
.iter()
|
||||
.filter(|(_, download_id)| *download_id == id)
|
||||
.map(|(gid, _)| gid.clone())
|
||||
.collect();
|
||||
for gid in &removed {
|
||||
gids.remove(gid);
|
||||
}
|
||||
removed
|
||||
};
|
||||
|
||||
if removed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut buffered = self.pending_completion.lock().await;
|
||||
for gid in &removed {
|
||||
buffered.remove(gid);
|
||||
log::info!("aria2 gid transition [{}]: forgot {}", id, gid);
|
||||
}
|
||||
removed.last().cloned()
|
||||
}
|
||||
|
||||
/// Overwrite a stale aria2 gid with the fresh gid minted by a retry
|
||||
/// `addUri`. Failing to call this after re-add leaks the semaphore permit.
|
||||
pub fn rotate_aria2_gid(&self, id: &str, stale_gid: &str, new_gid: &str) {
|
||||
let mut gids = self.aria2_gids.write().unwrap();
|
||||
gids.remove(stale_gid);
|
||||
gids.insert(new_gid.to_string(), id.to_string());
|
||||
log::info!(
|
||||
"aria2 gid transition [{}]: rotated {} -> {}",
|
||||
id,
|
||||
stale_gid,
|
||||
new_gid
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_permit_released(self: &Arc<Self>, id: &str) {
|
||||
@@ -441,6 +551,14 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self.aria2_retry_cancelled.lock().await.contains(&id) {
|
||||
log::info!(
|
||||
"aria2 retry cancellation [{}]: ignoring error for gid {} during removal",
|
||||
id,
|
||||
gid
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let strike = {
|
||||
let mut strikes = self.aria2_retry_strikes.lock().await;
|
||||
@@ -485,12 +603,50 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
return;
|
||||
}
|
||||
|
||||
let _retry_add_guard = this.aria2_retry_add_lock.lock().await;
|
||||
if !this.active_permits.lock().await.contains_key(&id_for_task) {
|
||||
return;
|
||||
}
|
||||
if this
|
||||
.aria2_retry_cancelled
|
||||
.lock()
|
||||
.await
|
||||
.contains(&id_for_task)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
match this.spawner.add_uri(&id_for_task, &payload).await {
|
||||
Ok(new_gid) => {
|
||||
if this
|
||||
.aria2_retry_cancelled
|
||||
.lock()
|
||||
.await
|
||||
.contains(&id_for_task)
|
||||
{
|
||||
if let Err(error) = this.spawner.remove_uri(&new_gid).await {
|
||||
log::error!(
|
||||
"aria2 retry cancellation [{}]: failed to remove late gid {}: {}",
|
||||
id_for_task,
|
||||
new_gid,
|
||||
error
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"aria2 retry cancellation [{}]: removed late gid {}",
|
||||
id_for_task,
|
||||
new_gid
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.rotate_aria2_gid(&id_for_task, &stale_gid, &new_gid);
|
||||
log::warn!(
|
||||
"aria2 retry cancellation [{}]: retained late gid {} mapping for remove retry",
|
||||
id_for_task,
|
||||
new_gid
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.aria2_retry_strikes
|
||||
.lock()
|
||||
.await
|
||||
@@ -670,7 +826,12 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
match crate::rpc_call(state.aria2_port, &state.aria2_secret, "aria2.addUri", params).await {
|
||||
Ok(result) => {
|
||||
let gid = result.as_str().unwrap_or("").to_string();
|
||||
Ok(gid)
|
||||
if gid.is_empty() {
|
||||
Err("aria2.addUri returned an empty gid".to_string())
|
||||
} else {
|
||||
log::info!("aria2 addUri [{}]: created gid {}", id, gid);
|
||||
Ok(gid)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// aria2 unavailable — fall back to native coordinator.
|
||||
@@ -706,6 +867,24 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, gid: &str) -> Result<(), String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let result = crate::rpc_call(
|
||||
state.aria2_port,
|
||||
&state.aria2_secret,
|
||||
"aria2.forceRemove",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await?;
|
||||
match result.as_str() {
|
||||
Some(returned_gid) if returned_gid == gid => Ok(()),
|
||||
Some(returned_gid) => Err(format!(
|
||||
"aria2.forceRemove returned unexpected gid {returned_gid}, expected {gid}"
|
||||
)),
|
||||
None => Err("aria2.forceRemove returned a non-string result".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let mut cancel_rx = state
|
||||
|
||||
@@ -31,6 +31,9 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
self.add_uri_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(format!("gid-{}", self.add_uri_calls.load(Ordering::SeqCst)))
|
||||
}
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
@@ -83,6 +86,33 @@ async fn release_permit_is_idempotent() {
|
||||
assert_eq!(avail_after_second, avail_after_first, "second release must not free another slot");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_aria2_permit_does_not_double_acquire() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
assert!(mgr.ensure_aria2_permit("a").await);
|
||||
assert!(!mgr.ensure_aria2_permit("a").await);
|
||||
assert_eq!(mgr.available_permits(), 1);
|
||||
|
||||
mgr.release_permit("a").await;
|
||||
assert_eq!(mgr.available_permits(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forgetting_aria2_gid_clears_mapping_without_releasing_twice() {
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let permit = mgr.acquire_permit().await;
|
||||
mgr.park_permit("a", permit).await;
|
||||
mgr.remember_gid("a".to_string(), "gid-a".to_string()).await;
|
||||
|
||||
assert_eq!(mgr.forget_aria2_gid("a").await.as_deref(), Some("gid-a"));
|
||||
assert!(mgr.aria2_gid_for_download("a").is_none());
|
||||
assert_eq!(mgr.available_permits(), 0);
|
||||
|
||||
mgr.release_permit("a").await;
|
||||
mgr.release_permit("a").await;
|
||||
assert_eq!(mgr.available_permits(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_then_pop_front_drains_fifo() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
@@ -232,6 +262,10 @@ impl SidecarSpawner for FixedMediaSpawner {
|
||||
unreachable!("aria2 is not used by media terminal-state tests")
|
||||
}
|
||||
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
unreachable!("aria2 is not used by media terminal-state tests")
|
||||
}
|
||||
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
self.outcome.clone()
|
||||
}
|
||||
@@ -370,6 +404,21 @@ async fn gid_completion_before_store_buffers_and_reconciles() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aria2_completion_forgets_gid_and_releases_permit() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, _spawner) = make_manager(1);
|
||||
let permit = mgr.acquire_permit().await;
|
||||
mgr.park_permit("a", permit).await;
|
||||
mgr.remember_gid("a".to_string(), "gid-a".to_string()).await;
|
||||
|
||||
mgr.apply_completion("a", PendingOutcome::Complete).await;
|
||||
|
||||
assert!(mgr.aria2_gid_for_download("a").is_none());
|
||||
assert_eq!(mgr.available_permits(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn move_up_down_reorders_pending() {
|
||||
use firelink_lib::ipc::QueueDirection;
|
||||
|
||||
@@ -66,6 +66,7 @@ type CommandMap = {
|
||||
start_download: { args: StartDownloadArgs; result: void };
|
||||
start_media_download: { args: StartMediaDownloadArgs; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
resume_download: { args: { id: string }; result: boolean };
|
||||
remove_download: { args: { id: string; filepath: string | null }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||
|
||||
@@ -270,6 +270,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
await invoke('remove_download', { id, filepath: null });
|
||||
} catch (e) {
|
||||
console.error("Failed to terminate download on deletion:", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,19 +360,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
info(`Download ${id} redownload requested (queued)`);
|
||||
},
|
||||
resumeDownload: async (id) => {
|
||||
let targetItem = get().downloads.find(d => d.id === id);
|
||||
const targetItem = get().downloads.find(d => d.id === id);
|
||||
if (!targetItem) return;
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
return { ...d, status: 'queued', speed: '-', eta: '-' };
|
||||
}
|
||||
return d;
|
||||
})
|
||||
}));
|
||||
|
||||
try {
|
||||
const resumedExisting = await invoke('resume_download', { id });
|
||||
if (resumedExisting) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
return { ...d, status: 'queued', speed: '-', eta: '-' };
|
||||
}
|
||||
return d;
|
||||
})
|
||||
}));
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const login = getSiteLogin(targetItem.url, settings);
|
||||
let keychainPassword = null;
|
||||
@@ -419,18 +425,23 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (runnable.length === 0) return 0;
|
||||
|
||||
const paused = runnable.filter(item => item.status === 'paused');
|
||||
const toEnqueue = runnable.filter(item => item.status !== 'paused');
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
runnable.some(r => r.id === item.id)
|
||||
toEnqueue.some(r => r.id === item.id)
|
||||
? { ...item, status: 'queued', speed: '-', eta: '-' }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
|
||||
try {
|
||||
await Promise.all(paused.map(item => get().resumeDownload(item.id)));
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const itemsToEnqueue = [];
|
||||
for (const item of runnable) {
|
||||
for (const item of toEnqueue) {
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
@@ -466,7 +477,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
is_media: item.isMedia || false
|
||||
});
|
||||
}
|
||||
await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
if (itemsToEnqueue.length > 0) {
|
||||
await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
}
|
||||
const order = await invoke('get_pending_order');
|
||||
set({ pendingOrder: order });
|
||||
} catch (e) {
|
||||
@@ -483,18 +496,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
activeIds.includes(item.id)
|
||||
? { ...item, status: 'paused', speed: '-', eta: '-' }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
|
||||
info(`Queue ${queueId} paused, ${activeIds.length} items paused`);
|
||||
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
|
||||
const results = await Promise.allSettled(
|
||||
activeIds.map(id => invoke('pause_download', { id }))
|
||||
);
|
||||
const pausedCount = results.filter(result => result.status === 'fulfilled').length;
|
||||
const failedCount = activeIds.length - pausedCount;
|
||||
if (failedCount > 0) {
|
||||
console.error(`Failed to pause ${failedCount} downloads in queue ${queueId}`);
|
||||
}
|
||||
info(`Queue ${queueId} paused, ${pausedCount} items paused`);
|
||||
syncSystemIntegrations();
|
||||
return activeIds.length;
|
||||
return pausedCount;
|
||||
},
|
||||
addQueue: (name) => {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
Reference in New Issue
Block a user