fix(torrent): repair magnet handoff and TUN discovery

- recognize direct magnet schemes and keep metadata parents out of terminal completion
- adopt validated followed payload GIDs with bounded, lifecycle-fenced reconciliation
- route direct Torrent DNS through the system resolver for TUN clients
- make magnet preview optional and repair stale fallback drafts
- release paused handoff permits and show peer-wait state consistently
- cover the lifecycle and failure paths with Torrent smokes and regression tests
This commit is contained in:
NimBold
2026-08-25 02:26:11 +03:30
parent b5ee53140d
commit 51db4fc6b7
7 changed files with 1019 additions and 29 deletions
+96 -1
View File
@@ -5260,6 +5260,19 @@ async fn pause_download(
);
}
Ok((status, progress)) if status == "complete" => {
if state
.queue_manager
.aria2_direct_magnet_needs_child_handoff(&id, gid)
.await
{
state.queue_manager.emit_state(
id.clone(),
crate::ipc::DownloadStatus::Downloading,
);
return Err(format!(
"{pause_error}; Torrent metadata is complete but its payload is still being discovered"
));
}
state.queue_manager.clear_aria2_allocation(&id).await;
state
.queue_manager
@@ -5305,6 +5318,25 @@ async fn pause_download(
log::info!("aria2 pause [{}]: gid {} paused", id, gid);
}
"complete" => {
if state
.queue_manager
.aria2_direct_magnet_needs_child_handoff(&id, gid)
.await
{
state.queue_manager.emit_state(
id.clone(),
crate::ipc::DownloadStatus::Downloading,
);
log::info!(
"aria2 pause [{}]: gid {} completed metadata; deferring pause until the payload child is mapped",
id,
gid
);
return Err(
"Torrent metadata is complete but its payload is still being discovered"
.to_string(),
);
}
state.queue_manager.clear_aria2_allocation(&id).await;
// Aria2 can reach complete before its terminal event updates
// Firelink's row. Treat a pause request in that narrow window
@@ -5678,6 +5710,21 @@ async fn resume_download(
return;
}
Ok((status, progress)) if status == "complete" => {
if queue_manager
.aria2_direct_magnet_needs_child_handoff(&id_clone, &gid_clone)
.await
{
queue_manager.emit_state(
id_clone.clone(),
crate::ipc::DownloadStatus::Downloading,
);
log::info!(
"aria2 resume [{}]: gid {} completed metadata after an ambiguous unpause; deferring until the payload child is mapped",
id_clone,
gid_clone
);
return;
}
log::info!(
"aria2 resume [{}]: {} but daemon reports gid {} complete; reconciling completion",
id_clone,
@@ -5777,6 +5824,21 @@ async fn resume_download(
match status_after_unpause.as_str() {
"active" | "waiting" => {}
"complete" => {
if queue_manager
.aria2_direct_magnet_needs_child_handoff(&id_clone, &gid_clone)
.await
{
queue_manager.emit_state(
id_clone.clone(),
crate::ipc::DownloadStatus::Downloading,
);
log::info!(
"aria2 resume [{}]: gid {} completed metadata after unpause; deferring until the payload child is mapped",
id_clone,
gid_clone
);
return;
}
queue_manager
.apply_completion_locked_with_progress(
&id_clone,
@@ -5945,6 +6007,23 @@ async fn resume_download(
Ok(true)
}
"complete" | "error" | "removed" => {
if status == "complete"
&& state
.queue_manager
.aria2_direct_magnet_needs_child_handoff(&id, &gid)
.await
{
state.queue_manager.emit_state(
id.clone(),
crate::ipc::DownloadStatus::Downloading,
);
log::info!(
"aria2 resume [{}]: gid {} completed metadata; deferring re-enqueue until the payload child is mapped",
id,
gid
);
return Ok(true);
}
drop(control_guard);
state.queue_manager.next_aria2_control_epoch(&id).await;
state.queue_manager.cancel_aria2_retries(&id).await;
@@ -7356,6 +7435,10 @@ const ARIA2_RECONCILIATION_STATUS_FIELDS: &[&str] = &[
"completedLength",
"totalLength",
"uploadLength",
// Direct magnets complete a metadata parent before Aria2 exposes the
// payload child. QueueManager validates this relationship before
// accepting a terminal event.
"followedBy",
];
fn aria2_reconciliation_status_params(gid: &str) -> serde_json::Value {
@@ -9030,6 +9113,15 @@ async fn resolve_magnet_metadata(
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
options.insert("timeout".to_string(), serde_json::json!("60"));
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
if proxy_value
.as_deref()
.is_none_or(|proxy| proxy.trim().is_empty())
{
// Keep optional magnet metadata refreshes on the same system DNS
// route as direct Torrent transfers when a TUN client owns resolver
// interception. Explicit proxy routes retain their own DNS policy.
options.insert("async-dns".to_string(), serde_json::json!("false"));
}
if let Some(proxy) = proxy_value.as_deref() {
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
}
@@ -14173,6 +14265,9 @@ mod tests {
assert!(fields
.iter()
.any(|field| field.as_str() == Some("uploadLength")));
assert!(fields
.iter()
.any(|field| field.as_str() == Some("followedBy")));
}
#[test]
@@ -18530,7 +18625,7 @@ pub fn run() {
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.tellStatus",
serde_json::json!([gid, ["status", "completedLength", "totalLength"]]),
serde_json::json!([gid, ["status", "completedLength", "totalLength", "followedBy"]]),
)
.await
.ok()
+704 -9
View File
@@ -58,6 +58,8 @@ pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999";
pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
pub const MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB: u32 = 1_048_576;
const MAX_ARIA2_MAGNET_FOLLOWED_GIDS: usize = 16;
const ARIA2_MAGNET_CHILD_HANDOFF_TIMEOUT: Duration = Duration::from_secs(60);
/// A per-download zero is different from an empty/global limit: Aria2 uses
/// `0` to remove the item cap, which intentionally lets an item override the
@@ -738,6 +740,37 @@ pub struct Aria2GidMapping {
pub epoch: u64,
}
#[derive(Debug, Clone)]
struct Aria2MagnetHandoffState {
parent_gid: String,
epoch: u64,
started_at: Instant,
}
#[derive(Debug, Clone)]
struct Aria2MagnetChildHandoff {
gid: String,
status: String,
error: Option<String>,
pending: Option<PendingAria2Outcome>,
}
#[derive(Debug, Clone)]
enum Aria2MagnetCompletionDisposition {
/// This GID is a normal Torrent GID, or a magnet whose final GID already
/// owns real files. The normal terminal path remains authoritative.
NotMetadataParent,
/// Aria2 has completed metadata acquisition but has not exposed the
/// payload child yet. Keep the permit and mapping alive for reconciliation.
Deferred,
/// The payload child was validated and atomically adopted by Firelink.
Adopted(Aria2MagnetChildHandoff),
/// Aria2 exposed a metadata parent but never exposed a valid payload child
/// before the bounded handoff deadline. Fail the download instead of
/// claiming that metadata bytes are the requested payload.
Failed(String),
}
#[derive(Clone, Copy, Debug)]
struct Aria2ConnectionOptions {
epoch: u64,
@@ -887,6 +920,100 @@ impl PendingAria2Outcome {
}
}
fn aria2_magnet_parent_has_metadata_file(status: &serde_json::Value) -> bool {
status
.get("files")
.and_then(serde_json::Value::as_array)
.is_some_and(|files| {
files.iter().any(|file| {
file.get("path")
.and_then(serde_json::Value::as_str)
.is_some_and(|path| path.starts_with("[METADATA]"))
})
})
}
fn aria2_magnet_parent_has_payload_file(status: &serde_json::Value) -> bool {
status
.get("files")
.and_then(serde_json::Value::as_array)
.is_some_and(|files| {
files.iter().any(|file| {
file.get("path")
.and_then(serde_json::Value::as_str)
.is_some_and(|path| !path.is_empty() && !path.starts_with("[METADATA]"))
})
})
}
fn aria2_magnet_followed_gids(
status: &serde_json::Value,
parent_gid: &str,
) -> Vec<String> {
status
.get("followedBy")
.and_then(serde_json::Value::as_array)
.into_iter()
.flat_map(|gids| gids.iter())
.filter_map(serde_json::Value::as_str)
.filter(|gid| {
!gid.is_empty()
&& *gid != parent_gid
&& gid.len() <= 64
&& gid.bytes().all(|byte| byte.is_ascii_hexdigit())
})
.take(MAX_ARIA2_MAGNET_FOLLOWED_GIDS)
.map(str::to_string)
.collect()
}
fn aria2_magnet_status_error(status: &serde_json::Value) -> String {
let error_code = status
.get("errorCode")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty());
let error_message = status
.get("errorMessage")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("aria2 magnet payload child ended unexpectedly");
let message = match error_code {
Some(code) => format!("aria2 error code {code}: {error_message}"),
None => error_message.to_string(),
};
crate::redact_sensitive_text(&message)
}
fn is_direct_magnet_payload(payload: &SpawnPayload) -> bool {
payload.is_torrent
&& !payload.torrent_verify_only
&& payload.torrent_path.is_none()
&& payload
.url
.trim_start()
.get(..7)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:"))
}
fn torrent_uses_direct_network(payload: &SpawnPayload) -> bool {
payload.is_torrent
&& !payload.torrent_verify_only
&& payload.proxy.as_deref().is_none_or(|proxy| {
let proxy = proxy.trim();
proxy.is_empty() || proxy.eq_ignore_ascii_case("none")
})
}
fn aria2_effective_resolver_mode(payload: &SpawnPayload) -> &'static str {
if payload.aria2_resolver_mode == Aria2ResolverMode::System
|| torrent_uses_direct_network(payload)
{
"system"
} else {
"automatic"
}
}
/// Result of recycling an aria2 transfer's connections. A refresh can race
/// with daemon completion or leave the transfer paused after an ambiguous
/// unpause failure, so callers must handle the verified daemon outcome.
@@ -1239,6 +1366,14 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
/// Gids whose terminal events must be ignored after a lifecycle transition.
/// This is bounded so a long-lived daemon cannot grow the set indefinitely.
aria2_ignored_gids: Mutex<VecDeque<String>>,
/// Direct magnets have a short Aria2 metadata-parent handoff before the
/// payload child becomes visible. Keep that observation bounded and
/// lifecycle-scoped so a missing child cannot leak a permit forever.
aria2_magnet_handoffs: Mutex<HashMap<String, Aria2MagnetHandoffState>>,
/// Once a direct magnet's payload child is adopted, remember that child
/// identity so terminal reconciliation does not request the full Aria2
/// file list again for a potentially very large Torrent.
aria2_magnet_payload_gids: Mutex<HashMap<String, Aria2GidMapping>>,
/// Wakes retry backoff workers when a pause/remove action cancels them.
aria2_retry_cancel_notify: Notify,
@@ -1320,6 +1455,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
aria2_retry_inflight: Mutex::new(HashMap::new()),
aria2_retrying_gids: Mutex::new(HashSet::new()),
aria2_ignored_gids: Mutex::new(VecDeque::new()),
aria2_magnet_handoffs: Mutex::new(HashMap::new()),
aria2_magnet_payload_gids: Mutex::new(HashMap::new()),
aria2_retry_cancel_notify: Notify::new(),
aria2_control_locks: Arc::new(StdMutex::new(HashMap::new())),
aria2_gid_state: Mutex::new(()),
@@ -4071,7 +4208,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.semaphore.available_permits()
}
fn emit_state(&self, id: impl Into<String>, status: DownloadStatus) {
pub(crate) fn emit_state(&self, id: impl Into<String>, status: DownloadStatus) {
use tauri::Emitter;
let _ = self
.app_handle
@@ -4621,6 +4758,11 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// to reconcile against the correct event path. In particular, buffered
/// errors must still pass through transient retry classification.
pub async fn remember_gid(&self, id: String, gid: String) -> Option<PendingAria2Outcome> {
// A new GID starts a fresh Aria2 observation. In particular, a
// missing-GID recovery or retry must not inherit the handoff deadline
// of the retired magnet metadata parent.
self.clear_aria2_magnet_handoff(&id).await;
self.aria2_magnet_payload_gids.lock().await.remove(&id);
let epoch = self.current_aria2_control_epoch(&id).await;
let buffered_outcome = {
let _gid_state = self.aria2_gid_state.lock().await;
@@ -4696,6 +4838,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
if let Some(options) = self.aria2_connection_options.lock().await.get_mut(id) {
options.epoch = epoch;
}
if self.aria2_gid_for_download(id).as_deref() == Some(gid) {
if let Some(payload_mapping) = self.aria2_magnet_payload_gids.lock().await.get_mut(id) {
if payload_mapping.id == id {
payload_mapping.epoch = epoch;
}
}
}
true
}
@@ -4721,6 +4870,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
outcome: PendingOutcome,
progress: Option<DownloadStateProgress>,
) {
self.clear_aria2_magnet_handoff(id).await;
self.clear_aria2_allocation(id).await;
if matches!(&outcome, PendingOutcome::Complete) {
self.capture_torrent_verification_evidence(id).await;
@@ -4940,6 +5090,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
pub async fn clear_aria2_retry_state(&self, id: &str) {
self.clear_aria2_magnet_handoff(id).await;
self.aria2_magnet_payload_gids.lock().await.remove(id);
self.aria2_payloads.lock().await.remove(id);
self.aria2_retry_strikes.lock().await.remove(id);
self.aria2_connection_options.lock().await.remove(id);
@@ -4965,6 +5117,295 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.aria2_retry_strikes.lock().await.remove(id);
}
async fn note_aria2_magnet_handoff(
&self,
id: &str,
parent_gid: &str,
epoch: u64,
) -> bool {
let mut handoffs = self.aria2_magnet_handoffs.lock().await;
let state = handoffs
.entry(id.to_string())
.or_insert_with(|| Aria2MagnetHandoffState {
parent_gid: parent_gid.to_string(),
epoch,
started_at: Instant::now(),
});
if state.parent_gid != parent_gid || state.epoch != epoch {
*state = Aria2MagnetHandoffState {
parent_gid: parent_gid.to_string(),
epoch,
started_at: Instant::now(),
};
}
state.started_at.elapsed() >= ARIA2_MAGNET_CHILD_HANDOFF_TIMEOUT
}
async fn clear_aria2_magnet_handoff(&self, id: &str) {
self.aria2_magnet_handoffs.lock().await.remove(id);
}
async fn remove_aria2_magnet_parent_result(&self, gid: &str) {
let Some(state) = self.app_handle.try_state::<crate::AppState>() else {
return;
};
let port = state
.aria2_port
.load(std::sync::atomic::Ordering::Relaxed);
let secret = state.aria2_secret.clone();
drop(state);
for attempt in 1..=3 {
match crate::rpc_call(
port,
&secret,
"aria2.removeDownloadResult",
serde_json::json!([gid]),
)
.await
{
Ok(result) if result.as_str() == Some(gid) => return,
Ok(result) => {
log::debug!(
"aria2 magnet handoff [stage=cleanup gid={} attempt={} result=unexpected_return value={}]",
gid,
attempt,
crate::redact_sensitive_text(&result.to_string())
);
return;
}
Err(_error) if attempt < 3 => {
tokio::time::sleep(Duration::from_millis(50 * attempt as u64)).await;
}
Err(error) => log::debug!(
"aria2 magnet handoff [stage=cleanup gid={} attempts={} result=deferred error_class={} error_code={}]",
gid,
attempt,
network_error_class(&error),
diagnostic_error_code(&error)
),
}
}
}
/// Aria2 represents a direct magnet as a short metadata parent lifecycle
/// followed by a real payload child GID. Firelink owns one download ID,
/// permit, and lifecycle epoch, so completion of the metadata parent must
/// be converted into an atomic mapping transfer before terminal handling.
async fn reconcile_aria2_magnet_parent_completion(
&self,
id: &str,
parent_gid: &str,
expected_mapping: &Aria2GidMapping,
) -> Aria2MagnetCompletionDisposition {
let payload = self.aria2_payloads.lock().await.get(id).cloned();
let Some(_) = payload.filter(is_direct_magnet_payload) else {
return Aria2MagnetCompletionDisposition::NotMetadataParent;
};
if !self.is_current_aria2_gid_mapping(parent_gid, expected_mapping)
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Aria2MagnetCompletionDisposition::Deferred;
}
// A child adopted by this lifecycle is already known to be the real
// payload. Avoid asking Aria2 for its complete file list just to prove
// that a terminal child is not the short metadata parent.
let adopted_payload = self
.aria2_magnet_payload_gids
.lock()
.await
.get(id)
.is_some_and(|mapping| {
mapping == expected_mapping
&& self.aria2_gid_for_download(id).as_deref() == Some(parent_gid)
});
if adopted_payload {
return Aria2MagnetCompletionDisposition::NotMetadataParent;
}
let handoff_expired = self
.note_aria2_magnet_handoff(id, parent_gid, expected_mapping.epoch)
.await;
let Some(state) = self.app_handle.try_state::<crate::AppState>() else {
return Aria2MagnetCompletionDisposition::Deferred;
};
let port = state
.aria2_port
.load(std::sync::atomic::Ordering::Relaxed);
let secret = state.aria2_secret.clone();
drop(state);
let parent_status = match crate::rpc_call(
port,
&secret,
"aria2.tellStatus",
serde_json::json!([parent_gid, ["status", "followedBy", "files", "errorCode", "errorMessage"]]),
)
.await
{
Ok(status) => status,
Err(error) => {
log::debug!(
"aria2 magnet handoff [stage=parent_status id={} gid={} epoch={} result=unavailable error_class={} error_code={}]",
id,
parent_gid,
expected_mapping.epoch,
network_error_class(&error),
diagnostic_error_code(&error)
);
return if handoff_expired {
Aria2MagnetCompletionDisposition::Failed(
"Aria2 completed magnet metadata but did not expose the payload download"
.to_string(),
)
} else {
Aria2MagnetCompletionDisposition::Deferred
};
}
};
let parent_status_name = parent_status
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let parent_has_metadata = aria2_magnet_parent_has_metadata_file(&parent_status);
let parent_has_payload = aria2_magnet_parent_has_payload_file(&parent_status);
let child_gids = aria2_magnet_followed_gids(&parent_status, parent_gid);
let child_count = child_gids.len();
// Aria2 can expose more than one followed GID. Query the bounded set
// concurrently so a missing or slow child cannot hold the per-download
// control lock while every 3-second RPC timeout is consumed in series.
let child_statuses = futures_util::future::join_all(child_gids.into_iter().map(|child_gid| async {
let result = crate::rpc_call(
port,
&secret,
"aria2.tellStatus",
serde_json::json!([child_gid, [
"status",
"belongsTo",
"errorCode",
"errorMessage",
"completedLength",
"totalLength",
"uploadLength",
"seeder"
]]),
)
.await;
match result {
Ok(status) => (child_gid, Some(status)),
Err(error) => {
log::debug!(
"aria2 magnet handoff [stage=child_status id={} parent_gid={} child_gid={} epoch={} result=unavailable error_class={} error_code={}]",
id,
parent_gid,
child_gid,
expected_mapping.epoch,
network_error_class(&error),
diagnostic_error_code(&error)
);
(child_gid, None)
}
}
})).await;
for (child_gid, child_status) in child_statuses {
let Some(child_status) = child_status else {
continue;
};
if child_status
.get("belongsTo")
.and_then(serde_json::Value::as_str)
.is_some_and(|belongs_to| belongs_to != parent_gid)
{
continue;
}
let child_status_name = child_status
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !matches!(
child_status_name,
"active" | "waiting" | "paused" | "complete" | "error" | "removed"
) {
continue;
}
let pending_child = {
let _gid_state = self.aria2_gid_state.lock().await;
let current_mapping = self.aria2_gid_mapping(parent_gid);
if current_mapping.as_ref() != Some(expected_mapping) {
return Aria2MagnetCompletionDisposition::Deferred;
}
let child_owned_by_other_download = self
.aria2_gid_mapping(&child_gid)
.is_some_and(|mapping| mapping.id != id);
if child_owned_by_other_download {
continue;
}
{
let mut gids = self.aria2_gids.write().unwrap();
gids.remove(parent_gid);
gids.insert(child_gid.clone(), expected_mapping.clone());
}
self.aria2_magnet_payload_gids
.lock()
.await
.insert(id.to_string(), expected_mapping.clone());
self.unignore_aria2_gid_locked(&child_gid).await;
self.ignore_aria2_gid_locked(parent_gid).await;
self.pending_download_starts.lock().await.remove(&child_gid);
let mut buffered = self.pending_completion.lock().await;
buffered.remove(parent_gid);
buffered.remove(&child_gid).map(|(_buffered_id, outcome)| outcome)
};
self.clear_aria2_magnet_handoff(id).await;
log::info!(
"aria2 magnet handoff [stage=gid_transition id={} parent_gid={} child_gid={} epoch={} child_status={} action=adopted]",
id,
parent_gid,
child_gid,
expected_mapping.epoch,
child_status_name
);
return Aria2MagnetCompletionDisposition::Adopted(Aria2MagnetChildHandoff {
gid: child_gid,
status: child_status_name.to_string(),
error: matches!(child_status_name, "error" | "removed")
.then(|| aria2_magnet_status_error(&child_status)),
pending: pending_child,
});
}
if matches!(parent_status_name, "error" | "removed") {
self.clear_aria2_magnet_handoff(id).await;
return Aria2MagnetCompletionDisposition::Failed(aria2_magnet_status_error(
&parent_status,
));
}
if handoff_expired {
Aria2MagnetCompletionDisposition::Failed(
"Aria2 completed magnet metadata but did not expose the payload download"
.to_string(),
)
} else {
log::debug!(
"aria2 magnet handoff [stage=child_wait id={} gid={} epoch={} status={} child_count={} parent_metadata={} parent_payload={} action=deferred]",
id,
parent_gid,
expected_mapping.epoch,
parent_status_name,
child_count,
parent_has_metadata,
parent_has_payload
);
Aria2MagnetCompletionDisposition::Deferred
}
}
async fn finish_aria2_retry(&self, id: &str, gid: &str, retry_epoch: u64) {
self.release_aria2_retry_inflight(id, retry_epoch).await;
self.aria2_retrying_gids.lock().await.remove(gid);
@@ -5225,6 +5666,41 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.aria2_gid_mapping(gid).as_ref() == Some(expected)
}
/// A direct magnet's metadata parent is not a terminal payload GID. The
/// parent may report `complete` before Aria2 exposes the followed child,
/// so imperative pause/resume commands must defer to the same handoff
/// reconciler as WebSocket and poller events instead of completing or
/// re-enqueueing the metadata marker.
pub(crate) async fn aria2_direct_magnet_needs_child_handoff(
&self,
id: &str,
gid: &str,
) -> bool {
let direct_magnet = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(is_direct_magnet_payload);
if !direct_magnet {
return false;
}
let Some(mapping) = self.aria2_gid_mapping(gid) else {
return false;
};
if mapping.id != id || self.aria2_gid_for_download(id).as_deref() != Some(gid) {
return false;
}
!self
.aria2_magnet_payload_gids
.lock()
.await
.get(id)
.is_some_and(|payload_mapping| payload_mapping == &mapping)
}
pub fn aria2_gid_mappings(&self) -> Vec<(String, String)> {
self.aria2_gids
.read()
@@ -5400,6 +5876,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// Remove every gid mapping for a download and discard buffered terminal
/// events for those gids. Returns the most recently encountered gid.
pub async fn forget_aria2_gid(&self, id: &str) -> Option<String> {
self.clear_aria2_magnet_handoff(id).await;
self.aria2_magnet_payload_gids.lock().await.remove(id);
self.clear_aria2_allocation(id).await;
let _gid_state = self.aria2_gid_state.lock().await;
let removed = {
@@ -5592,10 +6070,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
mapping.epoch,
strike,
action,
match payload.aria2_resolver_mode {
Aria2ResolverMode::Automatic => "automatic",
Aria2ResolverMode::System => "system",
},
aria2_effective_resolver_mode(&payload),
network_error_class(&error),
error_code,
requested_connections,
@@ -5876,6 +6351,77 @@ impl<R: tauri::Runtime> QueueManager<R> {
{
return;
}
if matches!(&outcome, PendingOutcome::Complete) {
match self
.reconcile_aria2_magnet_parent_completion(&mapping.id, gid, &mapping)
.await
{
Aria2MagnetCompletionDisposition::NotMetadataParent => {}
Aria2MagnetCompletionDisposition::Deferred => {
// A metadata parent is not the user's payload. Keep the
// current permit and mapping alive until the next
// poll/reconnect observation exposes the child GID.
self.emit_state(&mapping.id, DownloadStatus::Downloading);
return;
}
Aria2MagnetCompletionDisposition::Failed(error) => {
self.apply_completion_locked_with_progress(
&mapping.id,
PendingOutcome::Error(error),
progress,
)
.await;
return;
}
Aria2MagnetCompletionDisposition::Adopted(child) => {
let id = mapping.id.clone();
drop(_control_guard);
// Parent-result cleanup is best effort and may consume
// several bounded RPC attempts. Do it after releasing
// the per-download control lock so a stale daemon result
// cannot block pause, resume, or removal of the adopted
// payload lifecycle.
self.remove_aria2_magnet_parent_result(gid).await;
if let Some(pending) = child.pending {
Box::pin(self.handle_aria2_pending_event(&child.gid, pending)).await;
} else {
match child.status.as_str() {
"complete" => {
Box::pin(self.handle_aria2_event(&child.gid, PendingOutcome::Complete))
.await;
}
"error" | "removed" => {
Box::pin(self.handle_aria2_event(
&child.gid,
PendingOutcome::Error(child.error.unwrap_or_else(|| {
"aria2 magnet payload child ended unexpectedly".to_string()
})),
))
.await;
}
"paused" => {
// The child may have been paused by Aria2
// while the metadata-parent event was in
// flight. Mirror the normal pause boundary so
// this handoff cannot strand a queue permit.
self.clear_aria2_allocation(&id).await;
self.next_aria2_control_epoch(&id).await;
self.cancel_aria2_retries(&id).await;
self.release_seed_tracking(&id);
self.release_permit(&id).await;
self.emit_state(&id, DownloadStatus::Paused);
}
"active" | "waiting" => {
self.emit_state(&id, DownloadStatus::Downloading)
}
_ => {}
}
}
return;
}
}
}
self.apply_completion_locked_with_progress(&mapping.id, outcome, progress)
.await;
}
@@ -7660,6 +8206,21 @@ fn apply_aria2_torrent_options(
"bt-min-crypto-level".to_string(),
serde_json::json!(min_crypto_level),
);
// Keep metadata probing and generic .torrent following out of the normal
// transfer policy. A direct magnet still has an Aria2 metadata parent;
// QueueManager adopts its validated payload child explicitly.
options.insert("bt-metadata-only".to_string(), serde_json::json!("false"));
options.insert("bt-save-metadata".to_string(), serde_json::json!("false"));
options.insert("follow-torrent".to_string(), serde_json::json!("false"));
if torrent_uses_direct_network(payload) {
// Aria2's async resolver is independent of the system resolver. TUN
// clients commonly install DNS interception/routing at the system
// resolver boundary, so direct Torrent discovery must use the same
// resolver path as the working HTTP/media clients. Do not override an
// explicitly configured proxy route, where local DNS could leak or
// bypass the proxy's name-resolution policy.
options.insert("async-dns".to_string(), serde_json::json!("false"));
}
let seed_time = payload
.torrent_seed_time
@@ -8024,10 +8585,7 @@ impl SidecarSpawner for ProductionSpawner {
requested_connections,
transfer_connections,
transfer_uris.len(),
match payload.aria2_resolver_mode {
Aria2ResolverMode::Automatic => "automatic",
Aria2ResolverMode::System => "system",
},
aria2_effective_resolver_mode(payload),
proxy_route_for_log(payload.proxy.as_deref()),
admission_started.elapsed().as_millis()
);
@@ -9499,6 +10057,143 @@ mod tests {
);
}
#[test]
fn direct_magnet_parent_detection_requires_a_metadata_file() {
let parent = serde_json::json!({
"status": "complete",
"followedBy": ["0123456789abcdef"],
"files": [{"path": "[METADATA]firelink-torrent-runtime"}],
});
assert!(aria2_magnet_parent_has_metadata_file(&parent));
assert!(!aria2_magnet_parent_has_payload_file(&parent));
assert_eq!(
aria2_magnet_followed_gids(&parent, "fedcba9876543210"),
vec!["0123456789abcdef".to_string()]
);
let malformed = serde_json::json!({
"followedBy": [
"not-a-gid",
"0123456789abcdef",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef1"
],
"files": [{"path": "payload.bin"}],
});
assert!(!aria2_magnet_parent_has_metadata_file(&malformed));
assert!(aria2_magnet_parent_has_payload_file(&malformed));
assert_eq!(
aria2_magnet_followed_gids(&malformed, "parent"),
vec!["0123456789abcdef".to_string()]
);
let final_payload = serde_json::json!({
"status": "complete",
"files": [{"path": "/downloads/payload.bin"}],
});
assert!(aria2_magnet_parent_has_payload_file(&final_payload));
}
#[tokio::test]
async fn direct_magnet_control_status_cannot_treat_parent_as_payload() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
let id = "direct-magnet-control";
let gid = "0123456789abcdef";
let payload = SpawnPayload {
url: "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"
.to_string(),
is_torrent: true,
..Default::default()
};
assert!(is_direct_magnet_payload(&payload));
manager.aria2_payloads.lock().await.insert(
id.to_string(),
payload,
);
manager.remember_gid(id.to_string(), gid.to_string()).await;
assert_eq!(manager.aria2_gid_for_download(id).as_deref(), Some(gid));
assert!(
manager
.aria2_direct_magnet_needs_child_handoff(id, gid)
.await
);
manager
.aria2_magnet_payload_gids
.lock()
.await
.insert(
id.to_string(),
Aria2GidMapping {
id: id.to_string(),
epoch: 0,
},
);
assert!(!manager
.aria2_direct_magnet_needs_child_handoff(id, gid)
.await);
}
#[test]
fn normal_torrent_options_make_metadata_and_following_policy_explicit() {
let mut options = serde_json::Map::new();
apply_aria2_torrent_options(
&mut options,
&SpawnPayload {
is_torrent: true,
..Default::default()
},
)
.unwrap();
assert_eq!(
options.get("bt-metadata-only"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("bt-save-metadata"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("follow-torrent"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("async-dns"),
Some(&serde_json::json!("false"))
);
let mut proxied_options = serde_json::Map::new();
apply_aria2_torrent_options(
&mut proxied_options,
&SpawnPayload {
is_torrent: true,
proxy: Some("http://127.0.0.1:8080".to_string()),
..Default::default()
},
)
.unwrap();
assert!(!proxied_options.contains_key("async-dns"));
assert_eq!(
aria2_effective_resolver_mode(&SpawnPayload {
is_torrent: true,
..Default::default()
}),
"system"
);
assert_eq!(
aria2_effective_resolver_mode(&SpawnPayload {
is_torrent: true,
proxy: Some("http://127.0.0.1:8080".to_string()),
..Default::default()
}),
"automatic"
);
}
#[test]
fn generic_aria2_downloads_disable_followed_child_gids() {
let mut options = serde_json::Map::new();