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)
This commit is contained in:
NimBold
2026-09-03 08:55:36 +03:30
parent 4814c8e92b
commit 3f4f344620
4 changed files with 87 additions and 23 deletions
+20 -16
View File
@@ -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::<Aria2DaemonGuard>()
.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
}
+52
View File
@@ -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<String, serde_json::Value>,
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!(
+7 -5
View File
@@ -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::<crate::Aria2DaemonGuard>()
.route_contract_available();
crate::network::apply_aria2_system_resolver_for_daemon(
crate::network::apply_aria2_transfer_resolver(
&mut options,
route_contract_available,
);
+8 -2
View File
@@ -250,8 +250,11 @@ pub(crate) async fn run_metadata_probe_with_deadlines<C: RpcClient + 'static>(
}
.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<C: RpcClient + 'static> ProbeCleanupGuard<C> {
}
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