From 3f4f344620637f293dfcc3c5c64db76e721ba024 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 3 Sep 2026 08:55:36 +0330 Subject: [PATCH] fix(network): restore native async resolver for route-aware transfers (#38) - Use NativeAsyncResolver as primary for queue transfers and magnet probes when route contract is available - Fall back to standard system resolver options on stock Aria2 builds without custom flags - Guarantee minimum 5s cleanup reserve budget to prevent zero-duration probe cleanup timeouts - Prevent Aria2 single-threaded main loop freezing under TUN proxies (ref #38) --- src-tauri/src/lib.rs | 36 ++++++++++++----------- src-tauri/src/network.rs | 52 ++++++++++++++++++++++++++++++++++ src-tauri/src/queue.rs | 12 ++++---- src-tauri/src/torrent_probe.rs | 10 +++++-- 4 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd02e95..e7ef153 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9200,8 +9200,8 @@ async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(), } const MAGNET_METADATA_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); -const MAGNET_SYSTEM_RESOLVER_ATTEMPT: Duration = Duration::from_secs(20); -const MAGNET_ALTERNATE_RESOLVER_ATTEMPT: Duration = Duration::from_secs(40); +const MAGNET_PRIMARY_RESOLVER_ATTEMPT: Duration = Duration::from_secs(35); +const MAGNET_FALLBACK_RESOLVER_ATTEMPT: Duration = Duration::from_secs(25); const MAGNET_PROBE_CLEANUP_RESERVE: Duration = Duration::from_secs(5); fn magnet_probe_schedule(total_timeout: Duration) -> crate::torrent_probe::MetadataProbeSchedule { @@ -9310,18 +9310,18 @@ async fn resolve_magnet_metadata( let route_contract_available = app_handle .state::() .route_contract_available(); - let mut system_options = base_options.clone(); - crate::network::apply_aria2_system_resolver_for_daemon( - &mut system_options, + let mut primary_options = base_options.clone(); + crate::network::apply_aria2_transfer_resolver( + &mut primary_options, route_contract_available, ); let first_result = crate::torrent_probe::run_bounded_metadata_probe( client, &sanitized_source, - system_options, + primary_options, &metadata_path, - "system", - magnet_probe_schedule(MAGNET_SYSTEM_RESOLVER_ATTEMPT), + if route_contract_available { "route" } else { "stock" }, + magnet_probe_schedule(MAGNET_PRIMARY_RESOLVER_ATTEMPT), ) .await; @@ -9333,7 +9333,9 @@ async fn resolve_magnet_metadata( .err() .is_some_and(crate::torrent_probe::allows_resolver_fallback) => { - let cleanup_budget = operation_deadline.saturating_duration_since(Instant::now()); + let cleanup_budget = operation_deadline + .saturating_duration_since(Instant::now()) + .max(MAGNET_PROBE_CLEANUP_RESERVE); match tokio::time::timeout(cleanup_budget, remove_magnet_metadata_probe_dir(&probe_dir)) .await { @@ -9350,7 +9352,9 @@ async fn resolve_magnet_metadata( ); } } - let create_budget = operation_deadline.saturating_duration_since(Instant::now()); + let create_budget = operation_deadline + .saturating_duration_since(Instant::now()) + .max(MAGNET_PROBE_CLEANUP_RESERVE); match tokio::time::timeout(create_budget, tokio::fs::create_dir_all(&probe_dir)).await { Ok(Ok(())) => {} Ok(Err(error)) => { @@ -9365,10 +9369,10 @@ async fn resolve_magnet_metadata( ); } } - let alternate_budget = MAGNET_ALTERNATE_RESOLVER_ATTEMPT + let fallback_budget = MAGNET_FALLBACK_RESOLVER_ATTEMPT .min(operation_deadline.saturating_duration_since(Instant::now())); - let mut alternate_options = base_options; - crate::network::apply_aria2_route_contract(&mut alternate_options); + let mut fallback_options = base_options; + crate::network::apply_aria2_system_resolver_for_daemon(&mut fallback_options, true); crate::torrent_probe::run_bounded_metadata_probe( std::sync::Arc::new(Aria2RpcClient { port: state @@ -9377,10 +9381,10 @@ async fn resolve_magnet_metadata( secret: state.aria2_secret.clone(), }), &sanitized_source, - alternate_options, + fallback_options, &metadata_path, - "alternate", - magnet_probe_schedule(alternate_budget), + "system", + magnet_probe_schedule(fallback_budget), ) .await } diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs index 8cff10a..1ac3854 100644 --- a/src-tauri/src/network.rs +++ b/src-tauri/src/network.rs @@ -66,6 +66,31 @@ pub(crate) fn apply_aria2_route_contract( ); } +/// Select the appropriate resolver options for a transfer based on whether the +/// running Aria2 daemon advertises the Firelink route contract. +/// +/// When the Firelink route contract is available, transfers MUST use the +/// native-async resolver (`dns-resolver=native-async`, `async-dns=true`, +/// `network-target-policy=firelink-v1`). This executes the OS resolver +/// (`getaddrinfo`) on asynchronous worker threads, fully honoring TUN, VPN, +/// and proxy routes (e.g. Shadowrocket, Happ, Sing-box, V2RayN) without +/// blocking Aria2's single-threaded event loop or its RPC server. +/// +/// When the route contract is unavailable (stock Aria2 builds), Firelink falls +/// back to standard system resolver options (`async-dns=false`) without +/// passing custom options that would be rejected by stock binaries. +pub(crate) fn apply_aria2_transfer_resolver( + options: &mut serde_json::Map, + route_contract_available: bool, +) { + if route_contract_available { + apply_aria2_route_contract(options); + } else { + apply_aria2_system_resolver_for_daemon(options, false); + } +} + + fn has_string_capability(version: &serde_json::Value, field: &str, expected: &str) -> bool { version .get(field) @@ -494,6 +519,33 @@ mod tests { assert_eq!(patched_options.get("async-dns"), Some(&serde_json::json!("false"))); } + #[test] + fn aria2_transfer_resolver_selects_route_contract_when_available_and_stock_otherwise() { + let mut patched_options = serde_json::Map::new(); + apply_aria2_transfer_resolver(&mut patched_options, true); + assert_eq!( + patched_options.get("async-dns"), + Some(&serde_json::json!("true")) + ); + assert_eq!( + patched_options.get("dns-resolver"), + Some(&serde_json::json!(ARIA2_DNS_RESOLVER)) + ); + assert_eq!( + patched_options.get("network-target-policy"), + Some(&serde_json::json!(ARIA2_NETWORK_TARGET_POLICY)) + ); + + let mut stock_options = serde_json::Map::new(); + apply_aria2_transfer_resolver(&mut stock_options, false); + assert_eq!( + stock_options.get("async-dns"), + Some(&serde_json::json!("false")) + ); + assert!(!stock_options.contains_key("dns-resolver")); + assert!(!stock_options.contains_key("network-target-policy")); + } + #[test] fn stock_aria2_baseline_response_is_accepted_without_firelink_fields() { assert_eq!( diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 3ae59ca..7f4f522 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -8342,15 +8342,17 @@ impl SidecarSpawner for ProductionSpawner { let attempt_epoch = state.queue_manager.current_aria2_control_epoch(id).await; let admission_started = Instant::now(); let mut options = serde_json::Map::new(); - // Keep hostname resolution on the system/TUN route for every normal - // and Torrent transfer. The optional Firelink resolver is reserved for - // an explicit magnet metadata fallback and is never forced onto stock - // Aria2 builds. + // Keep hostname resolution route-aware and non-blocking for every + // normal and Torrent transfer. When the Firelink route contract is + // available, transfers use NativeAsyncResolver on background worker + // threads to honor TUN, VPN, and proxy routes without blocking Aria2's + // single-threaded event loop. Stock Aria2 builds fall back to clean + // standard resolver options without unsupported flags. let route_contract_available = self .app_handle .state::() .route_contract_available(); - crate::network::apply_aria2_system_resolver_for_daemon( + crate::network::apply_aria2_transfer_resolver( &mut options, route_contract_available, ); diff --git a/src-tauri/src/torrent_probe.rs b/src-tauri/src/torrent_probe.rs index 6b27bf8..f52010a 100644 --- a/src-tauri/src/torrent_probe.rs +++ b/src-tauri/src/torrent_probe.rs @@ -250,8 +250,11 @@ pub(crate) async fn run_metadata_probe_with_deadlines( } .await; + let cleanup_budget = cleanup_deadline + .saturating_duration_since(Instant::now()) + .max(Duration::from_secs(5)); let cleanup_result = match tokio::time::timeout( - cleanup_deadline.saturating_duration_since(Instant::now()), + cleanup_budget, cleanup_metadata_probe(client.as_ref(), &gid), ) .await @@ -497,9 +500,12 @@ impl ProbeCleanupGuard { } let mut first_error = None; + let cleanup_budget = deadline + .saturating_duration_since(Instant::now()) + .max(Duration::from_secs(5)); for gid in self.gids.clone() { match tokio::time::timeout( - deadline.saturating_duration_since(Instant::now()), + cleanup_budget, cleanup_metadata_probe(self.client.as_ref(), &gid), ) .await