fix(download): require aria2 for file transfers

Update yt-dlp to 2026.07.04 and refresh the bundled macOS runtime plus engine locks.

Route every non-media download through aria2 by removing the native HTTP fallback path, native GID handling, and the old direct-download harness.

Retry transient aria2 startup/RPC failures before failing, then show the real last error in the download row and Properties modal so Windows failures are diagnosable instead of silent.

Refresh docs and tests around the aria2-only file-download contract, and advance the Firefox extension submodule to its published wording cleanup.
This commit is contained in:
NimBold
2026-07-05 20:00:53 +03:30
parent 3aaf32f9ee
commit 757e313f71
88 changed files with 251 additions and 1423 deletions
+3 -609
View File
@@ -1,74 +1,18 @@
use crate::DownloadProgressEvent;
use futures_util::StreamExt;
use reqwest::{
header::{self, HeaderMap, HeaderName, HeaderValue},
Client, StatusCode,
};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
str::FromStr,
time::{Duration, Instant},
};
use std::collections::{HashMap, HashSet};
use tauri::{AppHandle, Emitter};
use tokio::{
fs::{self, OpenOptions},
io::{AsyncWriteExt, BufWriter},
sync::{mpsc, watch},
};
use uuid::Uuid;
const PROGRESS_INTERVAL: Duration = Duration::from_millis(1000);
const WRITE_BUFFER_CAPACITY: usize = 256 * 1024;
use tokio::sync::{mpsc, watch};
#[derive(Debug)]
pub enum DownloadCmd {
Start(Box<DownloadPayload>),
Pause(Uuid),
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
CancelWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
CaptureUrls(Vec<String>),
FrontendReady(bool),
}
#[derive(Clone, Debug, PartialEq)]
pub enum DownloadEvent {
Progress {
id: Uuid,
fraction: f64,
completed: u64,
total: Option<u64>,
},
Completed(Uuid),
Failed {
id: Uuid,
error: String,
},
/// Transient network drop: a backoff retry is scheduled and the slot is
/// still held. Carries the 0-based strike number and the classified reason.
Retrying {
id: Uuid,
strike: usize,
reason: String,
},
CapturedUrls(String),
}
#[derive(Debug)]
pub struct DownloadPayload {
pub id: Uuid,
pub urls: Vec<String>,
pub output_path: PathBuf,
pub speed_limit: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub headers: Option<String>,
pub cookies: Option<String>,
pub user_agent: Option<String>,
pub max_tries: u32,
pub proxy: Option<String>,
}
#[derive(Clone)]
pub struct DownloadCoordinator {
tx: mpsc::Sender<DownloadCmd>,
@@ -141,107 +85,6 @@ enum CoordinatorEventSink {
}
impl CoordinatorEventSink {
fn emit_progress(
&self,
id: Uuid,
completed: u64,
total: Option<u64>,
interval_bytes: u64,
interval: Duration,
) {
let speed_bytes = if interval.is_zero() {
0.0
} else {
interval_bytes as f64 / interval.as_secs_f64()
};
let fraction = total
.filter(|total| *total > 0)
.map(|total| completed as f64 / total as f64)
.unwrap_or(0.0)
.clamp(0.0, 1.0);
match self {
Self::Tauri(app_handle) => {
let eta = total
.filter(|total| speed_bytes > 0.0 && *total > completed)
.map(|total| format_duration((total - completed) as f64 / speed_bytes))
.unwrap_or_else(|| "-".to_string());
let _ = app_handle.emit(
"download-progress",
DownloadProgressEvent {
id: id.to_string(),
fraction,
speed: format_speed(speed_bytes),
eta,
size: total.map(|t| format_size(t as f64)),
size_is_final: false,
},
);
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Progress {
id,
fraction,
completed,
total,
});
}
}
}
fn emit_completed(&self, id: Uuid) {
match self {
Self::Tauri(app_handle) => {
let _ = app_handle.emit("download-complete", id.to_string());
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Completed(id));
}
}
}
fn emit_failed(&self, id: Uuid, error: String) {
match self {
Self::Tauri(app_handle) => {
log::error!("native download {} failed: {}", id, error);
let _ = app_handle.emit("download-failed", id.to_string());
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Failed { id, error });
}
}
}
/// Emit a transient `Retrying` state. In production this drives the
/// `download-state` event with status `retrying` (consumed by the queue's
/// completion listener and the frontend store); in headless tests it flows
/// through the `DownloadEvent` channel. The strike is 0-based and becomes
/// the human-facing attempt number (strike + 1).
fn emit_retrying(&self, id: Uuid, strike: usize, reason: String) {
match self {
Self::Tauri(app_handle) => {
use crate::ipc::{DownloadStateEvent, DownloadStatus};
let attempt = strike + 1;
let payload = DownloadStateEvent::retrying(
id.to_string(),
format!("Network drop — retry #{attempt}: {reason}"),
);
// Drive the same `download-state` channel the queue emits on
// so the frontend status flips to `retrying` uniformly.
let _ = app_handle.emit("download-state", payload);
log::warn!(
"download {id} transient error, backing off before retry #{attempt}: {reason}"
);
// Keep the compiler honest about DownloadStatus being used if a
// future refactor drops the `retrying` constructor path.
let _ = DownloadStatus::Retrying.as_str();
}
Self::Headless(event_tx) => {
let _ = event_tx.send(DownloadEvent::Retrying { id, strike, reason });
}
}
}
fn emit_captured_urls(&self, payload: String) -> bool {
match self {
Self::Tauri(app_handle) => app_handle.emit("deep-link-add-download", payload).is_ok(),
@@ -260,46 +103,15 @@ enum MediaCmd {
Finished(String),
}
#[derive(Debug, Clone, Copy)]
enum DownloadControl {
Pause,
Cancel,
Replace,
}
struct ActiveDownload {
generation: u64,
control_tx: mpsc::Sender<DownloadControl>,
}
enum WorkerEvent {
Finished {
id: Uuid,
generation: u64,
outcome: DownloadOutcome,
},
}
enum DownloadOutcome {
Completed,
Paused,
Cancelled,
Failed(String),
}
async fn run_coordinator(
events: CoordinatorEventSink,
mut command_rx: mpsc::Receiver<DownloadCmd>,
mut media_rx: mpsc::Receiver<MediaCmd>,
) {
let (worker_tx, mut worker_rx) = mpsc::channel(128);
let mut active = HashMap::<Uuid, ActiveDownload>::new();
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
let mut pending_acks = HashMap::<Uuid, tokio::sync::oneshot::Sender<()>>::new();
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
let mut pending_captured_urls = Vec::<String>::new();
let mut frontend_ready = false;
let mut next_generation = 0_u64;
loop {
tokio::select! {
@@ -309,48 +121,6 @@ async fn run_coordinator(
};
match command {
DownloadCmd::Start(payload_box) => {
let payload = *payload_box;
if let Some(previous) = active.remove(&payload.id) {
let _ = previous.control_tx.send(DownloadControl::Replace).await;
}
next_generation = next_generation.wrapping_add(1);
let generation = next_generation;
let id = payload.id;
let (control_tx, control_rx) = mpsc::channel(1);
active.insert(id, ActiveDownload { generation, control_tx });
let events = events.clone();
let worker_tx = worker_tx.clone();
tauri::async_runtime::spawn(async move {
let outcome = download_file(events, payload, control_rx).await;
let _ = worker_tx
.send(WorkerEvent::Finished { id, generation, outcome })
.await;
});
}
DownloadCmd::Pause(id) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Pause).await;
}
}
DownloadCmd::PauseWithAck(id, ack) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Pause).await;
pending_acks.insert(id, ack);
} else {
let _ = ack.send(());
}
}
DownloadCmd::CancelWithAck(id, ack) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
pending_acks.insert(id, ack);
} else {
let _ = ack.send(());
}
}
DownloadCmd::CaptureUrls(urls) => {
append_unique_urls(&mut pending_captured_urls, urls);
if frontend_ready && !pending_captured_urls.is_empty() {
@@ -371,32 +141,6 @@ async fn run_coordinator(
}
}
}
event = worker_rx.recv() => {
let Some(WorkerEvent::Finished { id, generation, outcome }) = event else {
continue;
};
let is_current = active
.get(&id)
.is_some_and(|download| download.generation == generation);
if is_current {
active.remove(&id);
}
if let Some(ack) = pending_acks.remove(&id) {
let _ = ack.send(());
}
match (is_current, outcome) {
(true, DownloadOutcome::Completed) => {
events.emit_completed(id);
}
(true, DownloadOutcome::Failed(error)) => {
events.emit_failed(id, error);
}
_ => {}
}
}
command = media_rx.recv() => {
let Some(command) = command else {
continue;
@@ -431,9 +175,6 @@ async fn run_coordinator(
}
}
for (_, download) in active {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
}
for (_, cancel_tx) in active_media {
let _ = cancel_tx.send(true);
}
@@ -444,315 +185,6 @@ fn append_unique_urls(target: &mut Vec<String>, urls: Vec<String>) {
target.extend(urls.into_iter().filter(|url| seen.insert(url.clone())));
}
async fn download_file(
events: CoordinatorEventSink,
payload: DownloadPayload,
mut control_rx: mpsc::Receiver<DownloadControl>,
) -> DownloadOutcome {
if let Some(parent) = payload.output_path.parent() {
if let Err(error) = fs::create_dir_all(parent).await {
return DownloadOutcome::Failed(error.to_string());
}
}
let (client, default_headers) = match build_client(&payload) {
Ok(client) => client,
Err(error) => return DownloadOutcome::Failed(error),
};
let mut last_error = "no download URL was provided".to_string();
// Connection-aware retry policy. A transient network drop never transitions
// the download straight to `Failed`: it is classified, the UI is told the
// item is `Retrying`, and a 3-strike exponential backoff (2s/5s/10s from
// `retry::BACKOFF_SCHEDULE`) runs before the next attempt — all while the
// worker slot stays held (the coordinator does not drop the active entry
// until this future resolves). `download_attempt` re-issues a Range header
// from the existing partial file on every retry, so no bytes are discarded.
//
// `max_tries` is the user-facing retry count. Attempts include the first
// try plus those configured retries.
let max_retries = payload.max_tries as usize;
let max_attempts = max_retries + 1;
'url: for url in &payload.urls {
let mut strike = 0_usize;
let mut attempts = 0_usize;
loop {
attempts += 1;
match download_attempt(
&events,
&client,
&default_headers,
&payload,
url,
&mut control_rx,
)
.await
{
Ok(()) => return DownloadOutcome::Completed,
Err(AttemptError::Controlled(DownloadControl::Pause)) => {
return DownloadOutcome::Paused;
}
Err(AttemptError::Controlled(DownloadControl::Cancel)) => {
if let Err(e) = fs::remove_file(&payload.output_path).await {
log::warn!(
"Failed to remove cancelled file '{}': {}",
payload.output_path.display(),
e
);
}
return DownloadOutcome::Cancelled;
}
Err(AttemptError::Controlled(DownloadControl::Replace)) => {
return DownloadOutcome::Cancelled;
}
Err(AttemptError::Failed(error)) => {
last_error = error.clone();
if attempts >= max_attempts {
continue 'url;
}
let transient = crate::retry::is_transient_network_error(&error);
let strikes_left = strike < max_retries;
if transient && strikes_left {
// Transient: announce `Retrying`, back off, then retry.
// The backoff sleep is itself cancelable so a user
// pause/cancel during the wait is honored immediately.
events.emit_retrying(payload.id, strike, error);
let delay = crate::retry::backoff_for(strike);
tokio::select! {
_ = tokio::time::sleep(delay) => {}
control = control_rx.recv() => {
return match control.unwrap_or(DownloadControl::Cancel) {
DownloadControl::Pause => DownloadOutcome::Paused,
DownloadControl::Cancel => {
if let Err(e) = fs::remove_file(&payload.output_path).await {
log::warn!("Failed to remove cancelled file '{}': {}", payload.output_path.display(), e);
}
DownloadOutcome::Cancelled
}
DownloadControl::Replace => DownloadOutcome::Cancelled,
};
}
}
strike += 1;
continue;
}
if !transient && !crate::retry::is_permanent_network_error(&error) {
// Legacy `max_tries` cap for ambiguous HTTP statuses (e.g.
// 500) that are neither clearly transient nor permanent.
tokio::time::sleep(Duration::from_millis(500)).await;
continue;
}
// Permanent error or transient strike budget exhausted.
continue 'url;
}
}
}
}
DownloadOutcome::Failed(last_error)
}
enum AttemptError {
Controlled(DownloadControl),
Failed(String),
}
async fn download_attempt(
events: &CoordinatorEventSink,
client: &Client,
default_headers: &reqwest::header::HeaderMap,
payload: &DownloadPayload,
url: &str,
control_rx: &mut mpsc::Receiver<DownloadControl>,
) -> Result<(), AttemptError> {
let existing_len = fs::metadata(&payload.output_path)
.await
.map(|metadata| metadata.len())
.unwrap_or(0);
let mut request = client.get(url).headers(default_headers.clone());
if existing_len > 0 {
request = request.header(header::RANGE, format!("bytes={existing_len}-"));
}
if let Some(username) = payload
.username
.as_deref()
.filter(|value| !value.is_empty())
{
request = request.basic_auth(username, payload.password.as_deref());
}
let response = tokio::select! {
control = control_rx.recv() => {
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
response = request.send() => {
response.map_err(|error| AttemptError::Failed(error.to_string()))?
}
};
if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) {
return Err(AttemptError::Failed(format!(
"{url} returned HTTP {}",
response.status()
)));
}
let resumed = existing_len > 0 && response.status() == StatusCode::PARTIAL_CONTENT;
if resumed {
let content_range = response
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|h| h.to_str().ok());
if !content_range.is_some_and(|r| r.starts_with(&format!("bytes {}-", existing_len))) {
return Err(AttemptError::Failed(
"Server returned invalid Content-Range for resume".to_string(),
));
}
}
let completed_at_start = if resumed { existing_len } else { 0 };
let total_len = response
.content_length()
.map(|remaining| remaining.saturating_add(completed_at_start));
let file = OpenOptions::new()
.create(true)
.write(true)
.append(resumed)
.truncate(!resumed)
.open(&payload.output_path)
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
let mut stream = response.bytes_stream();
let mut last_emitted_at = Instant::now();
let mut last_emitted_bytes = completed_at_start;
let mut completed = completed_at_start;
let speed_limit = payload.speed_limit.as_deref().and_then(parse_speed_limit);
let transfer_started_at = Instant::now();
let mut transferred_this_attempt = 0_u64;
loop {
tokio::select! {
control = control_rx.recv() => {
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
chunk = stream.next() => {
match chunk {
Some(Ok(bytes)) => {
writer
.write_all(&bytes)
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
completed = completed.saturating_add(bytes.len() as u64);
transferred_this_attempt =
transferred_this_attempt.saturating_add(bytes.len() as u64);
if let Some(bytes_per_second) = speed_limit {
let expected_elapsed =
Duration::from_secs_f64(transferred_this_attempt as f64 / bytes_per_second as f64);
let actual_elapsed = transfer_started_at.elapsed();
if expected_elapsed > actual_elapsed {
tokio::select! {
control = control_rx.recv() => {
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
}
_ = tokio::time::sleep(expected_elapsed - actual_elapsed) => {}
}
}
}
let now = Instant::now();
let interval = now.duration_since(last_emitted_at);
if interval >= PROGRESS_INTERVAL {
events.emit_progress(
payload.id,
completed,
total_len,
completed.saturating_sub(last_emitted_bytes),
interval,
);
last_emitted_at = now;
last_emitted_bytes = completed;
}
}
Some(Err(error)) => {
writer.flush().await.map_err(|flush_error| AttemptError::Failed(flush_error.to_string()))?;
return Err(AttemptError::Failed(error.to_string()));
}
None => break,
}
}
}
}
writer
.flush()
.await
.map_err(|error| AttemptError::Failed(error.to_string()))?;
events.emit_progress(
payload.id,
completed,
total_len,
completed.saturating_sub(last_emitted_bytes),
last_emitted_at.elapsed(),
);
Ok(())
}
fn build_client(payload: &DownloadPayload) -> Result<(Client, HeaderMap), String> {
let mut headers = HeaderMap::new();
if let Some(raw_headers) = payload.headers.as_deref() {
for line in raw_headers
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let (name, value) = line
.split_once(':')
.ok_or_else(|| format!("invalid HTTP header: {line}"))?;
headers.insert(
HeaderName::from_str(name.trim()).map_err(|error| error.to_string())?,
HeaderValue::from_str(value.trim()).map_err(|error| error.to_string())?,
);
}
}
if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) {
headers.insert(
header::COOKIE,
HeaderValue::from_str(cookies).map_err(|error| error.to_string())?,
);
}
let mut builder = Client::builder();
if let Some(user_agent) = payload
.user_agent
.as_deref()
.filter(|value| !value.is_empty())
{
builder = builder.user_agent(user_agent);
}
if let Some(proxy) = payload.proxy.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
if proxy.eq_ignore_ascii_case("none") {
builder = builder.no_proxy();
} else {
builder = builder.proxy(
reqwest::Proxy::all(proxy)
.map_err(|_| "Invalid proxy URL configured".to_string())?,
);
}
}
builder
.build()
.map_err(|error| error.to_string())
.map(|c| (c, headers))
}
pub(crate) fn format_speed(bytes_per_second: f64) -> String {
if bytes_per_second >= 1024.0 * 1024.0 {
format!("{:.1} MB/s", bytes_per_second / (1024.0 * 1024.0))
@@ -785,49 +217,11 @@ pub(crate) fn format_duration(seconds: f64) -> String {
}
}
fn parse_speed_limit(value: &str) -> Option<u64> {
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() || normalized == "0" {
return None;
}
let (number, multiplier) = if let Some(number) = normalized.strip_suffix("kb/s") {
(number, 1024.0)
} else if let Some(number) = normalized.strip_suffix("mb/s") {
(number, 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix("gb/s") {
(number, 1024.0 * 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix('k') {
(number, 1024.0)
} else if let Some(number) = normalized.strip_suffix('m') {
(number, 1024.0 * 1024.0)
} else if let Some(number) = normalized.strip_suffix('g') {
(number, 1024.0 * 1024.0 * 1024.0)
} else {
(normalized.as_str(), 1.0)
};
number
.trim()
.parse::<f64>()
.ok()
.filter(|number| *number > 0.0)
.map(|number| (number * multiplier) as u64)
}
#[cfg(test)]
mod tests {
use super::{parse_speed_limit, DownloadCmd, DownloadCoordinator, DownloadEvent};
use super::{DownloadCmd, DownloadCoordinator, DownloadEvent};
use std::time::Duration;
#[test]
fn parses_aria_style_speed_limits() {
assert_eq!(parse_speed_limit("512K"), Some(512 * 1024));
assert_eq!(parse_speed_limit("1.5M"), Some(1_572_864));
assert_eq!(parse_speed_limit("2 MB/s"), Some(2 * 1024 * 1024));
assert_eq!(parse_speed_limit("0"), None);
}
#[tokio::test]
async fn buffers_captured_urls_until_frontend_is_ready() {
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
+2
View File
@@ -108,6 +108,8 @@ pub struct DownloadItem {
pub queue_position: Option<i32>,
#[ts(optional)]
pub has_been_dispatched: Option<bool>,
#[ts(optional)]
pub last_error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+3 -62
View File
@@ -11,7 +11,6 @@ use std::time::{Duration, Instant};
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use ts_rs::TS;
use uuid::Uuid;
fn get_metadata_cache() -> &'static std::sync::Mutex<HashMap<String, String>> {
static CACHE: OnceLock<std::sync::Mutex<HashMap<String, String>>> = OnceLock::new();
@@ -2942,7 +2941,7 @@ async fn pause_download(
let removed_pending = state.queue_manager.remove_from_pending(&id).await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
if let Some(gid) = gid.as_deref() {
let status = aria2_download_status(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
@@ -3013,11 +3012,6 @@ async fn pause_download(
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state
.download_coordinator
.send(download::DownloadCmd::PauseWithAck(download_id, tx))
.await?;
} else {
let _ = tx.send(());
}
@@ -3052,13 +3046,6 @@ async fn resume_download(
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
};
if gid.starts_with("native:") {
state.queue_manager.forget_aria2_gid(&id).await;
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
}
let status = aria2_download_status(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
@@ -3212,7 +3199,7 @@ async fn remove_download(
state.queue_manager.cancel_aria2_retries(&id).await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
if let Some(gid) = gid.as_deref() {
let removal_result = async {
force_remove_aria2_gid(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
@@ -3243,13 +3230,6 @@ async fn remove_download(
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
let command = if delete_assets {
download::DownloadCmd::CancelWithAck(download_id, tx)
} else {
download::DownloadCmd::PauseWithAck(download_id, tx)
};
state.download_coordinator.send(command).await?;
} else {
let _ = tx.send(());
}
@@ -3356,7 +3336,7 @@ async fn detach_download_for_reconfigure(
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
if let Some(gid) = gid.as_deref() {
let removal_result = async {
let pause_res = rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
@@ -3396,11 +3376,6 @@ async fn detach_download_for_reconfigure(
.download_coordinator
.pause_media_with_ack(id.clone(), tx)
.await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state
.download_coordinator
.send(crate::download::DownloadCmd::PauseWithAck(download_id, tx))
.await?;
} else {
let _ = tx.send(()); // Fallback if no task exists
}
@@ -5007,7 +4982,6 @@ pub fn run() {
dispatcher_mgr.run_dispatcher().await;
});
let queue_manager_clone = Arc::clone(&queue_manager);
let queue_manager_poll = Arc::clone(&queue_manager);
app.manage(AppState {
@@ -5024,39 +4998,6 @@ pub fn run() {
queue_manager,
});
// Backend listener: release permits + emit terminal state for
// native (and aria2-fallback) downloads. Idempotent for Media/aria2
// which already release via finish_runner/handle_aria2_event.
let completion_app = app.handle().clone();
let completion_mgr = Arc::clone(&queue_manager_clone);
tauri::async_runtime::spawn(async move {
use tauri::Listener;
let rx_complete = completion_app.listen("download-complete", move |event| {
let raw_id = event.payload();
let id: String = serde_json::from_str(raw_id)
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
let mgr = Arc::clone(&completion_mgr);
tauri::async_runtime::spawn(async move {
mgr.apply_completion(&id, crate::queue::PendingOutcome::Complete).await;
});
});
let completion_app2 = completion_app.clone();
let completion_mgr2 = Arc::clone(&queue_manager_clone);
let rx_failed = completion_app2.listen("download-failed", move |event| {
let raw_id = event.payload();
let id: String = serde_json::from_str(raw_id)
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
let mgr = Arc::clone(&completion_mgr2);
tauri::async_runtime::spawn(async move {
mgr.apply_completion(&id, crate::queue::PendingOutcome::Error("download failed".to_string())).await;
});
});
// Keep the task alive; the listeners are unregistered on drop.
std::future::pending::<()>().await;
let _ = rx_complete;
let _ = rx_failed;
});
let deep_link_app = app.handle().clone();
#[cfg(target_os = "linux")]
if let Err(error) = app.deep_link().register_all() {
+63 -99
View File
@@ -28,7 +28,6 @@ pub enum PendingOutcome {
pub enum TaskKind {
Aria2,
Media,
Native,
}
/// Everything needed to start a sidecar, captured at enqueue time so the
@@ -64,7 +63,7 @@ pub struct SpawnPayload {
pub is_media: bool,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp/native
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
/// runners; in tests it is replaced with a fake that records calls and
/// optionally hangs to simulate a long-running download.
#[async_trait::async_trait]
@@ -80,9 +79,6 @@ pub trait SidecarSpawner: Send + Sync + 'static {
/// Run a media download to completion. The permit is parked for the full
/// duration; release is handled by QueueManager on the runner's exit.
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
/// Run a native HTTP download to completion.
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
}
/// The centralized concurrency gatekeeper. One instance lives in AppState.
@@ -407,7 +403,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
let id = task.id.clone();
// Park the permit BEFORE spawning. Uniform parking:
// aria2's RPC returns instantly, so the permit must outlive the
// dispatch_one call. Media/Native runners release on exit.
// dispatch_one call. Media runners release on exit.
self.park_permit(&id, permit).await;
self.active_kinds
.lock()
@@ -432,15 +428,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
id,
gid
);
if !gid.starts_with("native:") {
if let Err(error) = self.spawner.remove_uri(&gid).await {
log::warn!(
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
id,
gid,
error
);
}
if let Err(error) = self.spawner.remove_uri(&gid).await {
log::warn!(
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
id,
gid,
error
);
}
self.clear_aria2_retry_state(&id).await;
self.release_permit(&id).await;
@@ -464,21 +458,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
this.finish_runner(&id_for_task, outcome).await;
});
}
TaskKind::Native => {
// Native coordinator is event-driven (fire-and-observe). Send
// Start; completion is handled by the download-complete/
// download-failed listener in lib.rs setup() which calls
// release_permit + apply_completion.
let this = Arc::clone(&self);
let payload = task.payload.clone();
let id_for_task = id.clone();
tauri::async_runtime::spawn(async move {
if let Err(error) = this.spawner.run_native(&id_for_task, &payload).await {
this.emit_failed(&id_for_task, error);
this.release_permit(&id_for_task).await;
}
});
}
}
}
@@ -924,6 +903,17 @@ fn is_retryable_aria2_error(error: &str) -> bool {
is_transient_network_error(error) || is_aria2_range_mode_error(error)
}
fn is_aria2_rpc_unavailable(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
is_transient_network_error(error)
|| lower.contains("aria2 did not become ready")
|| lower.contains("connection refused")
|| lower.contains("failed to connect")
|| lower.contains("error trying to connect")
|| lower.contains("connection closed")
|| lower.contains("connection reset")
}
fn is_aria2_range_mode_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("invalid range header")
@@ -1123,8 +1113,7 @@ fn parse_content_range_bounds(value: &str) -> Option<(u64, u64)> {
Some((start.trim().parse().ok()?, end.trim().parse().ok()?))
}
/// Production spawner that delegates to the real aria2 RPC, yt-dlp, and
/// native coordinator runners.
/// Production spawner that delegates to the real aria2 RPC and yt-dlp runners.
pub struct ProductionSpawner {
app_handle: AppHandle<tauri::Wry>,
}
@@ -1133,6 +1122,32 @@ impl ProductionSpawner {
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
Self { app_handle }
}
async fn add_uri_rpc(
&self,
state: &crate::AppState,
params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
loop {
match crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.addUri",
params.clone(),
)
.await
{
Ok(result) => return Ok(result),
Err(error) => {
if !is_aria2_rpc_unavailable(&error) || std::time::Instant::now() >= deadline {
return Err(error);
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
}
}
}
#[async_trait::async_trait]
@@ -1210,14 +1225,7 @@ impl SidecarSpawner for ProductionSpawner {
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
let params = serde_json::json!([uris, options]);
match crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.addUri",
params,
)
.await
{
match self.add_uri_rpc(&state, &params).await {
Ok(result) => {
let gid = result.as_str().unwrap_or("").to_string();
if gid.is_empty() {
@@ -1228,35 +1236,8 @@ impl SidecarSpawner for ProductionSpawner {
}
}
Err(e) => {
// aria2 unavailable — fall back to native coordinator.
log::warn!("aria2 addUri failed, falling back to native: {}", e);
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = automatic_retry_limit(payload.max_tries) as u32;
let safe_filename =
crate::download_ownership::canonical_download_filename(&payload.filename);
state
.download_coordinator
.send(crate::download::DownloadCmd::Start(Box::new(
crate::download::DownloadPayload {
id: download_id,
urls: crate::collect_download_uris(
&payload.url,
payload.mirrors.as_deref(),
),
output_path: resolved_dest.join(safe_filename),
speed_limit: payload.speed_limit.clone(),
username: payload.username.clone(),
password: payload.password.clone(),
headers: payload.headers.clone(),
cookies: payload.cookies.clone(),
user_agent: payload.user_agent.clone(),
max_tries: mt,
proxy: payload.proxy.clone(),
},
)))
.await
.map_err(|e| e.to_string())?;
Ok(format!("native:{id}"))
log::error!("aria2 addUri [{}] failed: {}", id, e);
Err(format!("aria2 addUri failed: {e}"))
}
}
}
@@ -1320,36 +1301,6 @@ impl SidecarSpawner for ProductionSpawner {
.await;
outcome.map(|_| ())
}
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
let state = self.app_handle.state::<crate::AppState>();
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = automatic_retry_limit(payload.max_tries) as u32;
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
let safe_filename =
crate::download_ownership::canonical_download_filename(&payload.filename);
let output_path = resolved_dest.join(safe_filename);
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &output_path);
state
.download_coordinator
.send(crate::download::DownloadCmd::Start(Box::new(
crate::download::DownloadPayload {
id: download_id,
urls: crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()),
output_path,
speed_limit: payload.speed_limit.clone(),
username: payload.username.clone(),
password: payload.password.clone(),
headers: payload.headers.clone(),
cookies: payload.cookies.clone(),
user_agent: payload.user_agent.clone(),
max_tries: mt,
proxy: payload.proxy.clone(),
},
)))
.await?;
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, TS)]
@@ -1464,4 +1415,17 @@ mod tests {
));
assert!(!is_retryable_aria2_error("No URI available."));
}
#[test]
fn aria2_startup_rpc_errors_are_retryable() {
assert!(is_aria2_rpc_unavailable(
"error trying to connect: tcp connect error: Connection refused"
));
assert!(is_aria2_rpc_unavailable(
"aria2 did not become ready: connection refused"
));
assert!(!is_aria2_rpc_unavailable(
"aria2 error code 3: Resource not found"
));
}
}
+9 -14
View File
@@ -1,5 +1,5 @@
//! Connection-aware retry engine, shared by all three download backends
//! (native `reqwest`, `yt-dlp` media, and `aria2c`).
//! Connection-aware retry engine, shared by aria2c file downloads and yt-dlp
//! media downloads.
//!
//! ## Design contract
//!
@@ -9,11 +9,9 @@
//! allocation (semaphore permit / worker slot) is preserved.
//!
//! This module is deliberately runtime-agnostic and free of Tauri types so it
//! can be unit-tested headlessly. Each backend translates the schedule into its
//! own state-emission + cancellation vocabulary:
//! can be unit-tested headlessly. Each path translates the schedule into its
//! own state-emission and cancellation vocabulary:
//!
//! - **Native** (`download.rs`): calls [`BACKOFF_SCHEDULE`] inside the existing
//! `control_rx` `tokio::select!`, so pause/cancel still interrupt backoff.
//! - **yt-dlp** (`lib.rs`): sleeps between child re-spawns; `--continue` resumes.
//! - **aria2** (`queue.rs` / WS poller): sleeps before re-issuing `aria2.addUri`.
//!
@@ -67,10 +65,8 @@ pub fn backoff_for(strike: usize) -> Duration {
/// Classify an error string as a transient network condition worth retrying.
///
/// Returns `true` for socket drops, connect/read timeouts, connection resets,
/// and HTTP 408 / request-timeout conditions across all three backends:
/// and HTTP 408 / request-timeout conditions across both download paths:
///
/// - **reqwest**: `error.is_timeout()`, `error.is_connect()` surface as
/// "operation timed out", "error sending request", "connection reset".
/// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`,
/// `HTTP Error 408`.
/// - **aria2c**: `Timeout.`, `Connection was closed by server`.
@@ -103,7 +99,7 @@ pub fn is_transient_network_error(message: &str) -> bool {
let m = message.to_ascii_lowercase();
const TRANSIENT: [&str; 35] = [
// reqwest / hyper / OS socket-layer
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
"timed out",
"timeout",
"connection reset",
@@ -231,7 +227,7 @@ mod tests {
// --- transient classification: positive cases -------------------------
#[test]
fn classifies_reqwest_timeouts_as_transient() {
fn classifies_socket_timeouts_as_transient() {
assert!(is_transient_network_error("operation timed out"));
assert!(is_transient_network_error(
"error sending request: operation timed out"
@@ -305,9 +301,8 @@ mod tests {
#[test]
fn permanent_keyword_wins_over_transient_in_composite_message() {
// The native backend formats HTTP statuses as "{url} returned HTTP {status}"
// (download.rs). A 404 whose URL happens to contain "timeout" must still
// fail fast because the explicit "http 404" token wins.
// A 404 whose URL happens to contain "timeout" must still fail fast
// because the explicit "http 404" token wins.
assert!(!is_transient_network_error(
"https://site/timeout-page returned HTTP 404 Not Found"
));