mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
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:
@@ -433,7 +433,10 @@ async function cleanupRuntime() {
|
||||
|
||||
async function tellStatus(daemon, gid) {
|
||||
return rpc(daemon.rpcPort, daemon.secret, 'aria2.tellStatus', [gid, [
|
||||
'gid',
|
||||
'status',
|
||||
'belongsTo',
|
||||
'followedBy',
|
||||
'errorCode',
|
||||
'errorMessage',
|
||||
'completedLength',
|
||||
@@ -626,6 +629,7 @@ async function main() {
|
||||
const seedParent = path.join(tempRoot, 'seed');
|
||||
const seedRoot = path.join(seedParent, 'firelink-torrent-runtime');
|
||||
const probeDir = path.join(tempRoot, 'probe');
|
||||
const directDir = path.join(tempRoot, 'direct-magnet');
|
||||
const finalDir = path.join(tempRoot, 'final');
|
||||
const integrityDir = path.join(tempRoot, 'integrity');
|
||||
const encryptionDir = path.join(tempRoot, 'encryption');
|
||||
@@ -633,7 +637,7 @@ async function main() {
|
||||
const removeUnselectedDir = path.join(tempRoot, 'remove-unselected');
|
||||
const stallDir = path.join(tempRoot, 'stall');
|
||||
const dhtStateDir = path.join(tempRoot, 'aria2-state');
|
||||
for (const directory of [seedRoot, probeDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir, dhtStateDir]) fs.mkdirSync(directory, { recursive: true });
|
||||
for (const directory of [seedRoot, probeDir, directDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir, dhtStateDir]) fs.mkdirSync(directory, { recursive: true });
|
||||
|
||||
const dhtPath = path.join(dhtStateDir, 'dht.dat');
|
||||
const dht6Path = path.join(dhtStateDir, 'dht6.dat');
|
||||
@@ -706,6 +710,82 @@ async function main() {
|
||||
directory: probeDir,
|
||||
extraArgs: ['--seed-time=0'],
|
||||
});
|
||||
const directGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[magnet], {
|
||||
dir: directDir,
|
||||
'bt-force-encryption': 'false',
|
||||
'bt-require-crypto': 'false',
|
||||
'bt-min-crypto-level': 'plain',
|
||||
'seed-time': '0',
|
||||
'file-allocation': 'prealloc',
|
||||
'bt-metadata-only': 'false',
|
||||
'bt-save-metadata': 'false',
|
||||
'follow-torrent': 'false',
|
||||
'async-dns': 'false',
|
||||
'max-tries': '3',
|
||||
'retry-wait': '2',
|
||||
'connect-timeout': '20',
|
||||
timeout: '60',
|
||||
continue: 'true',
|
||||
'always-resume': 'true',
|
||||
'auto-file-renaming': 'false',
|
||||
// Keep the parent/child handoff observable on fast local CI runners.
|
||||
'max-download-limit': '32K',
|
||||
}]);
|
||||
let directLastParent;
|
||||
let directLastChild;
|
||||
const directHandoff = await waitFor(`normal magnet ${directGid} to expose its payload child`, async () => {
|
||||
assertDaemonRunning(client);
|
||||
const parent = await tellStatus(client, directGid);
|
||||
directLastParent = parent;
|
||||
if (parent.status === 'error' || parent.status === 'removed') {
|
||||
throw new Error(`normal magnet parent ended ${parent.status}: ${parent.errorCode || ''} ${parent.errorMessage || ''}`);
|
||||
}
|
||||
const childGid = parent.followedBy?.find(gid => typeof gid === 'string' && gid.length > 0);
|
||||
if (!childGid) return false;
|
||||
const child = await tellStatus(client, childGid);
|
||||
directLastChild = child;
|
||||
if (child.belongsTo && child.belongsTo !== directGid) return false;
|
||||
if (child.status === 'error' || child.status === 'removed') {
|
||||
throw new Error(`normal magnet payload ended ${child.status}: ${child.errorCode || ''} ${child.errorMessage || ''}`);
|
||||
}
|
||||
return { parent, childGid, child };
|
||||
}, 30000).catch(error => {
|
||||
throw new Error(`${error.message}; last parent ${JSON.stringify(directLastParent)}; last child ${JSON.stringify(directLastChild)}`);
|
||||
});
|
||||
const directStatus = await waitForDataComplete(client, directHandoff.childGid, 30000);
|
||||
const directOutput = path.join(directDir, torrent.name, 'selected.bin');
|
||||
const directOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [directHandoff.childGid]);
|
||||
assert(
|
||||
Number(directStatus.completedLength) === Number(directStatus.totalLength)
|
||||
&& Number(directStatus.totalLength) > 0,
|
||||
`normal magnet payload did not complete: ${JSON.stringify(directStatus)}`,
|
||||
);
|
||||
assert(fs.existsSync(directOutput), `normal magnet output is missing: ${directOutput}`);
|
||||
assert(!fs.existsSync(path.join(directDir, 'download')), 'normal magnet created a metadata-sized download artifact');
|
||||
const directBytes = fs.readFileSync(directOutput);
|
||||
assert(directBytes.length === torrent.files[0].data.length && directBytes.equals(torrent.files[0].data), `normal magnet output content differs (${directBytes.length}/${torrent.files[0].data.length}, ${directBytes[0]}/${torrent.files[0].data[0]})`);
|
||||
assert(directHandoff.parent.status === 'complete', `normal magnet parent did not complete metadata: ${JSON.stringify(directHandoff.parent)}`);
|
||||
assert(directHandoff.parent.files?.some(file => String(file.path).startsWith('[METADATA]')), 'normal magnet parent did not expose a metadata file');
|
||||
assert(directOptions['bt-metadata-only'] === 'false', 'normal magnet child did not retain payload mode');
|
||||
assert(directOptions['async-dns'] === 'false', 'direct Torrent did not retain system DNS resolution');
|
||||
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directGid]);
|
||||
try {
|
||||
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directHandoff.childGid]);
|
||||
} catch {
|
||||
// An active/seeding child must be force-removed below; completed
|
||||
// children can be retired directly as a result record.
|
||||
}
|
||||
const childForceRemoved = await forceRemoveIfPresent(client, directHandoff.childGid);
|
||||
try {
|
||||
await rpc(client.rpcPort, client.secret, 'aria2.removeDownloadResult', [directHandoff.childGid]);
|
||||
} catch {
|
||||
// A force-removed active child may already be gone.
|
||||
}
|
||||
if (childForceRemoved) {
|
||||
await waitForRemoved(client, directHandoff.childGid);
|
||||
}
|
||||
console.log(`[OK] normal magnet adopted payload child ${directHandoff.childGid} instead of completing metadata-only`);
|
||||
|
||||
const probeGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[magnet], {
|
||||
dir: probeDir,
|
||||
'bt-metadata-only': 'true',
|
||||
|
||||
+96
-1
@@ -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
@@ -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();
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
isMagnetUrl,
|
||||
isMetadataRefreshableRow,
|
||||
selectExactMediaSelection,
|
||||
updateRowIfCurrent,
|
||||
type AddDownloadDraftRow,
|
||||
@@ -851,6 +852,15 @@ export const AddDownloadsModal = () => {
|
||||
status: 'metadata-error',
|
||||
formats: undefined,
|
||||
selectedFormat: undefined,
|
||||
...(currentRow.isTorrent
|
||||
? {
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: undefined,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
}
|
||||
: {}),
|
||||
metadataBlockedReason,
|
||||
playlistError: row.isPlaylist
|
||||
? errorMessage
|
||||
@@ -1917,9 +1927,7 @@ export const AddDownloadsModal = () => {
|
||||
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
|
||||
: 'Unknown';
|
||||
const canSubmit = canSubmitMetadataRows(parsedItems);
|
||||
const failedMetadataCount = selectedItems.filter(item =>
|
||||
item.status === 'metadata-error' || item.status === 'fallback'
|
||||
).length;
|
||||
const metadataRefreshableCount = selectedItems.filter(isMetadataRefreshableRow).length;
|
||||
const failedMediaMetadataCount = selectedItems.filter(
|
||||
item => item.status === 'metadata-error' && item.isMedia
|
||||
).length;
|
||||
@@ -2158,8 +2166,8 @@ export const AddDownloadsModal = () => {
|
||||
<div className="add-download-preview-actions flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParsedItems(refreshFailedMetadataRows)}
|
||||
disabled={failedMetadataCount === 0}
|
||||
onClick={() => setParsedItems(rows => refreshFailedMetadataRows(rows, true))}
|
||||
disabled={metadataRefreshableCount === 0}
|
||||
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
|
||||
>
|
||||
<RefreshCw size={12} /> {t($ => $.addDownloads.refreshMetadata)}
|
||||
|
||||
@@ -344,10 +344,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<div className="download-cell-content download-status-content">
|
||||
<div
|
||||
className="download-progress-track"
|
||||
aria-label={allocationVisible ? downloadStatusLabel : undefined}
|
||||
aria-label={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
||||
aria-busy={allocationVisible ? true : undefined}
|
||||
aria-valuetext={allocationVisible ? downloadStatusLabel : undefined}
|
||||
role={allocationVisible ? 'progressbar' : undefined}
|
||||
aria-valuetext={allocationVisible || waitingForPeers ? downloadStatusLabel : undefined}
|
||||
role={allocationVisible || waitingForPeers ? 'progressbar' : undefined}
|
||||
>
|
||||
<div
|
||||
className={`download-progress-fill ${
|
||||
@@ -376,6 +376,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
? download.lastError
|
||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||
? `${downloadStatusLabel} #${queueIndex + 1}`
|
||||
: waitingForPeers
|
||||
? downloadStatusLabel
|
||||
: download.status === 'downloading'
|
||||
? displayPercent
|
||||
: download.status === 'processing'
|
||||
@@ -407,6 +409,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
{downloadStatusLabel} #{queueIndex + 1}
|
||||
</span>
|
||||
</>
|
||||
) : waitingForPeers ? (
|
||||
<span className="truncate">{downloadStatusLabel}</span>
|
||||
) : download.status === 'downloading' || download.status === 'verifying' || download.status === 'moving' ? (
|
||||
displayPercent
|
||||
) : download.status === 'seeding' ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
metadataSummaryMessage,
|
||||
isYouTubePlaylistUrl,
|
||||
isMagnetUrl,
|
||||
isMetadataRefreshableRow,
|
||||
isRemoteTorrentUrl,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
@@ -96,7 +97,7 @@ describe('add download metadata workflow', () => {
|
||||
expect(rows[0]).toMatchObject({
|
||||
isTorrent: true,
|
||||
isMedia: false,
|
||||
status: 'fallback'
|
||||
status: 'ready'
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
isTorrent: true,
|
||||
@@ -539,7 +540,7 @@ describe('add download metadata workflow', () => {
|
||||
const magnet = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
|
||||
const admitted = reconcileDownloadRows(magnet, [])[0];
|
||||
|
||||
expect(admitted.status).toBe('fallback');
|
||||
expect(admitted.status).toBe('ready');
|
||||
expect(canSubmitMetadataRows([admitted])).toBe(true);
|
||||
|
||||
const refreshed = refreshFailedMetadataRows([admitted])[0];
|
||||
@@ -548,6 +549,80 @@ describe('add download metadata workflow', () => {
|
||||
generation: 2,
|
||||
torrentCacheId: `${admitted.id}-2`,
|
||||
});
|
||||
expect(isMetadataRefreshableRow(admitted)).toBe(true);
|
||||
expect(isMetadataRefreshableRow({ ...admitted, status: 'loading' })).toBe(false);
|
||||
expect(isMetadataRefreshableRow(row())).toBe(false);
|
||||
});
|
||||
|
||||
it('refreshes only selected metadata rows when requested by the preview action', () => {
|
||||
const selected = row({
|
||||
id: 'selected-magnet',
|
||||
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
isTorrent: true,
|
||||
selected: true
|
||||
});
|
||||
const unselected = row({
|
||||
id: 'unselected-magnet',
|
||||
sourceUrl: 'magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
||||
isTorrent: true,
|
||||
selected: false
|
||||
});
|
||||
|
||||
const refreshed = refreshFailedMetadataRows([selected, unselected], true);
|
||||
|
||||
expect(refreshed[0]).toMatchObject({ status: 'loading', generation: 2 });
|
||||
expect(refreshed[1]).toBe(unselected);
|
||||
});
|
||||
|
||||
it('invalidates stale torrent metadata before an optional refresh', () => {
|
||||
const refreshed = refreshFailedMetadataRows([row({
|
||||
isTorrent: true,
|
||||
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
status: 'ready',
|
||||
torrentPath: '/managed/old.torrent',
|
||||
torrentCacheId: 'old-cache',
|
||||
torrentInfoHash: 'old-hash',
|
||||
torrentFiles: [{ index: 1, path: 'old.bin', length: 10 }],
|
||||
selectedTorrentFileIndices: [1]
|
||||
})])[0];
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
status: 'loading',
|
||||
generation: 2,
|
||||
torrentCacheId: 'row-1-2'
|
||||
});
|
||||
expect(refreshed.torrentPath).toBeUndefined();
|
||||
expect(refreshed.torrentInfoHash).toBeUndefined();
|
||||
expect(refreshed.torrentFiles).toBeUndefined();
|
||||
expect(refreshed.selectedTorrentFileIndices).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates legacy magnet fallback rows to transfer-ready state', () => {
|
||||
const magnet = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
|
||||
const legacy = row({
|
||||
sourceUrl: magnet,
|
||||
downloadUrl: 'torrent:stale-metadata-hash',
|
||||
status: 'fallback',
|
||||
isTorrent: true,
|
||||
torrentPath: '/managed/stale.torrent',
|
||||
torrentCacheId: 'legacy-cache',
|
||||
torrentInfoHash: 'legacy-hash',
|
||||
torrentFiles: [{ index: 1, path: 'stale.bin', length: 10 }],
|
||||
selectedTorrentFileIndices: [1]
|
||||
});
|
||||
|
||||
const migrated = reconcileDownloadRows(magnet, [legacy])[0];
|
||||
|
||||
expect(migrated).toMatchObject({
|
||||
status: 'ready',
|
||||
downloadUrl: magnet,
|
||||
isTorrent: true
|
||||
});
|
||||
expect(migrated.torrentPath).toBeUndefined();
|
||||
expect(migrated.torrentCacheId).toBeUndefined();
|
||||
expect(migrated.torrentInfoHash).toBeUndefined();
|
||||
expect(migrated.torrentFiles).toBeUndefined();
|
||||
expect(migrated.selectedTorrentFileIndices).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores stale metadata results after generation changes', () => {
|
||||
|
||||
@@ -114,6 +114,14 @@ export const isMagnetUrl = (value: string): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
export const isMagnetTorrentRow = (
|
||||
row: Pick<AddDownloadDraftRow, 'isTorrent' | 'sourceUrl'>
|
||||
): boolean => row.isTorrent === true && isMagnetUrl(row.sourceUrl);
|
||||
|
||||
export const isMetadataRefreshableRow = (row: AddDownloadDraftRow): boolean =>
|
||||
row.status !== 'loading'
|
||||
&& (row.status === 'metadata-error' || isMagnetTorrentRow(row));
|
||||
|
||||
type ParsedInput = {
|
||||
identity: string;
|
||||
sourceUrl: string;
|
||||
@@ -282,7 +290,7 @@ export const reconcileDownloadRows = (
|
||||
file: contextChanged || playlistContextChanged
|
||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||
: preserved.file,
|
||||
status: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'fallback' : 'loading',
|
||||
status: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading',
|
||||
generation: nextGeneration,
|
||||
requestContextVersion,
|
||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||
@@ -311,6 +319,26 @@ export const reconcileDownloadRows = (
|
||||
selectedTorrentFileIndices: undefined
|
||||
};
|
||||
}
|
||||
// Direct magnets are admission-ready even when their optional metadata
|
||||
// preview was never requested. Migrate drafts created by the old
|
||||
// fallback classification so the Add window cannot imply that the
|
||||
// transfer itself is a fallback download.
|
||||
if (preserved.status === 'fallback'
|
||||
&& input.valid
|
||||
&& input.isTorrent
|
||||
&& isMagnetUrl(input.sourceUrl)) {
|
||||
return {
|
||||
...preserved,
|
||||
downloadUrl: input.sourceUrl,
|
||||
status: 'ready',
|
||||
isTorrent: true,
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: undefined,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
};
|
||||
}
|
||||
return preserved;
|
||||
}
|
||||
|
||||
@@ -334,7 +362,7 @@ export const reconcileDownloadRows = (
|
||||
// the optional probe behind Refresh Metadata so the Add window never
|
||||
// blocks a valid magnet on the bounded native probe timeout.
|
||||
status: input.valid
|
||||
? input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'fallback' : 'loading'
|
||||
? input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading'
|
||||
: 'invalid',
|
||||
generation,
|
||||
requestContextVersion: input.requestContextVersion,
|
||||
@@ -402,19 +430,24 @@ export const updateRowIfCurrent = (
|
||||
);
|
||||
|
||||
export const refreshFailedMetadataRows = (
|
||||
rows: AddDownloadDraftRow[]
|
||||
rows: AddDownloadDraftRow[],
|
||||
selectedOnly = false
|
||||
): AddDownloadDraftRow[] => rows.map(row => {
|
||||
const refreshable = row.status === 'metadata-error'
|
||||
|| (row.status === 'fallback' && row.isTorrent === true && isMagnetUrl(row.sourceUrl));
|
||||
if (!refreshable) return row;
|
||||
if ((selectedOnly && row.selected === false) || !isMetadataRefreshableRow(row)) return row;
|
||||
const generation = row.generation + 1;
|
||||
return {
|
||||
...row,
|
||||
status: 'loading',
|
||||
generation,
|
||||
metadataBlockedReason: undefined,
|
||||
...(row.isTorrent === true && row.status === 'fallback'
|
||||
? { torrentCacheId: `${row.id}-${generation}` }
|
||||
...(row.isTorrent
|
||||
? {
|
||||
torrentPath: undefined,
|
||||
torrentCacheId: `${row.id}-${generation}`,
|
||||
torrentInfoHash: undefined,
|
||||
torrentFiles: undefined,
|
||||
selectedTorrentFileIndices: undefined
|
||||
}
|
||||
: {})
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user