mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-03 16:08:02 +00:00
fix(downloads): harden power management and aria2 recovery
This commit is contained in:
@@ -378,6 +378,8 @@ pub struct PersistedSettings {
|
||||
pub ask_where_to_save_each_file: bool,
|
||||
pub remember_last_used_download_directory: bool,
|
||||
pub prevents_sleep_while_downloading: bool,
|
||||
#[serde(default)]
|
||||
pub prevents_display_sleep_while_downloading: bool,
|
||||
pub media_cookie_source: MediaCookieSource,
|
||||
pub site_logins: Vec<SiteLogin>,
|
||||
pub auto_check_updates: bool,
|
||||
|
||||
+165
-110
@@ -2911,6 +2911,7 @@ pub mod error;
|
||||
#[allow(dead_code)]
|
||||
pub mod ipc;
|
||||
mod parity;
|
||||
mod power;
|
||||
mod platform;
|
||||
pub mod queue;
|
||||
pub mod process;
|
||||
@@ -2943,7 +2944,7 @@ pub struct AppState {
|
||||
pub aria2_port: std::sync::Arc<std::sync::atomic::AtomicU16>,
|
||||
pub aria2_secret: String,
|
||||
pub media_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
pub sleep_preventer: Arc<Mutex<Option<SleepPreventer>>>,
|
||||
pub power_manager: Arc<power::PowerManager>,
|
||||
pub scheduler_settings: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||
pub queue_manager: Arc<queue::QueueManager>,
|
||||
}
|
||||
@@ -5150,6 +5151,33 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) {
|
||||
gid,
|
||||
error
|
||||
);
|
||||
if aria2_gid_not_found(&error)
|
||||
&& state.queue_manager.has_active_permit(&id).await
|
||||
&& state
|
||||
.queue_manager
|
||||
.aria2_gid_mapping(&gid)
|
||||
.is_some_and(|mapping| mapping.id == id)
|
||||
{
|
||||
if let Some(mapping) = state.queue_manager.aria2_gid_mapping(&gid) {
|
||||
log::warn!(
|
||||
"aria2 reconnect reconciliation [{}]: mapped gid {} is missing; attempting payload recovery",
|
||||
id,
|
||||
gid
|
||||
);
|
||||
if let Err(recovery_error) = state
|
||||
.queue_manager
|
||||
.refresh_aria2_connections(&id, &gid, mapping.epoch)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"aria2 reconnect reconciliation [{}]: payload recovery for missing gid {} failed: {}",
|
||||
id,
|
||||
gid,
|
||||
recovery_error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -5222,7 +5250,7 @@ fn aria2_daemon_process_exited(app_handle: &tauri::AppHandle) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn aria2_gid_not_found(error: &str) -> bool {
|
||||
pub(crate) fn aria2_gid_not_found(error: &str) -> bool {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
lower.contains("gid") && lower.contains("not found")
|
||||
}
|
||||
@@ -5339,115 +5367,20 @@ fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformI
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_sleep {
|
||||
use std::ffi::c_void;
|
||||
#[allow(clippy::duplicated_attributes)]
|
||||
#[link(name = "IOKit", kind = "framework")]
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
extern "C" {
|
||||
pub fn IOPMAssertionCreateWithDescription(
|
||||
AssertionType: *const c_void,
|
||||
Name: *const c_void,
|
||||
Details: *const c_void,
|
||||
HumanReadableReason: *const c_void,
|
||||
LocalizationBundlePath: *const c_void,
|
||||
Timeout: f64,
|
||||
TimeoutAction: *const c_void,
|
||||
AssertionID: *mut u32,
|
||||
) -> i32;
|
||||
pub fn IOPMAssertionRelease(AssertionID: u32) -> i32;
|
||||
pub fn CFStringCreateWithCString(
|
||||
alloc: *const c_void,
|
||||
cStr: *const i8,
|
||||
encoding: u32,
|
||||
) -> *const c_void;
|
||||
pub fn CFRelease(arg: *const c_void);
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SleepPreventer {
|
||||
#[cfg(target_os = "macos")]
|
||||
Mac { system_sleep_id: u32, network_client_id: u32 },
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
Other(keepawake::KeepAwake),
|
||||
}
|
||||
|
||||
impl Drop for SleepPreventer {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let SleepPreventer::Mac { system_sleep_id, network_client_id } = self;
|
||||
unsafe {
|
||||
macos_sleep::IOPMAssertionRelease(*system_sleep_id);
|
||||
macos_sleep::IOPMAssertionRelease(*network_client_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn create_sleep_preventer() -> Result<SleepPreventer, String> {
|
||||
use std::ffi::CString;
|
||||
use std::ptr::null;
|
||||
unsafe {
|
||||
let create_cf_string = |s: &str| -> *const std::ffi::c_void {
|
||||
let cstr = CString::new(s).unwrap();
|
||||
macos_sleep::CFStringCreateWithCString(null(), cstr.as_ptr(), 0x08000100)
|
||||
};
|
||||
|
||||
let type_sys = create_cf_string("PreventSystemSleep");
|
||||
let type_net = create_cf_string("NetworkClientActive");
|
||||
let name = create_cf_string("Firelink active download");
|
||||
|
||||
let mut sys_id: u32 = 0;
|
||||
let mut net_id: u32 = 0;
|
||||
|
||||
let res1 = macos_sleep::IOPMAssertionCreateWithDescription(
|
||||
type_sys, name, null(), null(), null(), 0.0, null(), &mut sys_id
|
||||
);
|
||||
let res2 = macos_sleep::IOPMAssertionCreateWithDescription(
|
||||
type_net, name, null(), null(), null(), 0.0, null(), &mut net_id
|
||||
);
|
||||
|
||||
macos_sleep::CFRelease(type_sys);
|
||||
macos_sleep::CFRelease(type_net);
|
||||
macos_sleep::CFRelease(name);
|
||||
|
||||
if res1 == 0 && res2 == 0 {
|
||||
Ok(SleepPreventer::Mac { system_sleep_id: sys_id, network_client_id: net_id })
|
||||
} else {
|
||||
if res1 == 0 { macos_sleep::IOPMAssertionRelease(sys_id); }
|
||||
if res2 == 0 { macos_sleep::IOPMAssertionRelease(net_id); }
|
||||
Err("Failed to create macOS sleep assertions".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn create_sleep_preventer() -> Result<SleepPreventer, String> {
|
||||
keepawake::Builder::default()
|
||||
.idle(true)
|
||||
.reason("Firelink active download")
|
||||
.create()
|
||||
.map(SleepPreventer::Other)
|
||||
.map_err(|error| format!("failed to prevent system sleep: {error}"))
|
||||
#[tauri::command]
|
||||
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> {
|
||||
state.power_manager.set_system_prevention(prevent)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> {
|
||||
let mut current_preventer = state
|
||||
.sleep_preventer
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if prevent {
|
||||
if current_preventer.is_none() {
|
||||
*current_preventer = Some(create_sleep_preventer()?);
|
||||
}
|
||||
} else {
|
||||
*current_preventer = None;
|
||||
}
|
||||
Ok(())
|
||||
fn set_power_preferences(
|
||||
state: tauri::State<'_, AppState>,
|
||||
prevent_system_sleep: bool,
|
||||
prevent_display_sleep: bool,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.power_manager
|
||||
.set_preferences(prevent_system_sleep, prevent_display_sleep)
|
||||
}
|
||||
|
||||
pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> {
|
||||
@@ -6273,9 +6206,17 @@ fn db_save_settings(
|
||||
};
|
||||
crate::db::save_settings(&connection, &merged)?;
|
||||
let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?;
|
||||
let prevent_system_sleep = decoded.prevents_sleep_while_downloading;
|
||||
let prevent_display_sleep = decoded.prevents_display_sleep_while_downloading;
|
||||
if let Ok(mut cached) = app_state.scheduler_settings.write() {
|
||||
*cached = Some(decoded);
|
||||
}
|
||||
if let Err(error) = app_state
|
||||
.power_manager
|
||||
.set_preferences(prevent_system_sleep, prevent_display_sleep)
|
||||
{
|
||||
log::warn!("power: saved settings applied with an OS assertion error: {error}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7032,6 +6973,60 @@ mod tests {
|
||||
assert_eq!(observation.healthy_speed_samples, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_recovery_budget_survives_same_epoch_gid_replacement() {
|
||||
let mut observation = Aria2ConnectionObservation {
|
||||
gid: "gid-old".to_string(),
|
||||
control_epoch: 7,
|
||||
recovery_attempts: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let now = Instant::now();
|
||||
|
||||
assert_eq!(
|
||||
observe_aria2_connections_with_epoch(
|
||||
&mut observation,
|
||||
Aria2ConnectionSample {
|
||||
gid: "gid-new",
|
||||
control_epoch: 7,
|
||||
status: "active",
|
||||
total: 100,
|
||||
completed: 10,
|
||||
speed_bytes: 1024.0,
|
||||
active_connections: 16,
|
||||
requested_connections: 16,
|
||||
speed_limited: false,
|
||||
now,
|
||||
},
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
observation.recovery_attempts, 2,
|
||||
"a fresh gid in the same lifecycle must not reset the bounded recovery budget"
|
||||
);
|
||||
|
||||
observe_aria2_connections_with_epoch(
|
||||
&mut observation,
|
||||
Aria2ConnectionSample {
|
||||
gid: "gid-new-lifecycle",
|
||||
control_epoch: 8,
|
||||
status: "active",
|
||||
total: 100,
|
||||
completed: 10,
|
||||
speed_bytes: 1024.0,
|
||||
active_connections: 16,
|
||||
requested_connections: 16,
|
||||
speed_limited: false,
|
||||
now: now + Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
observation.recovery_attempts, 0,
|
||||
"a genuinely new lifecycle may start with a fresh recovery budget"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_recovery_requires_a_real_multi_connection_transfer() {
|
||||
let start = Instant::now();
|
||||
@@ -8848,10 +8843,14 @@ fn observe_aria2_connections_with_epoch(
|
||||
now,
|
||||
} = sample;
|
||||
if observation.gid != gid || observation.control_epoch != control_epoch {
|
||||
let preserved_recovery_attempts = (observation.control_epoch == control_epoch)
|
||||
.then_some(observation.recovery_attempts)
|
||||
.unwrap_or_default();
|
||||
*observation = Aria2ConnectionObservation {
|
||||
gid: gid.to_string(),
|
||||
control_epoch,
|
||||
last_completed: completed,
|
||||
recovery_attempts: preserved_recovery_attempts,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
@@ -9173,6 +9172,13 @@ pub fn run() {
|
||||
let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone()));
|
||||
|
||||
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
|
||||
let power_manager = queue_manager.power_manager();
|
||||
if let Some(settings) = persisted_settings.as_ref() {
|
||||
let _ = power_manager.set_preferences(
|
||||
settings.prevents_sleep_while_downloading,
|
||||
settings.prevents_display_sleep_while_downloading,
|
||||
);
|
||||
}
|
||||
let initial_global_speed_limit = persisted_settings
|
||||
.as_ref()
|
||||
.and_then(|settings| normalize_speed_limit_for_aria2(&settings.global_speed_limit));
|
||||
@@ -9197,11 +9203,15 @@ pub fn run() {
|
||||
aria2_port: aria2_port.clone(),
|
||||
aria2_secret: aria2_secret.clone(),
|
||||
media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)),
|
||||
sleep_preventer: Arc::new(Mutex::new(None)),
|
||||
power_manager: Arc::clone(&power_manager),
|
||||
scheduler_settings: Arc::clone(&scheduler_settings),
|
||||
queue_manager,
|
||||
});
|
||||
|
||||
if let Err(error) = power_manager.activate() {
|
||||
log::error!("power: failed to activate backend power management: {error}");
|
||||
}
|
||||
|
||||
// Build the window only after all command state is registered. This
|
||||
// prevents the frontend from racing startup and invoking IPC before
|
||||
// the database and portable storage layout are available.
|
||||
@@ -9464,6 +9474,7 @@ pub fn run() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
|
||||
let mut observations: HashMap<String, Aria2ConnectionObservation> = HashMap::new();
|
||||
let mut missing_gid_recovery_at: HashMap<String, Instant> = HashMap::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// Terminal cleanup removes a download's GID mapping. Do
|
||||
@@ -9475,6 +9486,7 @@ pub fn run() {
|
||||
.map(|(_, id)| id)
|
||||
.collect();
|
||||
observations.retain(|id, _| mapped_ids.contains(id));
|
||||
missing_gid_recovery_at.retain(|id, _| mapped_ids.contains(id));
|
||||
let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "connections", "errorMessage"]]);
|
||||
if let Ok(active_list) = rpc_call(poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellActive", params).await {
|
||||
if let Some(active_arr) = active_list.as_array() {
|
||||
@@ -9612,6 +9624,49 @@ pub fn run() {
|
||||
gid,
|
||||
error
|
||||
);
|
||||
let recovery_allowed = missing_gid_recovery_at
|
||||
.get(&id)
|
||||
.is_none_or(|last_attempt| {
|
||||
last_attempt.elapsed()
|
||||
>= Duration::from_secs(5)
|
||||
});
|
||||
if crate::aria2_gid_not_found(&error)
|
||||
&& recovery_allowed
|
||||
&& poll_mgr.has_active_permit(&id).await
|
||||
{
|
||||
if let Some(mapping) = poll_mgr
|
||||
.aria2_gid_mapping(&gid)
|
||||
.filter(|mapping| mapping.id == id)
|
||||
{
|
||||
missing_gid_recovery_at
|
||||
.insert(id.clone(), Instant::now());
|
||||
log::warn!(
|
||||
"aria2 poller reconciliation [{}]: mapped gid {} is missing; attempting payload recovery",
|
||||
id,
|
||||
gid
|
||||
);
|
||||
match poll_mgr
|
||||
.refresh_aria2_connections(
|
||||
&id,
|
||||
&gid,
|
||||
mapping.epoch,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
missing_gid_recovery_at.remove(&id);
|
||||
}
|
||||
Err(recovery_error) => {
|
||||
log::warn!(
|
||||
"aria2 poller reconciliation [{}]: payload recovery for missing gid {} failed: {}",
|
||||
id,
|
||||
gid,
|
||||
recovery_error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -9726,7 +9781,7 @@ pub fn run() {
|
||||
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
|
||||
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
|
||||
pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata,
|
||||
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action,
|
||||
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action,
|
||||
ack_schedule_trigger,
|
||||
check_automation_permission, request_automation_permission, open_automation_settings,
|
||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
const ASSERTION_REASON: &str = "Firelink active download";
|
||||
|
||||
/// Owns the OS power assertions for the whole backend transfer lifecycle.
|
||||
///
|
||||
/// The frontend may request the policy, but it does not own the assertion:
|
||||
/// queue permits are the source of truth for whether a transfer is actually
|
||||
/// active. Keeping the two assertions separate also means display sleep can
|
||||
/// be opted into without changing the system-sleep policy.
|
||||
struct PowerState {
|
||||
runtime_enabled: AtomicBool,
|
||||
prevent_system_sleep: AtomicBool,
|
||||
prevent_display_sleep: AtomicBool,
|
||||
active_transfers: AtomicUsize,
|
||||
}
|
||||
|
||||
enum PowerCommand {
|
||||
Reconcile(Option<Sender<Result<(), String>>>),
|
||||
}
|
||||
|
||||
struct PowerWorker {
|
||||
sender: Option<Sender<PowerCommand>>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PowerWorker {
|
||||
fn start(state: Arc<PowerState>) -> Result<Self, String> {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let join_handle = thread::Builder::new()
|
||||
.name("firelink-power".to_string())
|
||||
.spawn(move || run_power_worker(state, receiver))
|
||||
.map_err(|error| format!("failed to start power-management worker: {error}"))?;
|
||||
Ok(Self {
|
||||
sender: Some(sender),
|
||||
join_handle: Some(join_handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn send(&self, command: PowerCommand) -> Result<(), String> {
|
||||
self.sender
|
||||
.as_ref()
|
||||
.ok_or_else(|| "power-management worker is stopping".to_string())?
|
||||
.send(command)
|
||||
.map_err(|_| "power-management worker stopped unexpectedly".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PowerWorker {
|
||||
fn drop(&mut self) {
|
||||
// Closing the sender lets the worker drop its OS assertions on the
|
||||
// same thread that created them. This matters on Windows, where
|
||||
// SetThreadExecutionState is thread-local, and avoids calling the
|
||||
// Linux keepawake D-Bus destructor from an arbitrary queue thread.
|
||||
self.sender.take();
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PowerManager {
|
||||
state: Arc<PowerState>,
|
||||
worker: Mutex<Option<PowerWorker>>,
|
||||
}
|
||||
|
||||
impl PowerManager {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Arc::new(PowerState {
|
||||
runtime_enabled: AtomicBool::new(false),
|
||||
prevent_system_sleep: AtomicBool::new(true),
|
||||
prevent_display_sleep: AtomicBool::new(false),
|
||||
active_transfers: AtomicUsize::new(0),
|
||||
}),
|
||||
worker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enables OS integration after AppState has been registered. This keeps
|
||||
/// QueueManager tests and pre-window startup work from creating real
|
||||
/// assertions in the test process or during partial initialization.
|
||||
pub fn activate(&self) -> Result<(), String> {
|
||||
self.state.runtime_enabled.store(true, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_preferences(
|
||||
&self,
|
||||
prevent_system_sleep: bool,
|
||||
prevent_display_sleep: bool,
|
||||
) -> Result<(), String> {
|
||||
self.state
|
||||
.prevent_system_sleep
|
||||
.store(prevent_system_sleep, Ordering::Release);
|
||||
self.state
|
||||
.prevent_display_sleep
|
||||
.store(prevent_display_sleep, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_system_prevention(&self, enabled: bool) -> Result<(), String> {
|
||||
self.state
|
||||
.prevent_system_sleep
|
||||
.store(enabled, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_display_prevention(&self, enabled: bool) -> Result<(), String> {
|
||||
self.state
|
||||
.prevent_display_sleep
|
||||
.store(enabled, Ordering::Release);
|
||||
self.reconcile()
|
||||
}
|
||||
|
||||
pub fn set_active_transfer_count(self: &Arc<Self>, count: usize) {
|
||||
self.state.active_transfers.store(count, Ordering::Release);
|
||||
if self.state.runtime_enabled.load(Ordering::Acquire) {
|
||||
if let Err(error) = self.enqueue_reconcile() {
|
||||
log::error!(
|
||||
"power: failed to schedule assertion reconciliation for {count} active transfer(s): {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_transfer_count(&self) -> usize {
|
||||
self.state.active_transfers.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn reconcile(&self) -> Result<(), String> {
|
||||
if !self.state.runtime_enabled.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.ensure_worker()?;
|
||||
let (response_sender, response_receiver) = mpsc::channel();
|
||||
if let Err(error) = self.send_command(PowerCommand::Reconcile(Some(response_sender))) {
|
||||
self.restart_worker();
|
||||
return Err(error);
|
||||
}
|
||||
match response_receiver.recv() {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
self.restart_worker();
|
||||
Err("power-management worker stopped before reporting reconciliation".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_reconcile(&self) -> Result<(), String> {
|
||||
self.ensure_worker()?;
|
||||
if let Err(error) = self.send_command(PowerCommand::Reconcile(None)) {
|
||||
self.restart_worker();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_worker(&self) -> Result<(), String> {
|
||||
let mut worker = self
|
||||
.worker
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if worker.is_none() {
|
||||
*worker = Some(PowerWorker::start(Arc::clone(&self.state))?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_command(&self, command: PowerCommand) -> Result<(), String> {
|
||||
let worker = self
|
||||
.worker
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
worker
|
||||
.as_ref()
|
||||
.ok_or_else(|| "power-management worker is not running".to_string())?
|
||||
.send(command)
|
||||
}
|
||||
|
||||
fn restart_worker(&self) {
|
||||
let mut worker = self
|
||||
.worker
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
worker.take();
|
||||
}
|
||||
}
|
||||
|
||||
struct OsPowerAssertions {
|
||||
#[cfg(windows)]
|
||||
assertion: Option<keepawake::KeepAwake>,
|
||||
#[cfg(not(windows))]
|
||||
system: Option<keepawake::KeepAwake>,
|
||||
#[cfg(not(windows))]
|
||||
display: Option<keepawake::KeepAwake>,
|
||||
system_enabled: bool,
|
||||
display_enabled: bool,
|
||||
}
|
||||
|
||||
impl OsPowerAssertions {
|
||||
fn reconcile(&mut self, state: &PowerState) -> Result<(), String> {
|
||||
let active = state.active_transfers.load(Ordering::Acquire);
|
||||
let desired_system = active > 0 && state.prevent_system_sleep.load(Ordering::Acquire);
|
||||
let desired_display = active > 0 && state.prevent_display_sleep.load(Ordering::Acquire);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return self.reconcile_combined(desired_system, desired_display, active);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
self.reconcile_independent(desired_system, desired_display, active)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn reconcile_combined(
|
||||
&mut self,
|
||||
desired_system: bool,
|
||||
desired_display: bool,
|
||||
active: usize,
|
||||
) -> Result<(), String> {
|
||||
if self.system_enabled == desired_system && self.display_enabled == desired_display {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(assertion) = self.assertion.take() {
|
||||
drop_assertion(assertion, "combined");
|
||||
if self.system_enabled {
|
||||
log::info!("power: system-sleep prevention released");
|
||||
}
|
||||
if self.display_enabled {
|
||||
log::info!("power: display-sleep prevention released");
|
||||
}
|
||||
self.system_enabled = false;
|
||||
self.display_enabled = false;
|
||||
}
|
||||
|
||||
if desired_system || desired_display {
|
||||
let assertion = create_assertion(|| {
|
||||
keepawake::Builder::default()
|
||||
.display(desired_display)
|
||||
.idle(desired_system)
|
||||
.sleep(desired_system)
|
||||
.reason(ASSERTION_REASON)
|
||||
.create()
|
||||
})
|
||||
.map_err(|error| format!("failed to apply power prevention: {error}"))?;
|
||||
self.assertion = Some(assertion);
|
||||
self.system_enabled = desired_system;
|
||||
self.display_enabled = desired_display;
|
||||
if desired_system {
|
||||
log::info!(
|
||||
"power: system-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
if desired_display {
|
||||
log::info!(
|
||||
"power: display-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn reconcile_independent(
|
||||
&mut self,
|
||||
desired_system: bool,
|
||||
desired_display: bool,
|
||||
active: usize,
|
||||
) -> Result<(), String> {
|
||||
let mut first_error = None;
|
||||
if desired_system {
|
||||
if self.system.is_none() {
|
||||
match create_assertion(|| {
|
||||
keepawake::Builder::default()
|
||||
.idle(true)
|
||||
.sleep(true)
|
||||
.reason(ASSERTION_REASON)
|
||||
.create()
|
||||
}) {
|
||||
Ok(assertion) => {
|
||||
self.system = Some(assertion);
|
||||
self.system_enabled = true;
|
||||
log::info!(
|
||||
"power: system-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
first_error = Some(format!("failed to prevent system sleep: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(assertion) = self.system.take() {
|
||||
drop_assertion(assertion, "system-sleep");
|
||||
self.system_enabled = false;
|
||||
log::info!("power: system-sleep prevention released");
|
||||
}
|
||||
|
||||
if desired_display {
|
||||
if self.display.is_none() {
|
||||
match create_assertion(|| {
|
||||
keepawake::Builder::default()
|
||||
.display(true)
|
||||
.reason(ASSERTION_REASON)
|
||||
.create()
|
||||
}) {
|
||||
Ok(assertion) => {
|
||||
self.display = Some(assertion);
|
||||
self.display_enabled = true;
|
||||
log::info!(
|
||||
"power: display-sleep prevention enabled for {active} active transfer(s)"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let display_error = format!("failed to prevent display sleep: {error}");
|
||||
if first_error.is_none() {
|
||||
first_error = Some(display_error);
|
||||
} else {
|
||||
log::error!("power: {display_error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(assertion) = self.display.take() {
|
||||
drop_assertion(assertion, "display-sleep");
|
||||
self.display_enabled = false;
|
||||
log::info!("power: display-sleep prevention released");
|
||||
}
|
||||
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_assertion(
|
||||
create: impl FnOnce() -> keepawake::Result<keepawake::KeepAwake>,
|
||||
) -> Result<keepawake::KeepAwake, String> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create))
|
||||
.map_err(|_| "keepawake assertion creation panicked".to_string())?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn drop_assertion(assertion: keepawake::KeepAwake, kind: &str) {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(assertion))).is_err() {
|
||||
log::error!("power: keepawake {kind} assertion cleanup panicked");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_power_worker(state: Arc<PowerState>, receiver: Receiver<PowerCommand>) {
|
||||
let mut assertions = OsPowerAssertions {
|
||||
#[cfg(windows)]
|
||||
assertion: None,
|
||||
#[cfg(not(windows))]
|
||||
system: None,
|
||||
#[cfg(not(windows))]
|
||||
display: None,
|
||||
system_enabled: false,
|
||||
display_enabled: false,
|
||||
};
|
||||
while let Ok(PowerCommand::Reconcile(response)) = receiver.recv() {
|
||||
let result = assertions.reconcile(&state);
|
||||
if let Some(response) = response {
|
||||
let _ = response.send(result);
|
||||
} else if let Err(error) = result {
|
||||
let active = state.active_transfers.load(Ordering::Acquire);
|
||||
log::error!(
|
||||
"power: failed to reconcile assertions for {active} active transfer(s): {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(assertion) = assertions.assertion.take() {
|
||||
drop_assertion(assertion, "combined");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(assertion) = assertions.display.take() {
|
||||
drop_assertion(assertion, "display-sleep");
|
||||
}
|
||||
if let Some(assertion) = assertions.system.take() {
|
||||
drop_assertion(assertion, "system-sleep");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PowerManager;
|
||||
|
||||
#[test]
|
||||
fn tracks_transfer_activity_before_runtime_activation() {
|
||||
let manager = PowerManager::new();
|
||||
manager.set_active_transfer_count(2);
|
||||
assert_eq!(manager.active_transfer_count(), 2);
|
||||
|
||||
manager.set_active_transfer_count(0);
|
||||
assert_eq!(manager.active_transfer_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_changes_are_safe_before_runtime_activation() {
|
||||
let manager = PowerManager::new();
|
||||
assert!(manager.set_preferences(false, true).is_ok());
|
||||
assert!(manager.set_system_prevention(true).is_ok());
|
||||
assert!(manager.set_display_prevention(false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_worker_can_activate_without_active_transfers() {
|
||||
let manager = PowerManager::new();
|
||||
assert!(manager.activate().is_ok());
|
||||
manager.set_active_transfer_count(0);
|
||||
assert_eq!(manager.active_transfer_count(), 0);
|
||||
}
|
||||
}
|
||||
+294
-2
@@ -1,4 +1,5 @@
|
||||
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
|
||||
use crate::power::PowerManager;
|
||||
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
|
||||
use log;
|
||||
use serde::Deserialize;
|
||||
@@ -8,6 +9,7 @@ use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
|
||||
use ts_rs::TS;
|
||||
@@ -102,6 +104,20 @@ pub enum Aria2RefreshOutcome {
|
||||
Complete,
|
||||
}
|
||||
|
||||
/// Result of rebuilding an aria2 job while retaining its partial file and
|
||||
/// queue permit. `Refresh` is the compatibility path for test/alternate
|
||||
/// spawners that do not own aria2's addUri options.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Aria2RecreateOutcome {
|
||||
NewGid(String),
|
||||
Complete,
|
||||
Refresh,
|
||||
/// The old daemon job is gone and the replacement could not be created.
|
||||
/// The queue manager must retire the stale mapping and surface a paused
|
||||
/// lifecycle so the next user resume can enqueue a fresh job.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
/// What kind of sidecar a queued task spawns. Drives which runner the
|
||||
/// dispatcher invokes.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -173,6 +189,19 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
Err("aria2 connection refresh is unavailable".to_string())
|
||||
}
|
||||
|
||||
/// Rebuild an active aria2 job with the current payload/options. The
|
||||
/// partial file remains resumable because the production addUri path uses
|
||||
/// continue + always-resume. Alternate spawners may return `Refresh` to
|
||||
/// retain the older same-GID pause/unpause behavior.
|
||||
async fn recreate_uri(
|
||||
&self,
|
||||
_id: &str,
|
||||
_gid: &str,
|
||||
_payload: &SpawnPayload,
|
||||
) -> Result<Aria2RecreateOutcome, String> {
|
||||
Ok(Aria2RecreateOutcome::Refresh)
|
||||
}
|
||||
|
||||
/// Change one active aria2 transfer's runtime download cap. Media
|
||||
/// runners intentionally keep the default implementation: yt-dlp reads
|
||||
/// its limit only when the process starts.
|
||||
@@ -267,6 +296,9 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
/// resume tasks capture this and abort when a later pause/remove wins.
|
||||
aria2_control_epochs: Mutex<HashMap<String, u64>>,
|
||||
|
||||
/// Backend-owned power policy and active-transfer accounting.
|
||||
power_manager: Arc<PowerManager>,
|
||||
|
||||
spawner: Arc<dyn SidecarSpawner>,
|
||||
app_handle: AppHandle<R>,
|
||||
}
|
||||
@@ -316,11 +348,39 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
aria2_control_locks: Arc::new(StdMutex::new(HashMap::new())),
|
||||
aria2_gid_state: Mutex::new(()),
|
||||
aria2_control_epochs: Mutex::new(HashMap::new()),
|
||||
power_manager: PowerManager::new(),
|
||||
spawner,
|
||||
app_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn power_manager(&self) -> Arc<PowerManager> {
|
||||
Arc::clone(&self.power_manager)
|
||||
}
|
||||
|
||||
pub fn activate_power_management(&self) -> Result<(), String> {
|
||||
self.power_manager.activate()
|
||||
}
|
||||
|
||||
pub fn set_power_preferences(
|
||||
&self,
|
||||
prevent_system_sleep: bool,
|
||||
prevent_display_sleep: bool,
|
||||
) -> Result<(), String> {
|
||||
self.power_manager
|
||||
.set_preferences(prevent_system_sleep, prevent_display_sleep)
|
||||
}
|
||||
|
||||
pub fn set_system_sleep_prevention(&self, enabled: bool) -> Result<(), String> {
|
||||
self.power_manager.set_system_prevention(enabled)
|
||||
}
|
||||
|
||||
async fn sync_power_activity(&self) {
|
||||
let active_transfers = self.active_permits.lock().await.len();
|
||||
self.power_manager
|
||||
.set_active_transfer_count(active_transfers);
|
||||
}
|
||||
|
||||
/// Current pending order, as id list. Returned by move_in_queue.
|
||||
pub async fn pending_order(&self, queue_id: Option<&str>) -> Vec<String> {
|
||||
self.pending
|
||||
@@ -848,6 +908,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.to_string(), lifecycle_generation);
|
||||
self.sync_power_activity().await;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -935,6 +996,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
active: true,
|
||||
},
|
||||
);
|
||||
self.sync_power_activity().await;
|
||||
}
|
||||
|
||||
/// Park a candidate only when no newer lifecycle has already claimed the
|
||||
@@ -1014,6 +1076,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.to_string(), TaskKind::Aria2);
|
||||
self.sync_power_activity().await;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1081,6 +1144,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
if removed || queue_removed {
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
drop(_admission_gate);
|
||||
if removed {
|
||||
self.sync_power_activity().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn release_permit_for_generation(&self, id: &str, generation: u64) {
|
||||
@@ -1108,6 +1175,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
if removed {
|
||||
self.active_kinds.lock().await.remove(id);
|
||||
self.notify.notify_waiters();
|
||||
drop(_admission_gate);
|
||||
self.sync_power_activity().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1550,6 +1619,42 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
self.ignore_aria2_gid_locked(gid).await;
|
||||
}
|
||||
|
||||
async fn remove_stale_aria2_gid(&self, id: &str, gid: &str) {
|
||||
const MAX_ATTEMPTS: usize = 3;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match self.spawner.remove_uri(gid).await {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"aria2 lifecycle cleanup [{}]: removed stale replacement gid {} on attempt {}",
|
||||
id,
|
||||
gid,
|
||||
attempt
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(error) if attempt < MAX_ATTEMPTS => {
|
||||
log::warn!(
|
||||
"aria2 lifecycle cleanup [{}]: failed to remove stale replacement gid {} on attempt {}: {}; retrying",
|
||||
id,
|
||||
gid,
|
||||
attempt,
|
||||
error
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"aria2 lifecycle cleanup [{}]: stale replacement gid {} could not be removed after {} attempts: {}",
|
||||
id,
|
||||
gid,
|
||||
MAX_ATTEMPTS,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ignore_aria2_gid_locked(&self, gid: &str) {
|
||||
const MAX_IGNORED_GIDS: usize = 1024;
|
||||
let mut ignored = self.aria2_ignored_gids.lock().await;
|
||||
@@ -1613,7 +1718,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
/// persistent connection-pool collapse or a true zero-progress stall.
|
||||
/// The observed epoch must still own the GID before the refresh can act.
|
||||
pub async fn refresh_aria2_connections(
|
||||
&self,
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
gid: &str,
|
||||
observed_epoch: u64,
|
||||
@@ -1621,13 +1726,90 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
let _control_guard = self.acquire_aria2_control(id).await;
|
||||
if self.aria2_gid_for_download(id).as_deref() != Some(gid)
|
||||
|| !self.is_registered(id).await
|
||||
|| !self.has_active_permit(id).await
|
||||
|| self.is_aria2_retry_cancelled(id).await
|
||||
|| !self.is_aria2_control_epoch_current(id, observed_epoch).await
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let outcome = self.spawner.refresh_uri(gid).await?;
|
||||
let payload = self.aria2_payloads.lock().await.get(id).cloned();
|
||||
let recreation = if let Some(payload) = payload.as_ref() {
|
||||
self.spawner.recreate_uri(id, gid, payload).await?
|
||||
} else {
|
||||
// Older persisted rows may briefly reach recovery before their
|
||||
// payload has been rebuilt. Keep the current lifecycle intact and
|
||||
// use the non-destructive fallback until the payload is present.
|
||||
Aria2RecreateOutcome::Refresh
|
||||
};
|
||||
|
||||
if let Aria2RecreateOutcome::NewGid(new_gid) = recreation {
|
||||
if new_gid.trim().is_empty() || new_gid == gid {
|
||||
return Err(format!(
|
||||
"aria2 connection recovery returned an invalid replacement gid for {gid}"
|
||||
));
|
||||
}
|
||||
|
||||
let still_current = self.is_registered(id).await
|
||||
&& !self.is_aria2_retry_cancelled(id).await
|
||||
&& self.is_aria2_control_epoch_current(id, observed_epoch).await
|
||||
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
|
||||
if !still_current {
|
||||
self.ignore_aria2_gid(&new_gid).await;
|
||||
drop(_control_guard);
|
||||
self.remove_stale_aria2_gid(id, &new_gid).await;
|
||||
log::info!(
|
||||
"aria2 connection recovery [{}]: replacement gid {} became stale before rebind",
|
||||
id,
|
||||
new_gid
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let buffered_outcome = self.remember_gid(id.to_string(), new_gid.clone()).await;
|
||||
log::info!(
|
||||
"aria2 connection recovery [{}]: recreated gid {} from {} while retaining the lifecycle permit",
|
||||
id,
|
||||
new_gid,
|
||||
gid
|
||||
);
|
||||
drop(_control_guard);
|
||||
if let Some(outcome) = buffered_outcome {
|
||||
self.handle_aria2_event(&new_gid, outcome).await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let outcome = match recreation {
|
||||
Aria2RecreateOutcome::Complete => Aria2RefreshOutcome::Complete,
|
||||
Aria2RecreateOutcome::Refresh => self.spawner.refresh_uri(gid).await?,
|
||||
Aria2RecreateOutcome::Unavailable(error) => {
|
||||
let still_current = self.is_registered(id).await
|
||||
&& self.has_active_permit(id).await
|
||||
&& !self.is_aria2_retry_cancelled(id).await
|
||||
&& self.is_aria2_control_epoch_current(id, observed_epoch).await
|
||||
&& self.aria2_gid_for_download(id).as_deref() == Some(gid);
|
||||
if !still_current {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.next_aria2_control_epoch(id).await;
|
||||
self.cancel_aria2_retries(id).await;
|
||||
self.clear_aria2_retry_state(id).await;
|
||||
self.forget_aria2_gid(id).await;
|
||||
self.release_registered_id(id).await;
|
||||
self.release_permit(id).await;
|
||||
self.emit_state(id, DownloadStatus::Paused);
|
||||
log::warn!(
|
||||
"aria2 connection recovery [{}]: replacement job unavailable; retired stale gid {} and paused the download: {}",
|
||||
id,
|
||||
gid,
|
||||
error
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Aria2RecreateOutcome::NewGid(_) => unreachable!("replacement gid handled above"),
|
||||
};
|
||||
|
||||
let still_current = self.is_registered(id).await
|
||||
&& !self.is_aria2_retry_cancelled(id).await
|
||||
@@ -2595,6 +2777,116 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
}
|
||||
|
||||
async fn recreate_uri(
|
||||
&self,
|
||||
id: &str,
|
||||
gid: &str,
|
||||
payload: &SpawnPayload,
|
||||
) -> Result<Aria2RecreateOutcome, String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let secret = &state.aria2_secret;
|
||||
|
||||
let status = match crate::aria2_download_status(port, secret, gid).await {
|
||||
Ok(status) => Some(status),
|
||||
Err(error) if crate::aria2_gid_not_found(&error) => {
|
||||
log::warn!(
|
||||
"aria2 connection recovery [{}]: gid {} is already absent; rebuilding from the saved payload",
|
||||
id,
|
||||
gid
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
if let Some(status) = status {
|
||||
match status.as_str() {
|
||||
"complete" => return Ok(Aria2RecreateOutcome::Complete),
|
||||
"active" | "waiting" => {
|
||||
let pause_result = crate::rpc_call(
|
||||
port,
|
||||
secret,
|
||||
"aria2.forcePause",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = pause_result {
|
||||
match crate::aria2_download_status(port, secret, gid).await {
|
||||
Ok(status) if status == "paused" => {
|
||||
log::warn!(
|
||||
"aria2 connection recovery [{}]: forcePause for gid {} returned an error after the daemon paused it: {}",
|
||||
id,
|
||||
gid,
|
||||
error
|
||||
);
|
||||
}
|
||||
Ok(status) if status == "complete" => {
|
||||
return Ok(Aria2RecreateOutcome::Complete);
|
||||
}
|
||||
Ok(status) => {
|
||||
return Err(format!(
|
||||
"failed to pause aria2 gid {gid} before recreation: {error}; daemon reports {status}"
|
||||
));
|
||||
}
|
||||
Err(status_error) => {
|
||||
return Err(format!(
|
||||
"failed to pause aria2 gid {gid} before recreation: {error}; status verification failed: {status_error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"paused" => {}
|
||||
"removed" => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"cannot recreate aria2 gid {gid} from daemon state {other}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remove_result = crate::rpc_call(
|
||||
port,
|
||||
secret,
|
||||
"aria2.forceRemove",
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await;
|
||||
let remove_error = match remove_result {
|
||||
Ok(result) => crate::ensure_aria2_gid_result("forceRemove", gid, &result)
|
||||
.err()
|
||||
.map(|error| error.to_string()),
|
||||
Err(error) if crate::aria2_gid_not_found(&error) => None,
|
||||
Err(error) => Some(error.to_string()),
|
||||
};
|
||||
if let Some(error) = remove_error {
|
||||
match crate::aria2_download_status(port, secret, gid).await {
|
||||
Ok(status) if status == "complete" => {
|
||||
return Ok(Aria2RecreateOutcome::Complete);
|
||||
}
|
||||
Ok(status) if status == "removed" => {}
|
||||
Ok(status) => {
|
||||
return Err(format!(
|
||||
"failed to remove aria2 gid {gid} before recreation: {error}; daemon reports {status}"
|
||||
));
|
||||
}
|
||||
Err(status_error) if crate::aria2_gid_not_found(&status_error) => {}
|
||||
Err(status_error) => {
|
||||
return Err(format!(
|
||||
"failed to remove aria2 gid {gid} before recreation: {error}; status verification failed: {status_error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.add_uri(id, payload).await {
|
||||
Ok(new_gid) => Ok(Aria2RecreateOutcome::NewGid(new_gid)),
|
||||
Err(error) => Ok(Aria2RecreateOutcome::Unavailable(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_uri(&self, gid: &str) -> Result<Aria2RefreshOutcome, String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
@@ -486,6 +486,7 @@ fn default_settings() -> PersistedSettings {
|
||||
ask_where_to_save_each_file: false,
|
||||
remember_last_used_download_directory: false,
|
||||
prevents_sleep_while_downloading: true,
|
||||
prevents_display_sleep_while_downloading: false,
|
||||
media_cookie_source: MediaCookieSource::default(),
|
||||
site_logins: Vec::new(),
|
||||
auto_check_updates: true,
|
||||
|
||||
Reference in New Issue
Block a user