mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-28 11:37:17 +00:00
fix(downloads): harden queue admission and live speed controls
This commit is contained in:
@@ -70,6 +70,18 @@ pub struct Queue {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_main: bool,
|
pub is_main: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub max_concurrent: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct QueueConcurrencyConfig {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_concurrent: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
|
|||||||
+112
-30
@@ -4498,7 +4498,12 @@ async fn resume_download(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
|
queue_id: String,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
|
let queue_id = queue_id.trim().to_string();
|
||||||
|
if queue_id.is_empty() {
|
||||||
|
return Err("Queue id cannot be empty".to_string());
|
||||||
|
}
|
||||||
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
let control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
||||||
let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else {
|
let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -4521,6 +4526,11 @@ async fn resume_download(
|
|||||||
match status.as_str() {
|
match status.as_str() {
|
||||||
"paused" => {
|
"paused" => {
|
||||||
let control_epoch = state.queue_manager.next_aria2_control_epoch(&id).await;
|
let control_epoch = state.queue_manager.next_aria2_control_epoch(&id).await;
|
||||||
|
let lifecycle_generation = state
|
||||||
|
.queue_manager
|
||||||
|
.registered_lifecycle_generation(&id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
state.queue_manager.allow_aria2_retries(&id).await;
|
state.queue_manager.allow_aria2_retries(&id).await;
|
||||||
state.queue_manager.reset_aria2_retry_strikes(&id).await;
|
state.queue_manager.reset_aria2_retry_strikes(&id).await;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
@@ -4542,7 +4552,13 @@ async fn resume_download(
|
|||||||
let permit_candidate = if had_permit {
|
let permit_candidate = if had_permit {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
queue_manager.acquire_aria2_permit_candidate().await
|
queue_manager
|
||||||
|
.acquire_aria2_permit_candidate_for_queue(
|
||||||
|
&id_clone,
|
||||||
|
&queue_id,
|
||||||
|
lifecycle_generation,
|
||||||
|
)
|
||||||
|
.await
|
||||||
};
|
};
|
||||||
if permit_candidate.is_none() && !had_permit {
|
if permit_candidate.is_none() && !had_permit {
|
||||||
return;
|
return;
|
||||||
@@ -4557,12 +4573,22 @@ async fn resume_download(
|
|||||||
!= Some(gid_clone.as_str())
|
!= Some(gid_clone.as_str())
|
||||||
|| !queue_manager.is_registered(&id_clone).await
|
|| !queue_manager.is_registered(&id_clone).await
|
||||||
{
|
{
|
||||||
|
if permit_candidate.is_some() {
|
||||||
|
queue_manager
|
||||||
|
.release_aria2_permit_candidate(&id_clone, lifecycle_generation)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !queue_manager
|
if !queue_manager
|
||||||
.rebind_aria2_gid_epoch(&id_clone, &gid_clone, control_epoch)
|
.rebind_aria2_gid_epoch(&id_clone, &gid_clone, control_epoch)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
if permit_candidate.is_some() {
|
||||||
|
queue_manager
|
||||||
|
.release_aria2_permit_candidate(&id_clone, lifecycle_generation)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"aria2 resume [{}]: gid {} disappeared before lifecycle rebind",
|
"aria2 resume [{}]: gid {} disappeared before lifecycle rebind",
|
||||||
id_clone,
|
id_clone,
|
||||||
@@ -4572,7 +4598,12 @@ async fn resume_download(
|
|||||||
}
|
}
|
||||||
if let Some(permit) = permit_candidate {
|
if let Some(permit) = permit_candidate {
|
||||||
let _ = queue_manager
|
let _ = queue_manager
|
||||||
.park_aria2_permit_if_missing(&id_clone, permit)
|
.park_aria2_permit_if_missing_for_queue(
|
||||||
|
&id_clone,
|
||||||
|
&queue_id,
|
||||||
|
lifecycle_generation,
|
||||||
|
permit,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let _ = app_handle_clone.emit(
|
let _ = app_handle_clone.emit(
|
||||||
@@ -4702,47 +4733,82 @@ async fn resume_download(
|
|||||||
}
|
}
|
||||||
"active" | "waiting" => {
|
"active" | "waiting" => {
|
||||||
let resume_epoch = state.queue_manager.current_aria2_control_epoch(&id).await;
|
let resume_epoch = state.queue_manager.current_aria2_control_epoch(&id).await;
|
||||||
|
let lifecycle_generation = state
|
||||||
|
.queue_manager
|
||||||
|
.registered_lifecycle_generation(&id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
drop(control_guard);
|
drop(control_guard);
|
||||||
state.queue_manager.allow_aria2_retries(&id).await;
|
state.queue_manager.allow_aria2_retries(&id).await;
|
||||||
let had_permit = state.queue_manager.has_active_permit(&id).await;
|
let queue_manager = state.queue_manager.clone();
|
||||||
let permit_candidate = if had_permit {
|
let id_clone = id.clone();
|
||||||
None
|
let gid_clone = gid.clone();
|
||||||
} else {
|
let queue_id_clone = queue_id.clone();
|
||||||
state.queue_manager.acquire_aria2_permit_candidate().await
|
let app_handle_clone = app_handle.clone();
|
||||||
};
|
let status_for_log = status.to_string();
|
||||||
if permit_candidate.is_none() && !had_permit {
|
tauri::async_runtime::spawn(async move {
|
||||||
return Ok(true);
|
let had_permit = queue_manager.has_active_permit(&id_clone).await;
|
||||||
}
|
let permit_candidate = if had_permit {
|
||||||
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
|
None
|
||||||
let still_current = state.queue_manager.is_registered(&id).await
|
} else {
|
||||||
&& !state.queue_manager.is_aria2_retry_cancelled(&id).await
|
queue_manager
|
||||||
&& state
|
.acquire_aria2_permit_candidate_for_queue(
|
||||||
.queue_manager
|
&id_clone,
|
||||||
.is_aria2_control_epoch_current(&id, resume_epoch)
|
&queue_id_clone,
|
||||||
.await
|
lifecycle_generation,
|
||||||
&& state.queue_manager.aria2_gid_for_download(&id).as_deref() == Some(gid.as_str());
|
)
|
||||||
if still_current {
|
.await
|
||||||
|
};
|
||||||
|
let had_permit_candidate = permit_candidate.is_some();
|
||||||
|
if permit_candidate.is_none() && !had_permit {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _control_guard = queue_manager.acquire_aria2_control(&id_clone).await;
|
||||||
|
let still_current = queue_manager.is_registered(&id_clone).await
|
||||||
|
&& !queue_manager.is_aria2_retry_cancelled(&id_clone).await
|
||||||
|
&& queue_manager
|
||||||
|
.is_aria2_control_epoch_current(&id_clone, resume_epoch)
|
||||||
|
.await
|
||||||
|
&& queue_manager.aria2_gid_for_download(&id_clone).as_deref()
|
||||||
|
== Some(gid_clone.as_str());
|
||||||
|
if !still_current {
|
||||||
|
if had_permit_candidate {
|
||||||
|
queue_manager
|
||||||
|
.release_aria2_permit_candidate(&id_clone, lifecycle_generation)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(permit) = permit_candidate {
|
if let Some(permit) = permit_candidate {
|
||||||
let _ = state
|
let parked = queue_manager
|
||||||
.queue_manager
|
.park_aria2_permit_if_missing_for_queue(
|
||||||
.park_aria2_permit_if_missing(&id, permit)
|
&id_clone,
|
||||||
|
&queue_id_clone,
|
||||||
|
lifecycle_generation,
|
||||||
|
permit,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
if !parked && !queue_manager.has_active_permit(&id_clone).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"aria2 resume [{}]: gid {} already {}; no duplicate job created",
|
"aria2 resume [{}]: gid {} already {}; no duplicate job created",
|
||||||
id,
|
id_clone,
|
||||||
gid,
|
gid_clone,
|
||||||
status
|
status_for_log
|
||||||
);
|
);
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle_clone.emit(
|
||||||
"download-state",
|
"download-state",
|
||||||
crate::ipc::DownloadStateEvent::new(
|
crate::ipc::DownloadStateEvent::new(
|
||||||
id,
|
&id_clone,
|
||||||
crate::ipc::DownloadStatus::Downloading,
|
crate::ipc::DownloadStatus::Downloading,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
"complete" | "error" | "removed" => {
|
"complete" | "error" | "removed" => {
|
||||||
@@ -5687,6 +5753,22 @@ async fn set_concurrent_limit(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_queue_concurrency_limits(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
limits: Vec<crate::ipc::QueueConcurrencyConfig>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.queue_manager
|
||||||
|
.replace_queue_limits(
|
||||||
|
limits
|
||||||
|
.into_iter()
|
||||||
|
.map(|limit| (limit.id, limit.max_concurrent))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn set_download_speed_limit(
|
async fn set_download_speed_limit(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
@@ -9327,7 +9409,7 @@ pub fn run() {
|
|||||||
authorize_keychain_access,
|
authorize_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_download_speed_limit, set_global_speed_limit, remove_download,
|
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_global_speed_limit, remove_download,
|
||||||
detach_download_for_reconfigure,
|
detach_download_for_reconfigure,
|
||||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||||
|
|||||||
+410
-53
@@ -14,6 +14,7 @@ use ts_rs::TS;
|
|||||||
|
|
||||||
/// Default capacity when no setting is read yet.
|
/// Default capacity when no setting is read yet.
|
||||||
pub const DEFAULT_MAX_CONCURRENT: usize = 3;
|
pub const DEFAULT_MAX_CONCURRENT: usize = 3;
|
||||||
|
pub const MAX_QUEUE_CONCURRENT: usize = 12;
|
||||||
pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__";
|
pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__";
|
||||||
pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1;
|
pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1;
|
||||||
pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16;
|
pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16;
|
||||||
@@ -122,6 +123,13 @@ pub struct QueuedTask {
|
|||||||
pub lifecycle_generation: u64,
|
pub lifecycle_generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct QueuePermitOwnership {
|
||||||
|
queue_id: String,
|
||||||
|
lifecycle_generation: u64,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Args mirroring start_download / start_media_download. Kept untyped-loose
|
/// Args mirroring start_download / start_media_download. Kept untyped-loose
|
||||||
/// (String/Option) to match the existing command signatures exactly.
|
/// (String/Option) to match the existing command signatures exactly.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -197,6 +205,19 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
|||||||
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
||||||
active_permit_generations: Mutex<HashMap<String, u64>>,
|
active_permit_generations: Mutex<HashMap<String, u64>>,
|
||||||
active_kinds: Mutex<HashMap<String, TaskKind>>,
|
active_kinds: Mutex<HashMap<String, TaskKind>>,
|
||||||
|
/// Queue overrides are stored only for queues with an explicit limit.
|
||||||
|
/// Missing entries inherit the global target capacity.
|
||||||
|
queue_limits: Mutex<HashMap<String, usize>>,
|
||||||
|
/// One entry represents either a queued dispatch reservation or an active
|
||||||
|
/// transfer. Keeping both phases in one map makes queue-slot ownership
|
||||||
|
/// exactly-once across the async addUri handoff.
|
||||||
|
queue_permit_ownership: Mutex<HashMap<String, QueuePermitOwnership>>,
|
||||||
|
/// Serializes queue-slot selection with global permit acquisition and
|
||||||
|
/// ownership transitions.
|
||||||
|
admission_gate: Mutex<()>,
|
||||||
|
/// Last queue selected by the dispatcher. Selection starts after this
|
||||||
|
/// queue when multiple queues have eligible work.
|
||||||
|
dispatch_cursor: Mutex<Option<String>>,
|
||||||
target_capacity: AtomicUsize,
|
target_capacity: AtomicUsize,
|
||||||
slots_to_retire: AtomicUsize,
|
slots_to_retire: AtomicUsize,
|
||||||
notify: Notify,
|
notify: Notify,
|
||||||
@@ -275,6 +296,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
active_permits: Mutex::new(HashMap::new()),
|
active_permits: Mutex::new(HashMap::new()),
|
||||||
active_permit_generations: Mutex::new(HashMap::new()),
|
active_permit_generations: Mutex::new(HashMap::new()),
|
||||||
active_kinds: Mutex::new(HashMap::new()),
|
active_kinds: Mutex::new(HashMap::new()),
|
||||||
|
queue_limits: Mutex::new(HashMap::new()),
|
||||||
|
queue_permit_ownership: Mutex::new(HashMap::new()),
|
||||||
|
admission_gate: Mutex::new(()),
|
||||||
|
dispatch_cursor: Mutex::new(None),
|
||||||
target_capacity: AtomicUsize::new(capacity),
|
target_capacity: AtomicUsize::new(capacity),
|
||||||
slots_to_retire: AtomicUsize::new(0),
|
slots_to_retire: AtomicUsize::new(0),
|
||||||
notify: Notify::new(),
|
notify: Notify::new(),
|
||||||
@@ -315,6 +340,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
// Epoch checks remain the authoritative guard; removing this marker
|
// Epoch checks remain the authoritative guard; removing this marker
|
||||||
// prevents terminal downloads from accumulating cancellation entries.
|
// prevents terminal downloads from accumulating cancellation entries.
|
||||||
self.aria2_retry_cancelled.lock().await.remove(id);
|
self.aria2_retry_cancelled.lock().await.remove(id);
|
||||||
|
self.notify.notify_waiters();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_registered(&self, id: &str) -> bool {
|
pub async fn is_registered(&self, id: &str) -> bool {
|
||||||
@@ -463,6 +489,38 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
self.push_with_generation(task, 0).await
|
self.push_with_generation(task, 0).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the explicit per-queue concurrency overrides. A missing queue
|
||||||
|
/// entry inherits the global limit; every effective queue limit is capped
|
||||||
|
/// by the global target at admission time.
|
||||||
|
pub async fn replace_queue_limits(
|
||||||
|
&self,
|
||||||
|
limits: Vec<(String, Option<usize>)>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut next = HashMap::with_capacity(limits.len());
|
||||||
|
for (queue_id, limit) in limits {
|
||||||
|
let queue_id = queue_id.trim();
|
||||||
|
if queue_id.is_empty() {
|
||||||
|
return Err("Queue id cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
if next.contains_key(queue_id) {
|
||||||
|
return Err(format!("Duplicate queue id '{queue_id}'"));
|
||||||
|
}
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
if !(1..=MAX_QUEUE_CONCURRENT).contains(&limit) {
|
||||||
|
return Err(format!(
|
||||||
|
"Queue concurrency must be between 1 and {MAX_QUEUE_CONCURRENT}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
next.insert(queue_id.to_string(), limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
|
*self.queue_limits.lock().await = next;
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn next_aria2_control_epoch(&self, id: &str) -> u64 {
|
pub async fn next_aria2_control_epoch(&self, id: &str) -> u64 {
|
||||||
let mut epochs = self.aria2_control_epochs.lock().await;
|
let mut epochs = self.aria2_control_epochs.lock().await;
|
||||||
let epoch = epochs.get(id).copied().unwrap_or_default().wrapping_add(1);
|
let epoch = epochs.get(id).copied().unwrap_or_default().wrapping_add(1);
|
||||||
@@ -627,6 +685,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
self.semaphore.clone().acquire_owned().await.ok()
|
self.semaphore.clone().acquire_owned().await.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn try_acquire_permit_after_retirement(&self) -> Option<OwnedSemaphorePermit> {
|
||||||
|
loop {
|
||||||
|
let permit = self.semaphore.clone().try_acquire_owned().ok()?;
|
||||||
|
if self.retire_slot_if_needed() {
|
||||||
|
permit.forget();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Some(permit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn acquire_permit_after_retirement(&self) -> Option<OwnedSemaphorePermit> {
|
async fn acquire_permit_after_retirement(&self) -> Option<OwnedSemaphorePermit> {
|
||||||
loop {
|
loop {
|
||||||
let permit = self.acquire_permit().await?;
|
let permit = self.acquire_permit().await?;
|
||||||
@@ -645,6 +714,37 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
self.acquire_permit_after_retirement().await
|
self.acquire_permit_after_retirement().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquire and reserve both the global slot and a queue slot for a resume
|
||||||
|
/// that may still be outside the per-download control lock. The reservation
|
||||||
|
/// is generation-stamped so a concurrent pause cannot be overwritten by a
|
||||||
|
/// late resume worker.
|
||||||
|
pub async fn acquire_aria2_permit_candidate_for_queue(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
queue_id: &str,
|
||||||
|
lifecycle_generation: u64,
|
||||||
|
) -> Option<OwnedSemaphorePermit> {
|
||||||
|
loop {
|
||||||
|
if !self.is_registered_generation(id, lifecycle_generation).await {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let notified = self.notify.notified();
|
||||||
|
if let Some(permit) = self
|
||||||
|
.try_reserve_queue_slot(id, queue_id, lifecycle_generation)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if self.is_registered_generation(id, lifecycle_generation).await {
|
||||||
|
return Some(permit);
|
||||||
|
}
|
||||||
|
self.release_queue_reservation_for_generation(id, lifecycle_generation)
|
||||||
|
.await;
|
||||||
|
drop(permit);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
notified.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn retire_slot_if_needed(&self) -> bool {
|
fn retire_slot_if_needed(&self) -> bool {
|
||||||
let mut debt = self.slots_to_retire.load(Ordering::Relaxed);
|
let mut debt = self.slots_to_retire.load(Ordering::Relaxed);
|
||||||
while debt > 0 {
|
while debt > 0 {
|
||||||
@@ -661,12 +761,180 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn try_reserve_queue_slot(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
queue_id: &str,
|
||||||
|
lifecycle_generation: u64,
|
||||||
|
) -> Option<OwnedSemaphorePermit> {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
|
let mut ownership = self.queue_permit_ownership.lock().await;
|
||||||
|
if ownership.contains_key(id)
|
||||||
|
|| self.active_permits.lock().await.contains_key(id)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let global_target = self.target_capacity.load(Ordering::Relaxed);
|
||||||
|
let queue_limit = self
|
||||||
|
.queue_limits
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(queue_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(global_target)
|
||||||
|
.min(global_target);
|
||||||
|
let queue_active = ownership
|
||||||
|
.values()
|
||||||
|
.filter(|active| active.queue_id == queue_id)
|
||||||
|
.count();
|
||||||
|
if queue_active >= queue_limit {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let permit = self.try_acquire_permit_after_retirement()?;
|
||||||
|
ownership.insert(
|
||||||
|
id.to_string(),
|
||||||
|
QueuePermitOwnership {
|
||||||
|
queue_id: queue_id.to_string(),
|
||||||
|
lifecycle_generation,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Some(permit)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_queue_reservation_for_generation(&self, id: &str, generation: u64) {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
|
let removed = self
|
||||||
|
.queue_permit_ownership
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|ownership| {
|
||||||
|
ownership.lifecycle_generation == generation && !ownership.active
|
||||||
|
});
|
||||||
|
if removed {
|
||||||
|
self.queue_permit_ownership.lock().await.remove(id);
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activate_admitted_permit(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
lifecycle_generation: u64,
|
||||||
|
permit: OwnedSemaphorePermit,
|
||||||
|
) -> bool {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
|
let mut ownership = self.queue_permit_ownership.lock().await;
|
||||||
|
let owned = ownership
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|entry| entry.lifecycle_generation == lifecycle_generation && !entry.active);
|
||||||
|
let active_already_owned = self.active_permits.lock().await.contains_key(id);
|
||||||
|
if !owned || active_already_owned {
|
||||||
|
if owned {
|
||||||
|
ownership.remove(id);
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(entry) = ownership.get_mut(id) {
|
||||||
|
entry.active = true;
|
||||||
|
}
|
||||||
|
drop(ownership);
|
||||||
|
self.active_permits.lock().await.insert(id.to_string(), permit);
|
||||||
|
self.active_permit_generations
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(id.to_string(), lifecycle_generation);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_admit_next_task(&self) -> Option<(OwnedSemaphorePermit, QueuedTask)> {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
if pending.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ownership = self.queue_permit_ownership.lock().await;
|
||||||
|
let mut queue_ids = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for task in pending.iter() {
|
||||||
|
if !ownership.contains_key(&task.id) && seen.insert(task.queue_id.clone()) {
|
||||||
|
queue_ids.push(task.queue_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if queue_ids.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = self.dispatch_cursor.lock().await.clone();
|
||||||
|
let start = cursor
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|queue_id| queue_ids.iter().position(|candidate| candidate == queue_id))
|
||||||
|
.map_or(0, |position| (position + 1) % queue_ids.len());
|
||||||
|
let global_target = self.target_capacity.load(Ordering::Relaxed);
|
||||||
|
let queue_limits = self.queue_limits.lock().await;
|
||||||
|
let selected_queue = (0..queue_ids.len()).find_map(|offset| {
|
||||||
|
let queue_id = &queue_ids[(start + offset) % queue_ids.len()];
|
||||||
|
let queue_limit = queue_limits
|
||||||
|
.get(queue_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(global_target)
|
||||||
|
.min(global_target);
|
||||||
|
let active = ownership
|
||||||
|
.values()
|
||||||
|
.filter(|active| active.queue_id == *queue_id)
|
||||||
|
.count();
|
||||||
|
(active < queue_limit).then_some(queue_id.clone())
|
||||||
|
})?;
|
||||||
|
let task_index = pending
|
||||||
|
.iter()
|
||||||
|
.position(|task| task.queue_id == selected_queue && !ownership.contains_key(&task.id))?;
|
||||||
|
drop(queue_limits);
|
||||||
|
drop(ownership);
|
||||||
|
|
||||||
|
let permit = self.try_acquire_permit_after_retirement()?;
|
||||||
|
let task = pending.remove(task_index)?;
|
||||||
|
self.queue_permit_ownership.lock().await.insert(
|
||||||
|
task.id.clone(),
|
||||||
|
QueuePermitOwnership {
|
||||||
|
queue_id: selected_queue.clone(),
|
||||||
|
lifecycle_generation: task.lifecycle_generation,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*self.dispatch_cursor.lock().await = Some(selected_queue);
|
||||||
|
Some((permit, task))
|
||||||
|
}
|
||||||
|
|
||||||
/// Park an already-acquired permit under `id`.
|
/// Park an already-acquired permit under `id`.
|
||||||
pub async fn park_permit(&self, id: &str, permit: OwnedSemaphorePermit) {
|
pub async fn park_permit(&self, id: &str, permit: OwnedSemaphorePermit) {
|
||||||
|
self.park_permit_for_queue(id, "main", 0, permit).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn park_permit_for_queue(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
queue_id: &str,
|
||||||
|
lifecycle_generation: u64,
|
||||||
|
permit: OwnedSemaphorePermit,
|
||||||
|
) {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
self.active_permits
|
self.active_permits
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.insert(id.to_string(), permit);
|
.insert(id.to_string(), permit);
|
||||||
|
self.queue_permit_ownership.lock().await.insert(
|
||||||
|
id.to_string(),
|
||||||
|
QueuePermitOwnership {
|
||||||
|
queue_id: queue_id.to_string(),
|
||||||
|
lifecycle_generation,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Park a candidate only when no newer lifecycle has already claimed the
|
/// Park a candidate only when no newer lifecycle has already claimed the
|
||||||
@@ -676,12 +944,72 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
id: &str,
|
id: &str,
|
||||||
permit: OwnedSemaphorePermit,
|
permit: OwnedSemaphorePermit,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
self.park_aria2_permit_if_missing_for_queue(id, "main", 0, permit)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate a queue-aware resume reservation. The caller owns the permit
|
||||||
|
/// while it revalidates the lifecycle under the per-download control lock.
|
||||||
|
pub async fn park_aria2_permit_if_missing_for_queue(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
queue_id: &str,
|
||||||
|
lifecycle_generation: u64,
|
||||||
|
permit: OwnedSemaphorePermit,
|
||||||
|
) -> bool {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
|
let registered_generation = self
|
||||||
|
.registered_lifecycle_generations
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(id)
|
||||||
|
.copied();
|
||||||
|
let mut ownership = self.queue_permit_ownership.lock().await;
|
||||||
|
let has_matching_reservation = ownership.get(id).is_some_and(|entry| {
|
||||||
|
entry.queue_id == queue_id
|
||||||
|
&& entry.lifecycle_generation == lifecycle_generation
|
||||||
|
&& !entry.active
|
||||||
|
&& (registered_generation == Some(lifecycle_generation)
|
||||||
|
|| (registered_generation.is_none() && lifecycle_generation == 0))
|
||||||
|
});
|
||||||
|
if ownership.contains_key(id) && !has_matching_reservation {
|
||||||
|
let remove_reservation = ownership
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|entry| entry.lifecycle_generation == lifecycle_generation && !entry.active);
|
||||||
|
if remove_reservation {
|
||||||
|
ownership.remove(id);
|
||||||
|
self.notify.notify_waiters();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ownership.get(id).is_none()
|
||||||
|
&& registered_generation.is_some_and(|current| current != lifecycle_generation)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let mut permits = self.active_permits.lock().await;
|
let mut permits = self.active_permits.lock().await;
|
||||||
if permits.contains_key(id) {
|
if permits.contains_key(id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
permits.insert(id.to_string(), permit);
|
permits.insert(id.to_string(), permit);
|
||||||
drop(permits);
|
drop(permits);
|
||||||
|
if let Some(entry) = ownership.get_mut(id) {
|
||||||
|
entry.active = true;
|
||||||
|
} else {
|
||||||
|
ownership.insert(
|
||||||
|
id.to_string(),
|
||||||
|
QueuePermitOwnership {
|
||||||
|
queue_id: queue_id.to_string(),
|
||||||
|
lifecycle_generation,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
drop(ownership);
|
||||||
|
self.active_permit_generations
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(id.to_string(), lifecycle_generation);
|
||||||
self.active_kinds
|
self.active_kinds
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
@@ -689,11 +1017,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tag_permit_generation(&self, id: &str, generation: u64) {
|
pub async fn release_aria2_permit_candidate(&self, id: &str, lifecycle_generation: u64) {
|
||||||
self.active_permit_generations
|
self.release_queue_reservation_for_generation(id, lifecycle_generation)
|
||||||
.lock()
|
.await;
|
||||||
.await
|
|
||||||
.insert(id.to_string(), generation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn active_kind(&self, id: &str) -> Option<TaskKind> {
|
pub async fn active_kind(&self, id: &str) -> Option<TaskKind> {
|
||||||
@@ -704,52 +1030,84 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
/// when this call acquired and parked the permit, false when one was
|
/// when this call acquired and parked the permit, false when one was
|
||||||
/// already parked.
|
/// already parked.
|
||||||
pub async fn ensure_aria2_permit(&self, id: &str) -> bool {
|
pub async fn ensure_aria2_permit(&self, id: &str) -> bool {
|
||||||
if self.active_permits.lock().await.contains_key(id) {
|
self.ensure_aria2_permit_for_queue(id, "main").await
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let permit = match self.acquire_permit_after_retirement().await {
|
/// Ensure a paused or externally recovered aria2 transfer owns one global
|
||||||
Some(p) => p,
|
/// permit and one slot in its queue. This waits without holding a
|
||||||
None => return false,
|
/// per-download control lock and wakes on either kind of capacity change.
|
||||||
};
|
pub async fn ensure_aria2_permit_for_queue(&self, id: &str, queue_id: &str) -> bool {
|
||||||
let mut permits = self.active_permits.lock().await;
|
loop {
|
||||||
if permits.contains_key(id) {
|
if self.has_active_permit(id).await
|
||||||
drop(permits);
|
|| self.queue_permit_ownership.lock().await.contains_key(id)
|
||||||
drop(permit);
|
{
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
let notified = self.notify.notified();
|
||||||
|
let generation = self
|
||||||
|
.registered_lifecycle_generation(id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(permit) = self
|
||||||
|
.try_reserve_queue_slot(id, queue_id, generation)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if self.park_aria2_permit_if_missing_for_queue(
|
||||||
|
id,
|
||||||
|
queue_id,
|
||||||
|
generation,
|
||||||
|
permit,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
self.active_kinds
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(id.to_string(), TaskKind::Aria2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
notified.await;
|
||||||
}
|
}
|
||||||
permits.insert(id.to_string(), permit);
|
|
||||||
drop(permits);
|
|
||||||
self.active_kinds
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(id.to_string(), TaskKind::Aria2);
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn release_permit(&self, id: &str) {
|
pub async fn release_permit(&self, id: &str) {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
|
let queue_removed = self.queue_permit_ownership.lock().await.remove(id).is_some();
|
||||||
let removed = self.active_permits.lock().await.remove(id).is_some();
|
let removed = self.active_permits.lock().await.remove(id).is_some();
|
||||||
self.active_permit_generations.lock().await.remove(id);
|
self.active_permit_generations.lock().await.remove(id);
|
||||||
self.active_kinds.lock().await.remove(id);
|
self.active_kinds.lock().await.remove(id);
|
||||||
if removed {
|
if removed || queue_removed {
|
||||||
self.notify.notify_one();
|
self.notify.notify_waiters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn release_permit_for_generation(&self, id: &str, generation: u64) {
|
async fn release_permit_for_generation(&self, id: &str, generation: u64) {
|
||||||
|
let _admission_gate = self.admission_gate.lock().await;
|
||||||
let removed = {
|
let removed = {
|
||||||
let mut permits = self.active_permits.lock().await;
|
let mut permits = self.active_permits.lock().await;
|
||||||
let mut generations = self.active_permit_generations.lock().await;
|
let mut generations = self.active_permit_generations.lock().await;
|
||||||
if generations.get(id).copied() == Some(generation) {
|
let queue_owned = self
|
||||||
|
.queue_permit_ownership
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|ownership| ownership.lifecycle_generation == generation);
|
||||||
|
if queue_owned || generations.get(id).copied() == Some(generation) {
|
||||||
generations.remove(id);
|
generations.remove(id);
|
||||||
permits.remove(id).is_some()
|
let active_removed = permits.remove(id).is_some();
|
||||||
|
if queue_owned {
|
||||||
|
self.queue_permit_ownership.lock().await.remove(id);
|
||||||
|
}
|
||||||
|
active_removed || queue_owned
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if removed {
|
if removed {
|
||||||
self.active_kinds.lock().await.remove(id);
|
self.active_kinds.lock().await.remove(id);
|
||||||
self.notify.notify_one();
|
self.notify.notify_waiters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,10 +1179,11 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
if delta > 0 {
|
if delta > 0 {
|
||||||
self.semaphore.add_permits(delta);
|
self.semaphore.add_permits(delta);
|
||||||
}
|
}
|
||||||
self.notify.notify_one();
|
self.notify.notify_waiters();
|
||||||
} else {
|
} else {
|
||||||
let delta = prev_target - new_target;
|
let delta = prev_target - new_target;
|
||||||
self.slots_to_retire.fetch_add(delta, Ordering::Relaxed);
|
self.slots_to_retire.fetch_add(delta, Ordering::Relaxed);
|
||||||
|
self.notify.notify_waiters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,29 +1193,20 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The long-running dispatcher. One instance is spawned in setup().
|
/// The long-running dispatcher. One instance is spawned in setup().
|
||||||
/// Idle-parks on Notify; CAS-honors retirement debt; re-pops under lock.
|
/// It scans for a queue with capacity before reserving the global slot, so
|
||||||
|
/// a saturated front queue cannot block later eligible queues.
|
||||||
pub async fn run_dispatcher(self: Arc<Self>) {
|
pub async fn run_dispatcher(self: Arc<Self>) {
|
||||||
loop {
|
loop {
|
||||||
// (1) Idle-park: avoid busy-spin when pending is empty.
|
let notified = self.notify.notified();
|
||||||
if self.pending.lock().await.is_empty() {
|
if let Some((permit, task)) = self.try_admit_next_task().await {
|
||||||
self.notify.notified().await;
|
Arc::clone(&self).dispatch_one(permit, task).await;
|
||||||
continue;
|
} else {
|
||||||
|
// This covers both an empty pending list and the case where
|
||||||
|
// all queue/global capacity is occupied. The notification
|
||||||
|
// future is created before inspection to close the lost-wake
|
||||||
|
// window without polling or sleeping.
|
||||||
|
notified.await;
|
||||||
}
|
}
|
||||||
// (2) Acquire a slot.
|
|
||||||
let permit = match self.acquire_permit_after_retirement().await {
|
|
||||||
Some(p) => p,
|
|
||||||
None => break, // Semaphore closed, exit dispatcher
|
|
||||||
};
|
|
||||||
// (4) Re-pop under lock — guards against racing removals between
|
|
||||||
// waking from Notify and acquiring the permit.
|
|
||||||
let task = match self.pending.lock().await.pop_front() {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
drop(permit);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Arc::clone(&self).dispatch_one(permit, task).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,16 +1222,23 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.is_registered_generation(&id, lifecycle_generation)
|
.is_registered_generation(&id, lifecycle_generation)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
self.release_queue_reservation_for_generation(&id, lifecycle_generation)
|
||||||
|
.await;
|
||||||
drop(control_guard);
|
drop(control_guard);
|
||||||
drop(permit);
|
drop(permit);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Park the permit BEFORE spawning. Uniform parking:
|
// Activate the reservation BEFORE spawning. Uniform parking:
|
||||||
// aria2's RPC returns instantly, so the permit must outlive the
|
// aria2's RPC returns instantly, so the permit must outlive the
|
||||||
// dispatch_one call. Media runners release on exit.
|
// dispatch_one call. Media runners release on exit.
|
||||||
self.park_permit(&id, permit).await;
|
if !self
|
||||||
self.tag_permit_generation(&id, lifecycle_generation).await;
|
.activate_admitted_permit(&id, lifecycle_generation, permit)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
drop(control_guard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.active_kinds
|
self.active_kinds
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -783,6 +783,245 @@ async fn aria2_resume_waits_for_shrunk_capacity() {
|
|||||||
.expect("resume task should not panic"));
|
.expect("resume task should not panic"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_skips_a_full_front_queue_for_later_eligible_work() {
|
||||||
|
let (manager, spawner) = make_manager(2);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-b".to_string(), Some(1)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if spawner.add_uri_calls.load(Ordering::SeqCst) == 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("later eligible queue should dispatch");
|
||||||
|
assert!(manager.aria2_gid_for_download("a1").is_some());
|
||||||
|
assert!(manager.aria2_gid_for_download("b1").is_some());
|
||||||
|
assert!(manager.aria2_gid_for_download("a2").is_none());
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatcher_rotates_eligible_queues_without_starving_later_work() {
|
||||||
|
let (manager, spawner) = make_manager(1);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-b".to_string(), Some(1)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("a1", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("a2", "queue-a")).await.unwrap();
|
||||||
|
manager.push(aria2_task_in_queue("b1", "queue-b")).await.unwrap();
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while spawner.add_uri_calls.load(Ordering::SeqCst) < 1 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first queue task should dispatch");
|
||||||
|
manager.release_permit("a1").await;
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while manager.aria2_gid_for_download("b1").is_none() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("later queue should be selected before another task from queue-a");
|
||||||
|
assert!(manager.aria2_gid_for_download("a2").is_none());
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_limit_increase_wakes_waiting_work_and_decrease_keeps_active_tasks() {
|
||||||
|
let (manager, spawner) = make_manager(3);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), Some(2))])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for id in ["a1", "a2", "a3"] {
|
||||||
|
manager.push(aria2_task_in_queue(id, "queue-a")).await.unwrap();
|
||||||
|
}
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while spawner.add_uri_calls.load(Ordering::SeqCst) < 2 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("queue override should allow two active transfers");
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), Some(1))])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
|
manager.release_permit("a1").await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
assert_eq!(
|
||||||
|
spawner.add_uri_calls.load(Ordering::SeqCst),
|
||||||
|
2,
|
||||||
|
"reducing a queue limit must not admit work while one active transfer remains"
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.release_permit("a2").await;
|
||||||
|
timeout(Duration::from_secs(1), async {
|
||||||
|
while spawner.add_uri_calls.load(Ordering::SeqCst) < 3 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("queue work should resume after active count falls below the reduced limit");
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_overrides_never_exceed_the_global_ceiling() {
|
||||||
|
let (manager, spawner) = make_manager(2);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(12)),
|
||||||
|
("queue-b".to_string(), Some(12)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (id, queue_id) in [
|
||||||
|
("a1", "queue-a"),
|
||||||
|
("a2", "queue-a"),
|
||||||
|
("b1", "queue-b"),
|
||||||
|
("b2", "queue-b"),
|
||||||
|
] {
|
||||||
|
manager.push(aria2_task_in_queue(id, queue_id)).await.unwrap();
|
||||||
|
}
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 2);
|
||||||
|
assert_eq!(manager.available_permits(), 0);
|
||||||
|
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_limit_configuration_rejects_malformed_input() {
|
||||||
|
let (manager, _spawner) = make_manager(2);
|
||||||
|
assert!(manager
|
||||||
|
.replace_queue_limits(vec![(String::new(), Some(1))])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), Some(0))])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-a".to_string(), None),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![("queue-a".to_string(), None)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_queue_resume_reservation_is_removed_when_activation_fails() {
|
||||||
|
let (manager, _spawner) = make_manager(1);
|
||||||
|
let previous = manager
|
||||||
|
.reserve_enqueue_generation("candidate", 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(previous.is_none());
|
||||||
|
let candidate = manager
|
||||||
|
.acquire_aria2_permit_candidate_for_queue("candidate", "queue-a", 1)
|
||||||
|
.await
|
||||||
|
.expect("candidate should reserve the queue slot");
|
||||||
|
|
||||||
|
manager.release_registered_id("candidate").await;
|
||||||
|
assert!(!manager
|
||||||
|
.park_aria2_permit_if_missing_for_queue("candidate", "queue-a", 1, candidate)
|
||||||
|
.await);
|
||||||
|
assert_eq!(manager.available_permits(), 1);
|
||||||
|
|
||||||
|
assert!(manager.ensure_aria2_permit_for_queue("replacement", "queue-a").await);
|
||||||
|
manager.release_permit("replacement").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_pending_id_cannot_replace_an_existing_queue_ownership() {
|
||||||
|
let (manager, spawner) = make_manager(2);
|
||||||
|
let manager = Arc::new(manager);
|
||||||
|
manager
|
||||||
|
.replace_queue_limits(vec![
|
||||||
|
("queue-a".to_string(), Some(1)),
|
||||||
|
("queue-b".to_string(), Some(1)),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(manager
|
||||||
|
.ensure_aria2_permit_for_queue("duplicate", "queue-b")
|
||||||
|
.await);
|
||||||
|
manager
|
||||||
|
.reserve_enqueue_generation("duplicate", 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
manager
|
||||||
|
.commit_reserved_enqueue(aria2_task_in_queue("duplicate", "queue-a"), 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let dispatcher = {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||||
|
};
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(manager.available_permits(), 1);
|
||||||
|
manager.release_permit("duplicate").await;
|
||||||
|
dispatcher.abort();
|
||||||
|
}
|
||||||
|
|
||||||
fn aria2_task(id: &str) -> QueuedTask {
|
fn aria2_task(id: &str) -> QueuedTask {
|
||||||
QueuedTask {
|
QueuedTask {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -793,6 +1032,12 @@ fn aria2_task(id: &str) -> QueuedTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn aria2_task_in_queue(id: &str, queue_id: &str) -> QueuedTask {
|
||||||
|
let mut task = aria2_task(id);
|
||||||
|
task.queue_id = queue_id.to_string();
|
||||||
|
task
|
||||||
|
}
|
||||||
|
|
||||||
fn media_task(id: &str) -> QueuedTask {
|
fn media_task(id: &str) -> QueuedTask {
|
||||||
QueuedTask {
|
QueuedTask {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type Queue = { id: string, name: string, isMain: boolean, };
|
export type Queue = { id: string, name: string, isMain: boolean, maxConcurrent?: number, };
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type QueueConcurrencyConfig = { id: string, maxConcurrent: number | null, };
|
||||||
@@ -72,6 +72,8 @@ export const PropertiesModal = () => {
|
|||||||
|
|
||||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||||
|
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
|
||||||
|
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||||
|
|
||||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -143,6 +145,11 @@ export const PropertiesModal = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeLimit = item?.speedLimit?.trim();
|
||||||
|
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||||
|
}, [item?.speedLimit, selectedPropertiesDownloadId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||||
@@ -245,8 +252,27 @@ export const PropertiesModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLiveSpeedLimit = async (limit: string | null) => {
|
||||||
|
if (isLiveSpeedLimitPending || item.isMedia || !['downloading', 'retrying'].includes(item.status)) return;
|
||||||
|
|
||||||
|
setErrorMessage('');
|
||||||
|
setIsLiveSpeedLimitPending(true);
|
||||||
|
try {
|
||||||
|
await useDownloadStore.getState().setDownloadSpeedLimit(item.id, limit);
|
||||||
|
if (limit === null) setLiveSpeedLimitValue('');
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(t($ => $.properties.liveSpeedLimitFailed, {
|
||||||
|
detail: error instanceof Error ? error.message : String(error)
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setIsLiveSpeedLimitPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const identityLocked = getIdentityLocked(item.status);
|
const identityLocked = getIdentityLocked(item.status);
|
||||||
const transferLocked = getTransferLocked(item.status);
|
const transferLocked = getTransferLocked(item.status);
|
||||||
|
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
|
||||||
|
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
|
||||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||||
const observedConnectionTotal = Math.max(
|
const observedConnectionTotal = Math.max(
|
||||||
1,
|
1,
|
||||||
@@ -458,6 +484,53 @@ export const PropertiesModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
|
||||||
|
<div className="col-start-2 rounded-md border border-border-modal/60 bg-border-color/20 p-3 space-y-2">
|
||||||
|
{liveSpeedLimitAvailable ? (
|
||||||
|
<>
|
||||||
|
<label htmlFor="live-speed-limit" className="block text-xs font-medium text-text-primary">
|
||||||
|
{t($ => $.properties.liveSpeedLimit)}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="live-speed-limit"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={liveSpeedLimitValue}
|
||||||
|
onChange={event => setLiveSpeedLimitValue(event.currentTarget.value)}
|
||||||
|
placeholder={t($ => $.properties.liveSpeedLimitPlaceholder)}
|
||||||
|
disabled={isLiveSpeedLimitPending}
|
||||||
|
aria-describedby="live-speed-limit-hint"
|
||||||
|
className="w-28 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleLiveSpeedLimit(liveSpeedLimitValue)}
|
||||||
|
disabled={isLiveSpeedLimitPending}
|
||||||
|
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t($ => $.properties.liveSpeedLimitApply)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleLiveSpeedLimit(null)}
|
||||||
|
disabled={isLiveSpeedLimitPending || !liveSpeedLimitValue}
|
||||||
|
className="app-button px-3 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t($ => $.properties.liveSpeedLimitClear)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="live-speed-limit-hint" className="text-[11px] text-text-muted">
|
||||||
|
{t($ => $.properties.liveSpeedLimitHint)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-text-muted">
|
||||||
|
{t($ => $.properties.liveSpeedLimitUnavailable)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||||
const { selectedFilter, onSelectFilter } = props;
|
const { selectedFilter, onSelectFilter } = props;
|
||||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||||
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -475,6 +475,45 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
<Pause size={14} className="me-2 text-text-secondary" />
|
<Pause size={14} className="me-2 text-text-secondary" />
|
||||||
{t($ => $.actions.pauseQueue)}
|
{t($ => $.actions.pauseQueue)}
|
||||||
</button>
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const queue = queues.find(candidate => candidate.id === contextMenu.id);
|
||||||
|
if (!queue) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="queue-concurrency-label"
|
||||||
|
className="px-3 py-2 text-[12px] text-text-secondary"
|
||||||
|
onClick={event => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<label id="queue-concurrency-label" htmlFor="queue-concurrency-select" className="block mb-1">
|
||||||
|
{t($ => $.sidebar.queueConcurrency)}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="queue-concurrency-select"
|
||||||
|
aria-label={t($ => $.sidebar.queueConcurrency)}
|
||||||
|
value={queue.maxConcurrent?.toString() ?? ''}
|
||||||
|
className="w-full rounded border border-border-color bg-bg-context-menu px-1.5 py-1 text-[12px] text-text-primary outline-none focus:border-accent"
|
||||||
|
onChange={event => {
|
||||||
|
const value = event.currentTarget.value === ''
|
||||||
|
? null
|
||||||
|
: Number(event.currentTarget.value);
|
||||||
|
void setQueueConcurrency(queue.id, value).catch(error => {
|
||||||
|
addToast({
|
||||||
|
message: t($ => $.sidebar.queueConcurrencyFailed, { detail: String(error) }),
|
||||||
|
variant: 'error',
|
||||||
|
isActionable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t($ => $.sidebar.queueConcurrencyGlobal)}</option>
|
||||||
|
{Array.from({ length: 12 }, (_, index) => index + 1).map(value => (
|
||||||
|
<option key={value} value={value}>{value}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div className="h-px bg-border-color my-1 mx-2" />
|
<div className="h-px bg-border-color my-1 mx-2" />
|
||||||
<button
|
<button
|
||||||
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ const common = {
|
|||||||
connectionCountUnknown: '—/{{total}}',
|
connectionCountUnknown: '—/{{total}}',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
speedCap: 'Speed cap',
|
speedCap: 'Speed cap',
|
||||||
|
liveSpeedLimit: 'Live speed cap',
|
||||||
|
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
|
||||||
|
liveSpeedLimitPlaceholder: 'e.g. 1024K',
|
||||||
|
liveSpeedLimitApply: 'Apply',
|
||||||
|
liveSpeedLimitClear: 'Clear',
|
||||||
|
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||||
category: 'Category',
|
category: 'Category',
|
||||||
lastTry: 'Last try',
|
lastTry: 'Last try',
|
||||||
dateAdded: 'Date added',
|
dateAdded: 'Date added',
|
||||||
@@ -326,6 +333,9 @@ const common = {
|
|||||||
queueNameExists: 'A queue with this name already exists',
|
queueNameExists: 'A queue with this name already exists',
|
||||||
startQueueFailed: 'Could not start queue: {{detail}}',
|
startQueueFailed: 'Could not start queue: {{detail}}',
|
||||||
pauseQueueFailed: 'Could not pause queue: {{detail}}',
|
pauseQueueFailed: 'Could not pause queue: {{detail}}',
|
||||||
|
queueConcurrency: 'Concurrent downloads',
|
||||||
|
queueConcurrencyGlobal: 'Use global limit',
|
||||||
|
queueConcurrencyFailed: 'Could not update queue concurrency: {{detail}}',
|
||||||
},
|
},
|
||||||
downloadTable: {
|
downloadTable: {
|
||||||
unknownQueue: 'Unknown Queue',
|
unknownQueue: 'Unknown Queue',
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ const fa = {
|
|||||||
connectionCountUnknown: '—/{{total}} فعال',
|
connectionCountUnknown: '—/{{total}} فعال',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
speedCap: 'سقف سرعت',
|
speedCap: 'سقف سرعت',
|
||||||
|
liveSpeedLimit: 'سقف سرعت زنده',
|
||||||
|
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال میشود. سرعت دانلودهای رسانهای هنگام اجرا قابل تغییر نیست.',
|
||||||
|
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
|
||||||
|
liveSpeedLimitApply: 'اعمال',
|
||||||
|
liveSpeedLimitClear: 'پاک کردن',
|
||||||
|
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||||
category: 'دسته',
|
category: 'دسته',
|
||||||
lastTry: 'آخرین تلاش',
|
lastTry: 'آخرین تلاش',
|
||||||
dateAdded: 'تاریخ افزودن',
|
dateAdded: 'تاریخ افزودن',
|
||||||
@@ -326,6 +333,9 @@ const fa = {
|
|||||||
queueNameExists: 'یک صف با این نام از قبل وجود دارد',
|
queueNameExists: 'یک صف با این نام از قبل وجود دارد',
|
||||||
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
|
startQueueFailed: 'شروع صف ناموفق بود: {{detail}}',
|
||||||
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
|
pauseQueueFailed: 'توقف صف ناموفق بود: {{detail}}',
|
||||||
|
queueConcurrency: 'دانلودهای همزمان',
|
||||||
|
queueConcurrencyGlobal: 'استفاده از محدودیت کلی',
|
||||||
|
queueConcurrencyFailed: 'بهروزرسانی همزمانی صف ناموفق بود: {{detail}}',
|
||||||
},
|
},
|
||||||
downloadTable: {
|
downloadTable: {
|
||||||
unknownQueue: 'صف نامشخص',
|
unknownQueue: 'صف نامشخص',
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ const he = {
|
|||||||
connectionCountUnknown: '—/{{total}} פעילות',
|
connectionCountUnknown: '—/{{total}} פעילות',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
speedCap: 'הגבלת מהירות',
|
speedCap: 'הגבלת מהירות',
|
||||||
|
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
|
||||||
|
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
|
||||||
|
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
|
||||||
|
liveSpeedLimitApply: 'החל',
|
||||||
|
liveSpeedLimitClear: 'נקה',
|
||||||
|
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||||
category: 'קטגוריה',
|
category: 'קטגוריה',
|
||||||
lastTry: 'ניסיון אחרון',
|
lastTry: 'ניסיון אחרון',
|
||||||
dateAdded: 'תאריך הוספה',
|
dateAdded: 'תאריך הוספה',
|
||||||
@@ -326,6 +333,9 @@ const he = {
|
|||||||
queueNameExists: 'כבר קיים תור בשם זה',
|
queueNameExists: 'כבר קיים תור בשם זה',
|
||||||
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
|
startQueueFailed: 'לא ניתן להפעיל את התור: {{detail}}',
|
||||||
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
|
pauseQueueFailed: 'לא ניתן להשהות את התור: {{detail}}',
|
||||||
|
queueConcurrency: 'הורדות בו-זמניות',
|
||||||
|
queueConcurrencyGlobal: 'שימוש במגבלה הכללית',
|
||||||
|
queueConcurrencyFailed: 'לא ניתן לעדכן את מקביליות התור: {{detail}}',
|
||||||
},
|
},
|
||||||
downloadTable: {
|
downloadTable: {
|
||||||
unknownQueue: 'תור לא ידוע',
|
unknownQueue: 'תור לא ידוע',
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ const ru = {
|
|||||||
connectionCountUnknown: '—/{{total}} активных',
|
connectionCountUnknown: '—/{{total}} активных',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
speedCap: 'Ограничение скорости',
|
speedCap: 'Ограничение скорости',
|
||||||
|
liveSpeedLimit: 'Текущее ограничение скорости',
|
||||||
|
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
|
||||||
|
liveSpeedLimitPlaceholder: 'например, 1024K',
|
||||||
|
liveSpeedLimitApply: 'Применить',
|
||||||
|
liveSpeedLimitClear: 'Очистить',
|
||||||
|
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||||
category: 'Категория',
|
category: 'Категория',
|
||||||
lastTry: 'Последняя попытка',
|
lastTry: 'Последняя попытка',
|
||||||
dateAdded: 'Дата добавления',
|
dateAdded: 'Дата добавления',
|
||||||
@@ -326,6 +333,9 @@ const ru = {
|
|||||||
queueNameExists: 'Очередь с таким названием уже существует',
|
queueNameExists: 'Очередь с таким названием уже существует',
|
||||||
startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
|
startQueueFailed: 'Не удалось запустить очередь: {{detail}}',
|
||||||
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
|
pauseQueueFailed: 'Не удалось приостановить очередь: {{detail}}',
|
||||||
|
queueConcurrency: 'Одновременные загрузки',
|
||||||
|
queueConcurrencyGlobal: 'Использовать общий лимит',
|
||||||
|
queueConcurrencyFailed: 'Не удалось обновить параллельность очереди: {{detail}}',
|
||||||
},
|
},
|
||||||
downloadTable: {
|
downloadTable: {
|
||||||
unknownQueue: 'Неизвестная очередь',
|
unknownQueue: 'Неизвестная очередь',
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ const uk = {
|
|||||||
connectionCountUnknown: '—/{{total}} активних',
|
connectionCountUnknown: '—/{{total}} активних',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
speedCap: 'Обмеження швидкості',
|
speedCap: 'Обмеження швидкості',
|
||||||
|
liveSpeedLimit: 'Поточне обмеження швидкості',
|
||||||
|
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
|
||||||
|
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
|
||||||
|
liveSpeedLimitApply: 'Застосувати',
|
||||||
|
liveSpeedLimitClear: 'Очистити',
|
||||||
|
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||||
category: 'Категорія',
|
category: 'Категорія',
|
||||||
lastTry: 'Остання спроба',
|
lastTry: 'Остання спроба',
|
||||||
dateAdded: 'Дата додавання',
|
dateAdded: 'Дата додавання',
|
||||||
@@ -326,6 +333,9 @@ const uk = {
|
|||||||
queueNameExists: 'Черга з такою назвою вже існує',
|
queueNameExists: 'Черга з такою назвою вже існує',
|
||||||
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
|
startQueueFailed: 'Не вдалося запустити чергу: {{detail}}',
|
||||||
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
|
pauseQueueFailed: 'Не вдалося призупинити чергу: {{detail}}',
|
||||||
|
queueConcurrency: 'Одночасні завантаження',
|
||||||
|
queueConcurrencyGlobal: 'Використовувати загальний ліміт',
|
||||||
|
queueConcurrencyFailed: 'Не вдалося оновити паралельність черги: {{detail}}',
|
||||||
},
|
},
|
||||||
downloadTable: {
|
downloadTable: {
|
||||||
unknownQueue: 'Невідома черга',
|
unknownQueue: 'Невідома черга',
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ const zhCN = {
|
|||||||
connectionCountUnknown: '—/{{total}} 个连接',
|
connectionCountUnknown: '—/{{total}} 个连接',
|
||||||
connectionsUnavailable: '—',
|
connectionsUnavailable: '—',
|
||||||
speedCap: '速度上限',
|
speedCap: '速度上限',
|
||||||
|
liveSpeedLimit: '实时速度上限',
|
||||||
|
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
|
||||||
|
liveSpeedLimitPlaceholder: '例如 1024K',
|
||||||
|
liveSpeedLimitApply: '应用',
|
||||||
|
liveSpeedLimitClear: '清除',
|
||||||
|
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||||
|
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||||
category: '类别',
|
category: '类别',
|
||||||
lastTry: '上次尝试',
|
lastTry: '上次尝试',
|
||||||
dateAdded: '添加日期',
|
dateAdded: '添加日期',
|
||||||
@@ -326,6 +333,9 @@ const zhCN = {
|
|||||||
queueNameExists: '同名队列已存在',
|
queueNameExists: '同名队列已存在',
|
||||||
startQueueFailed: '无法启动队列:{{detail}}',
|
startQueueFailed: '无法启动队列:{{detail}}',
|
||||||
pauseQueueFailed: '无法暂停队列:{{detail}}',
|
pauseQueueFailed: '无法暂停队列:{{detail}}',
|
||||||
|
queueConcurrency: '并行下载数',
|
||||||
|
queueConcurrencyGlobal: '使用全局限制',
|
||||||
|
queueConcurrencyFailed: '无法更新队列并发数:{{detail}}',
|
||||||
},
|
},
|
||||||
downloadTable: {
|
downloadTable: {
|
||||||
unknownQueue: '未知队列',
|
unknownQueue: '未知队列',
|
||||||
|
|||||||
+3
-1
@@ -16,6 +16,7 @@ import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
|
|||||||
import type { EnqueueItem } from './bindings/EnqueueItem';
|
import type { EnqueueItem } from './bindings/EnqueueItem';
|
||||||
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||||
|
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||||
|
|
||||||
type CommandMap = {
|
type CommandMap = {
|
||||||
fetch_metadata: {
|
fetch_metadata: {
|
||||||
@@ -37,7 +38,7 @@ type CommandMap = {
|
|||||||
reveal_in_file_manager: { args: { path: string }; result: void };
|
reveal_in_file_manager: { args: { path: string }; result: void };
|
||||||
open_downloaded_file: { args: { path: string }; result: void };
|
open_downloaded_file: { args: { path: string }; result: void };
|
||||||
pause_download: { args: { id: string }; result: void };
|
pause_download: { args: { id: string }; result: void };
|
||||||
resume_download: { args: { id: string }; result: boolean };
|
resume_download: { args: { id: string; queueId: string }; result: boolean };
|
||||||
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
|
||||||
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
detach_download_for_reconfigure: { args: { id: string }; result: void };
|
||||||
begin_dock_badge_session: { args: undefined; result: number };
|
begin_dock_badge_session: { args: undefined; result: number };
|
||||||
@@ -48,6 +49,7 @@ type CommandMap = {
|
|||||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||||
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
|
||||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||||
|
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
|
||||||
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
|
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
|
||||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||||
request_automation_permission: { args: undefined; result: void };
|
request_automation_permission: { args: undefined; result: void };
|
||||||
|
|||||||
@@ -267,6 +267,166 @@ describe('useDownloadStore', () => {
|
|||||||
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
|
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists a queue concurrency override only after backend synchronization', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
queues: [
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||||
|
|
||||||
|
await useDownloadStore.getState().setQueueConcurrency('queue-a', 2);
|
||||||
|
|
||||||
|
expect(useDownloadStore.getState().queues).toEqual([
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
|
||||||
|
]);
|
||||||
|
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||||
|
'set_queue_concurrency_limits',
|
||||||
|
{
|
||||||
|
limits: [
|
||||||
|
{ id: 'main', maxConcurrent: null },
|
||||||
|
{ id: 'queue-a', maxConcurrent: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the previous queue concurrency after backend synchronization fails', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
queues: [
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().setQueueConcurrency('queue-a', 3))
|
||||||
|
.rejects.toThrow('backend unavailable');
|
||||||
|
expect(useDownloadStore.getState().queues[1].maxConcurrent).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebases queue concurrency updates when queue state changes during IPC', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
queues: [
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true },
|
||||||
|
{ id: 'queue-a', name: 'Downloads', isMain: false }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let releaseFirstSync!: () => void;
|
||||||
|
const firstSyncReleased = new Promise<void>(resolve => { releaseFirstSync = resolve; });
|
||||||
|
let syncCalls = 0;
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'set_queue_concurrency_limits') {
|
||||||
|
syncCalls += 1;
|
||||||
|
if (syncCalls === 1) await firstSyncReleased;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useDownloadStore.getState().setQueueConcurrency('queue-a', 3);
|
||||||
|
await vi.waitFor(() => expect(syncCalls).toBe(1));
|
||||||
|
useDownloadStore.setState(state => ({
|
||||||
|
queues: state.queues.filter(queue => queue.id !== 'queue-a')
|
||||||
|
}));
|
||||||
|
releaseFirstSync();
|
||||||
|
|
||||||
|
await expect(update).rejects.toThrow('Queue no longer exists.');
|
||||||
|
expect(useDownloadStore.getState().queues).toEqual([
|
||||||
|
{ id: 'main', name: 'Main Queue', isMain: true }
|
||||||
|
]);
|
||||||
|
expect(syncCalls).toBe(2);
|
||||||
|
const configCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||||
|
.filter(([command]) => command === 'set_queue_concurrency_limits');
|
||||||
|
expect(configCalls[1]).toEqual([
|
||||||
|
'set_queue_concurrency_limits',
|
||||||
|
{ limits: [{ id: 'main', maxConcurrent: null }] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an active normal download after applying a live speed limit', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'live-speed',
|
||||||
|
status: 'downloading',
|
||||||
|
isMedia: false,
|
||||||
|
speedLimit: '512K'
|
||||||
|
}] as any[]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||||
|
|
||||||
|
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', '2M');
|
||||||
|
|
||||||
|
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_download_speed_limit', {
|
||||||
|
id: 'live-speed',
|
||||||
|
limit: '2M'
|
||||||
|
});
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
|
||||||
|
|
||||||
|
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', null);
|
||||||
|
const speedLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||||
|
.filter(([command]) => command === 'set_download_speed_limit');
|
||||||
|
expect(speedLimitCalls[speedLimitCalls.length - 1]).toEqual(['set_download_speed_limit', {
|
||||||
|
id: 'live-speed',
|
||||||
|
limit: null
|
||||||
|
}]);
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects live speed changes for media and inactive downloads', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [
|
||||||
|
{ id: 'media-speed', status: 'downloading', isMedia: true, speedLimit: '1M' },
|
||||||
|
{ id: 'paused-speed', status: 'paused', isMedia: false, speedLimit: '1M' }
|
||||||
|
] as any[]
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().setDownloadSpeedLimit('media-speed', '2M'))
|
||||||
|
.rejects.toThrow('media downloads');
|
||||||
|
await expect(useDownloadStore.getState().setDownloadSpeedLimit('paused-speed', '2M'))
|
||||||
|
.rejects.toThrow('active download');
|
||||||
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_download_speed_limit', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the prior live speed limit when the backend rejects the update', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'live-speed-failure',
|
||||||
|
status: 'downloading',
|
||||||
|
isMedia: false,
|
||||||
|
speedLimit: '512K'
|
||||||
|
}] as any[]
|
||||||
|
});
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||||
|
if (command === 'set_download_speed_limit') throw new Error('aria2 unavailable');
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(useDownloadStore.getState().setDownloadSpeedLimit('live-speed-failure', '2M'))
|
||||||
|
.rejects.toThrow('aria2 unavailable');
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('512K');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces duplicate live speed updates for one download', async () => {
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{ id: 'live-speed-duplicate', status: 'downloading', isMedia: false }] as any[]
|
||||||
|
});
|
||||||
|
let releaseBackend!: () => void;
|
||||||
|
const backendFinished = new Promise<void>(resolve => { releaseBackend = resolve; });
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async () => backendFinished);
|
||||||
|
|
||||||
|
const first = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '2M');
|
||||||
|
const second = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '3M');
|
||||||
|
expect(second).toBe(first);
|
||||||
|
releaseBackend();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'set_download_speed_limit'))
|
||||||
|
.toHaveLength(1);
|
||||||
|
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes malformed persisted queues around one canonical main queue', () => {
|
it('normalizes malformed persisted queues around one canonical main queue', () => {
|
||||||
expect(normalizePersistedQueues([
|
expect(normalizePersistedQueues([
|
||||||
{ id: 'custom-a', name: ' Downloads ', isMain: false },
|
{ id: 'custom-a', name: ' Downloads ', isMain: false },
|
||||||
@@ -286,6 +446,47 @@ describe('useDownloadStore', () => {
|
|||||||
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
|
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps only valid persisted queue concurrency overrides', () => {
|
||||||
|
expect(normalizePersistedQueues([
|
||||||
|
{ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 },
|
||||||
|
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
|
||||||
|
{ id: 'zero', name: 'Zero', isMain: false, maxConcurrent: 0 },
|
||||||
|
{ id: 'large', name: 'Large', isMain: false, maxConcurrent: 13 },
|
||||||
|
{ id: 'null', name: 'Null', isMain: false, maxConcurrent: null }
|
||||||
|
])).toEqual([
|
||||||
|
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main', isMain: true, maxConcurrent: 4 },
|
||||||
|
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
|
||||||
|
{ id: 'zero', name: 'Zero', isMain: false },
|
||||||
|
{ id: 'large', name: 'Large', isMain: false },
|
||||||
|
{ id: 'null', name: 'Null', isMain: false }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('synchronizes normalized queue limits before startup resume can run', async () => {
|
||||||
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd === 'db_get_all_queues') {
|
||||||
|
return [
|
||||||
|
JSON.stringify({ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 }),
|
||||||
|
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (cmd === 'db_get_all_downloads') return [];
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDownloadStore.getState().initDB();
|
||||||
|
|
||||||
|
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
||||||
|
'set_queue_concurrency_limits',
|
||||||
|
{
|
||||||
|
limits: [
|
||||||
|
{ id: '00000000-0000-0000-0000-000000000001', maxConcurrent: 4 },
|
||||||
|
{ id: 'queue-a', maxConcurrent: null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
it('remaps persisted downloads when queue records are malformed or missing', async () => {
|
||||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||||
if (cmd === 'db_get_all_queues') {
|
if (cmd === 'db_get_all_queues') {
|
||||||
@@ -731,6 +932,10 @@ describe('useDownloadStore', () => {
|
|||||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||||
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
||||||
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
||||||
|
expect(calls.find(c => c[0] === 'resume_download')?.[1]).toEqual({
|
||||||
|
id: 'resume-generation',
|
||||||
|
queueId: 'MAIN'
|
||||||
|
});
|
||||||
expect(enqueueGeneration).toBe('1');
|
expect(enqueueGeneration).toBe('1');
|
||||||
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
||||||
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const downloadLifecycleGenerations = new Map<string, bigint>();
|
|||||||
const queueReorderPromises = new Map<string, Promise<void>>();
|
const queueReorderPromises = new Map<string, Promise<void>>();
|
||||||
const queueStartPromises = new Map<string, Promise<string[]>>();
|
const queueStartPromises = new Map<string, Promise<string[]>>();
|
||||||
const queueControlGenerations = new Map<string, number>();
|
const queueControlGenerations = new Map<string, number>();
|
||||||
|
let queueConfigurationQueue: Promise<void> = Promise.resolve();
|
||||||
type DownloadLifecycleOperation = {
|
type DownloadLifecycleOperation = {
|
||||||
kind: string;
|
kind: string;
|
||||||
promise: Promise<unknown>;
|
promise: Promise<unknown>;
|
||||||
@@ -585,18 +586,39 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
export type { DownloadStatus };
|
export type { DownloadStatus };
|
||||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
|
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
|
||||||
|
const MAX_QUEUE_CONCURRENT = 12;
|
||||||
|
|
||||||
const queueNameKey = (name: string): string => name.trim().toLowerCase();
|
const queueNameKey = (name: string): string => name.trim().toLowerCase();
|
||||||
|
|
||||||
export const normalizePersistedQueueState = (queues: Queue[]) => {
|
const normalizeQueueConcurrency = (value: unknown): number | undefined => {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
|
||||||
|
return value >= 1 && value <= MAX_QUEUE_CONCURRENT ? value : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersistedQueue = Omit<Queue, 'maxConcurrent'> & {
|
||||||
|
maxConcurrent?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueWithNormalizedConcurrency = (queue: PersistedQueue): Queue => {
|
||||||
|
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
|
||||||
|
return maxConcurrent === undefined
|
||||||
|
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||||
|
: { id: queue.id, name: queue.name, isMain: queue.isMain, maxConcurrent };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizePersistedQueueState = (queues: PersistedQueue[]) => {
|
||||||
const validQueues = queues.filter(queue =>
|
const validQueues = queues.filter(queue =>
|
||||||
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
|
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
|
||||||
);
|
).map(queueWithNormalizedConcurrency);
|
||||||
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|
||||||
|| validQueues.find(queue => queue.isMain);
|
|| validQueues.find(queue => queue.isMain);
|
||||||
const persistedMainId = persistedMain?.id.trim();
|
const persistedMainId = persistedMain?.id.trim();
|
||||||
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
|
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
|
||||||
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }];
|
const normalizedMain: Queue = { id: MAIN_QUEUE_ID, name: mainName, isMain: true };
|
||||||
|
if (persistedMain?.maxConcurrent !== undefined) {
|
||||||
|
normalizedMain.maxConcurrent = persistedMain.maxConcurrent;
|
||||||
|
}
|
||||||
|
const normalized: Queue[] = [normalizedMain];
|
||||||
const seenIds = new Set([MAIN_QUEUE_ID]);
|
const seenIds = new Set([MAIN_QUEUE_ID]);
|
||||||
const seenNames = new Set([queueNameKey(mainName)]);
|
const seenNames = new Set([queueNameKey(mainName)]);
|
||||||
const queueIdRemap = new Map<string, string>();
|
const queueIdRemap = new Map<string, string>();
|
||||||
@@ -620,15 +642,37 @@ export const normalizePersistedQueueState = (queues: Queue[]) => {
|
|||||||
}
|
}
|
||||||
seenIds.add(id);
|
seenIds.add(id);
|
||||||
seenNames.add(queueNameKey(name));
|
seenNames.add(queueNameKey(name));
|
||||||
normalized.push({ id, name, isMain: false });
|
const normalizedQueue = { id, name, isMain: false } as Queue;
|
||||||
|
if (queue.maxConcurrent !== undefined) {
|
||||||
|
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
|
||||||
|
if (maxConcurrent !== undefined) normalizedQueue.maxConcurrent = maxConcurrent;
|
||||||
|
}
|
||||||
|
normalized.push(normalizedQueue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { queues: normalized, queueIdRemap };
|
return { queues: normalized, queueIdRemap };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizePersistedQueues = (queues: Queue[]): Queue[] =>
|
export const normalizePersistedQueues = (queues: PersistedQueue[]): Queue[] =>
|
||||||
normalizePersistedQueueState(queues).queues;
|
normalizePersistedQueueState(queues).queues;
|
||||||
|
|
||||||
|
const synchronizeQueueConcurrencyLimits = async (queues: Queue[]): Promise<void> => {
|
||||||
|
await invoke('set_queue_concurrency_limits', {
|
||||||
|
limits: queues.map(queue => ({
|
||||||
|
id: queue.id,
|
||||||
|
maxConcurrent: queue.maxConcurrent ?? null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameQueueConcurrencyConfig = (left: Queue[], right: Queue[]): boolean =>
|
||||||
|
left.length === right.length && left.every((queue, index) => {
|
||||||
|
const other = right[index];
|
||||||
|
return other !== undefined
|
||||||
|
&& queue.id === other.id
|
||||||
|
&& (queue.maxConcurrent ?? null) === (other.maxConcurrent ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
export type { DownloadItem, Queue };
|
export type { DownloadItem, Queue };
|
||||||
export type ExtensionDownloadRequest = ExtensionDownload;
|
export type ExtensionDownloadRequest = ExtensionDownload;
|
||||||
export type AddDownloadAction =
|
export type AddDownloadAction =
|
||||||
@@ -706,6 +750,8 @@ interface DownloadState {
|
|||||||
startAll: () => Promise<number>;
|
startAll: () => Promise<number>;
|
||||||
pauseAll: () => Promise<number>;
|
pauseAll: () => Promise<number>;
|
||||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||||
|
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||||
|
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
|
||||||
addQueue: (name: string) => boolean;
|
addQueue: (name: string) => boolean;
|
||||||
renameQueue: (id: string, name: string) => boolean;
|
renameQueue: (id: string, name: string) => boolean;
|
||||||
removeQueue: (id: string) => Promise<void>;
|
removeQueue: (id: string) => Promise<void>;
|
||||||
@@ -794,7 +840,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
lastTry: new Date().toISOString()
|
lastTry: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
const resumedExisting = await invoke('resume_download', { id });
|
const resumedExisting = await invoke('resume_download', {
|
||||||
|
id,
|
||||||
|
queueId: targetItem.queueId || MAIN_QUEUE_ID
|
||||||
|
});
|
||||||
|
|
||||||
let dispatchSucceeded = resumedExisting;
|
let dispatchSucceeded = resumedExisting;
|
||||||
if (!dispatchSucceeded) {
|
if (!dispatchSucceeded) {
|
||||||
@@ -1551,6 +1600,79 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setDownloadSpeedLimit: (id, limit) => runDownloadLifecycleOperation(
|
||||||
|
id,
|
||||||
|
'speed-limit',
|
||||||
|
async () => {
|
||||||
|
await waitForPendingStartupResume();
|
||||||
|
const item = get().downloads.find(download => download.id === id);
|
||||||
|
if (!item) throw new Error('Download no longer exists.');
|
||||||
|
if (item.isMedia) {
|
||||||
|
throw new Error('Live speed control is unavailable for media downloads.');
|
||||||
|
}
|
||||||
|
if (!['downloading', 'retrying'].includes(item.status)) {
|
||||||
|
throw new Error('Live speed control requires an active download.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = limit?.trim() || '';
|
||||||
|
const normalizedLimit = trimmed
|
||||||
|
? normalizeSpeedLimitForBackend(trimmed)
|
||||||
|
: null;
|
||||||
|
if (trimmed && normalizedLimit === null) {
|
||||||
|
throw new Error('Enter a valid speed limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke('set_download_speed_limit', {
|
||||||
|
id,
|
||||||
|
limit: normalizedLimit
|
||||||
|
});
|
||||||
|
if (get().downloads.some(download => download.id === id)) {
|
||||||
|
get().updateDownload(id, { speedLimit: normalizedLimit ?? undefined });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
preemptDispatch
|
||||||
|
),
|
||||||
|
setQueueConcurrency: (id, maxConcurrent) => {
|
||||||
|
const operation = queueConfigurationQueue.then(async () => {
|
||||||
|
if (
|
||||||
|
maxConcurrent !== null
|
||||||
|
&& (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > MAX_QUEUE_CONCURRENT)
|
||||||
|
) {
|
||||||
|
throw new Error('Queue concurrency must be between 1 and 12.');
|
||||||
|
}
|
||||||
|
const currentQueues = get().queues;
|
||||||
|
if (!currentQueues.some(queue => queue.id === id)) {
|
||||||
|
throw new Error('Queue no longer exists.');
|
||||||
|
}
|
||||||
|
const nextQueues = currentQueues.map(queue =>
|
||||||
|
queue.id === id
|
||||||
|
? maxConcurrent === null
|
||||||
|
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||||
|
: { ...queue, maxConcurrent }
|
||||||
|
: queue
|
||||||
|
);
|
||||||
|
await synchronizeQueueConcurrencyLimits(nextQueues);
|
||||||
|
const latestQueues = get().queues;
|
||||||
|
if (!latestQueues.some(queue => queue.id === id)) {
|
||||||
|
await synchronizeQueueConcurrencyLimits(latestQueues);
|
||||||
|
throw new Error('Queue no longer exists.');
|
||||||
|
}
|
||||||
|
const rebasedQueues = latestQueues.map(queue =>
|
||||||
|
queue.id === id
|
||||||
|
? maxConcurrent === null
|
||||||
|
? { id: queue.id, name: queue.name, isMain: queue.isMain }
|
||||||
|
: { ...queue, maxConcurrent }
|
||||||
|
: queue
|
||||||
|
);
|
||||||
|
if (!sameQueueConcurrencyConfig(nextQueues, rebasedQueues)) {
|
||||||
|
await synchronizeQueueConcurrencyLimits(rebasedQueues);
|
||||||
|
}
|
||||||
|
set({ queues: rebasedQueues });
|
||||||
|
});
|
||||||
|
queueConfigurationQueue = operation.then(() => undefined, () => undefined);
|
||||||
|
return operation;
|
||||||
|
},
|
||||||
addQueue: (name) => {
|
addQueue: (name) => {
|
||||||
const normalizedName = name.trim();
|
const normalizedName = name.trim();
|
||||||
if (!normalizedName) return false;
|
if (!normalizedName) return false;
|
||||||
@@ -1772,7 +1894,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
try {
|
try {
|
||||||
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
|
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
|
||||||
try {
|
try {
|
||||||
return [JSON.parse(value) as Queue];
|
return [JSON.parse(value) as PersistedQueue];
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('Skipping malformed persisted queue record during startup');
|
console.warn('Skipping malformed persisted queue record during startup');
|
||||||
return [];
|
return [];
|
||||||
@@ -1796,6 +1918,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
? normalizeQueuePositions(downloads)
|
? normalizeQueuePositions(downloads)
|
||||||
: state.downloads
|
: state.downloads
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The backend dispatcher is live before the frontend finishes startup.
|
||||||
|
// Synchronize the normalized queue policy before any saved download is
|
||||||
|
// allowed to claim a permit.
|
||||||
|
await synchronizeQueueConcurrencyLimits(queues);
|
||||||
|
|
||||||
// Reset interrupted active downloads to queued.
|
// Reset interrupted active downloads to queued.
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user