mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
fix(torrent): harden lifecycle and properties state
- admit valid magnets immediately while keeping metadata refresh optional - fence Torrent recovery, diagnostics, and allocation presentation by lifecycle - avoid false peer-wait claims when telemetry is missing or malformed - stabilize Properties metric label/value layout for narrow and RTL windows Tests: - npm test -- --run - npm run build - npm run check:i18n - node --test scripts/*.node-test.js - cargo test --all-targets - npm run smoke:torrent - npm run smoke:torrent:failure-paths - git diff --check
This commit is contained in:
+147
-26
@@ -14708,6 +14708,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_zero_progress_does_not_trigger_connection_recovery() {
|
||||
let start = Instant::now();
|
||||
let mut observation = Aria2ConnectionObservation::default();
|
||||
|
||||
for offset in [0, 31, 62] {
|
||||
assert_eq!(
|
||||
observe_aria2_connections_with_epoch(
|
||||
&mut observation,
|
||||
Aria2ConnectionSample {
|
||||
gid: "torrent-gid",
|
||||
control_epoch: 4,
|
||||
status: "active",
|
||||
total: 2 * 1024 * 1024 * 1024,
|
||||
completed: 0,
|
||||
speed_bytes: 0.0,
|
||||
active_connections: 0,
|
||||
effective_connections: 1,
|
||||
speed_limited: false,
|
||||
is_torrent: true,
|
||||
now: start + Duration::from_secs(offset),
|
||||
},
|
||||
),
|
||||
None,
|
||||
"peer discovery must not recycle a Torrent GID after a zero-byte wait"
|
||||
);
|
||||
}
|
||||
assert_eq!(observation.recovery_attempts, 0);
|
||||
assert!(observation.no_progress_since.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_recovery_uses_a_cooldown_instead_of_a_one_shot_latch() {
|
||||
let start = Instant::now();
|
||||
@@ -14798,6 +14829,7 @@ mod tests {
|
||||
active_connections: 16,
|
||||
effective_connections: 16,
|
||||
speed_limited: false,
|
||||
is_torrent: false,
|
||||
now: start + Duration::from_secs(offset),
|
||||
},
|
||||
);
|
||||
@@ -14814,6 +14846,7 @@ mod tests {
|
||||
active_connections: 16,
|
||||
effective_connections: 16,
|
||||
speed_limited: false,
|
||||
is_torrent: false,
|
||||
now: start + Duration::from_secs(31),
|
||||
},
|
||||
);
|
||||
@@ -14832,6 +14865,7 @@ mod tests {
|
||||
active_connections: 1,
|
||||
effective_connections: 16,
|
||||
speed_limited: false,
|
||||
is_torrent: false,
|
||||
now: start + Duration::from_secs(62),
|
||||
},
|
||||
),
|
||||
@@ -14865,6 +14899,7 @@ mod tests {
|
||||
active_connections: 16,
|
||||
effective_connections: 16,
|
||||
speed_limited: false,
|
||||
is_torrent: false,
|
||||
now,
|
||||
},
|
||||
),
|
||||
@@ -14887,6 +14922,7 @@ mod tests {
|
||||
active_connections: 16,
|
||||
effective_connections: 16,
|
||||
speed_limited: false,
|
||||
is_torrent: false,
|
||||
now: now + Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
@@ -17466,6 +17502,9 @@ struct Aria2ConnectionObservation {
|
||||
last_completed: u64,
|
||||
last_logged_active_connections: Option<i32>,
|
||||
last_connection_logged_at: Option<Instant>,
|
||||
started_at: Option<Instant>,
|
||||
torrent_tracker_count: Option<usize>,
|
||||
torrent_tracker_count_loaded: bool,
|
||||
seeder: bool,
|
||||
verifying: bool,
|
||||
}
|
||||
@@ -17480,6 +17519,7 @@ struct Aria2ConnectionSample<'a> {
|
||||
active_connections: i32,
|
||||
effective_connections: i32,
|
||||
speed_limited: bool,
|
||||
is_torrent: bool,
|
||||
now: Instant,
|
||||
}
|
||||
|
||||
@@ -17542,6 +17582,7 @@ fn observe_aria2_connections(
|
||||
active_connections,
|
||||
effective_connections,
|
||||
speed_limited,
|
||||
is_torrent: false,
|
||||
now,
|
||||
},
|
||||
)
|
||||
@@ -17561,6 +17602,7 @@ fn observe_aria2_connections_with_epoch(
|
||||
active_connections,
|
||||
effective_connections,
|
||||
speed_limited,
|
||||
is_torrent,
|
||||
now,
|
||||
} = sample;
|
||||
if observation.gid != gid || observation.control_epoch != control_epoch {
|
||||
@@ -17578,6 +17620,26 @@ fn observe_aria2_connections_with_epoch(
|
||||
};
|
||||
}
|
||||
|
||||
// BitTorrent peer discovery is intentionally open-ended. A Torrent can
|
||||
// remain active at zero bytes and zero connections while DHT, trackers,
|
||||
// or PEX are still converging. Recreating its GID destroys that discovery
|
||||
// lifecycle and was the source of the observed thirty-second restart
|
||||
// loop. Missing-GID reconciliation remains owned by the poller and is
|
||||
// therefore unaffected by this transfer-kind gate.
|
||||
if is_torrent {
|
||||
observation.started_at.get_or_insert(now);
|
||||
observation.degraded_since = None;
|
||||
observation.no_progress_since = None;
|
||||
observation.last_active_connections = None;
|
||||
observation.connection_decline_steps = 0;
|
||||
observation.healthy_speed_samples = 0;
|
||||
observation.healthy_samples_since_recovery = 0;
|
||||
observation.recovery_attempts = 0;
|
||||
observation.last_refreshed_at = None;
|
||||
observation.last_completed = completed;
|
||||
return None;
|
||||
}
|
||||
|
||||
let remaining = total.saturating_sub(completed);
|
||||
let mut reason = None;
|
||||
if status == "active" && total > completed {
|
||||
@@ -18173,6 +18235,7 @@ pub fn run() {
|
||||
};
|
||||
let torrent_startup_settings =
|
||||
crate::settings::torrent_startup_settings(persisted_settings.as_ref());
|
||||
let torrent_peer_discovery_for_poll = torrent_peer_discovery;
|
||||
|
||||
let aria2_secret_clone = aria2_secret.clone();
|
||||
let app_handle_bg = app.handle().clone();
|
||||
@@ -18591,6 +18654,7 @@ pub fn run() {
|
||||
"numSeeders",
|
||||
"seeder",
|
||||
"connections",
|
||||
"errorCode",
|
||||
"errorMessage",
|
||||
"verifiedLength",
|
||||
"verifyIntegrityPending"
|
||||
@@ -18663,6 +18727,9 @@ pub fn run() {
|
||||
.unwrap_or(requested_connections)
|
||||
.max(1);
|
||||
let speed_limited = poll_mgr.aria2_speed_limited(&id).await;
|
||||
let is_torrent = poll_mgr.aria2_is_torrent(&id).await;
|
||||
let is_verification =
|
||||
poll_mgr.aria2_is_torrent_verification(&id).await;
|
||||
let control_epoch = mapping.epoch;
|
||||
// The status snapshot and both connection
|
||||
// lookups await. A pause,
|
||||
@@ -18677,10 +18744,33 @@ pub fn run() {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let should_load_torrent_tracker_count = is_torrent
|
||||
&& observations
|
||||
.get(&id)
|
||||
.is_none_or(|observation| {
|
||||
!observation.torrent_tracker_count_loaded
|
||||
});
|
||||
let torrent_tracker_count = if should_load_torrent_tracker_count {
|
||||
Some(poll_mgr.aria2_torrent_tracker_count(&id).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if should_load_torrent_tracker_count
|
||||
&& (!poll_mgr.is_current_aria2_gid_mapping(gid, &mapping)
|
||||
|| !poll_mgr
|
||||
.is_aria2_control_epoch_current(&id, control_epoch)
|
||||
.await)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seen_ids.insert(id.clone());
|
||||
seen_gids.insert(gid.to_string());
|
||||
let now = Instant::now();
|
||||
let observation = observations.entry(id.clone()).or_default();
|
||||
if let Some(torrent_tracker_count) = torrent_tracker_count {
|
||||
observation.torrent_tracker_count = torrent_tracker_count;
|
||||
observation.torrent_tracker_count_loaded = true;
|
||||
}
|
||||
let recovery_reason = observe_aria2_connections_with_epoch(
|
||||
observation,
|
||||
Aria2ConnectionSample {
|
||||
@@ -18693,15 +18783,13 @@ pub fn run() {
|
||||
active_connections,
|
||||
effective_connections,
|
||||
speed_limited,
|
||||
is_torrent,
|
||||
now,
|
||||
},
|
||||
);
|
||||
let entering_seeding = is_seeder && !observation.seeder;
|
||||
observation.seeder = is_seeder;
|
||||
|
||||
let is_torrent = poll_mgr.aria2_is_torrent(&id).await;
|
||||
let is_verification =
|
||||
poll_mgr.aria2_is_torrent_verification(&id).await;
|
||||
if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping)
|
||||
|| !poll_mgr
|
||||
.is_aria2_control_epoch_current(&id, control_epoch)
|
||||
@@ -18721,30 +18809,63 @@ pub fn run() {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !is_torrent
|
||||
&& (observation.last_logged_active_connections
|
||||
!= Some(active_connections)
|
||||
|| observation.last_connection_logged_at.is_none_or(
|
||||
|logged_at| {
|
||||
now.duration_since(logged_at)
|
||||
>= ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL
|
||||
},
|
||||
))
|
||||
if observation.last_logged_active_connections
|
||||
!= Some(active_connections)
|
||||
|| observation.last_connection_logged_at.is_none_or(
|
||||
|logged_at| {
|
||||
now.duration_since(logged_at)
|
||||
>= ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL
|
||||
},
|
||||
)
|
||||
{
|
||||
log::info!(
|
||||
"aria2 progress [stage=connections id={} gid={} epoch={} retry_strike={} status={} active_connections={} requested_connections={} effective_connections={} completed_bytes={} total_bytes={} speed_bytes_per_second={}]",
|
||||
id,
|
||||
gid,
|
||||
control_epoch,
|
||||
retry_strike,
|
||||
status,
|
||||
active_connections,
|
||||
requested_connections,
|
||||
effective_connections,
|
||||
completed,
|
||||
total,
|
||||
speed_bytes
|
||||
);
|
||||
if is_torrent {
|
||||
let error_code = status_info
|
||||
.get("errorCode")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("none");
|
||||
let elapsed_ms = observation
|
||||
.started_at
|
||||
.map(|started_at| now.duration_since(started_at).as_millis())
|
||||
.unwrap_or_default();
|
||||
let seeders = num_seeders
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
log::info!(
|
||||
"aria2 Torrent diagnostics [id={} gid={} epoch={} status={} error_code={} active_connections={} seeders={} elapsed_ms={} tracker_count={} dht={} dht6={} pex={} lpd={}]",
|
||||
id,
|
||||
gid,
|
||||
control_epoch,
|
||||
status,
|
||||
error_code,
|
||||
active_connections,
|
||||
seeders,
|
||||
elapsed_ms,
|
||||
observation
|
||||
.torrent_tracker_count
|
||||
.map(|count| count.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
torrent_peer_discovery_for_poll.0,
|
||||
torrent_peer_discovery_for_poll.1,
|
||||
torrent_peer_discovery_for_poll.2,
|
||||
torrent_peer_discovery_for_poll.3,
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"aria2 progress [stage=connections id={} gid={} epoch={} retry_strike={} status={} active_connections={} requested_connections={} effective_connections={} completed_bytes={} total_bytes={} speed_bytes_per_second={}]",
|
||||
id,
|
||||
gid,
|
||||
control_epoch,
|
||||
retry_strike,
|
||||
status,
|
||||
active_connections,
|
||||
requested_connections,
|
||||
effective_connections,
|
||||
completed,
|
||||
total,
|
||||
speed_bytes
|
||||
);
|
||||
}
|
||||
observation.last_logged_active_connections =
|
||||
Some(active_connections);
|
||||
observation.last_connection_logged_at = Some(now);
|
||||
|
||||
+48
-7
@@ -2410,6 +2410,51 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.is_some_and(|payload| payload.is_torrent)
|
||||
}
|
||||
|
||||
/// Return only the bounded number of tracker endpoints associated with a
|
||||
/// live Torrent. The endpoint values and managed metadata path never leave
|
||||
/// this method; callers use the count for redacted diagnostics only.
|
||||
pub async fn aria2_torrent_tracker_count(&self, id: &str) -> Option<usize> {
|
||||
let payload = self.aria2_payloads.lock().await.get(id).cloned()?;
|
||||
if !payload.is_torrent {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut count = payload
|
||||
.torrent_trackers
|
||||
.as_deref()
|
||||
.map(|trackers| {
|
||||
trackers
|
||||
.split(',')
|
||||
.filter(|tracker| !tracker.trim().is_empty())
|
||||
.take(256)
|
||||
.count()
|
||||
});
|
||||
|
||||
if payload
|
||||
.url
|
||||
.trim_start()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("magnet:")
|
||||
{
|
||||
let magnet_count = crate::torrent::magnet_tracker_count(&payload.url).ok()?;
|
||||
count = Some(count.unwrap_or_default().saturating_add(magnet_count));
|
||||
}
|
||||
|
||||
if let Some(torrent_path) = payload.torrent_path.as_deref() {
|
||||
let path = crate::torrent::validate_managed_torrent_path(
|
||||
&self.app_handle,
|
||||
id,
|
||||
torrent_path,
|
||||
)
|
||||
.ok()?;
|
||||
let bytes = crate::torrent::read_bounded_torrent_bytes(&path).await.ok()?;
|
||||
let metadata = crate::torrent::torrent_details_from_bytes(&bytes).ok()?;
|
||||
count = Some(count.unwrap_or_default().saturating_add(metadata.trackers.len()));
|
||||
}
|
||||
|
||||
count.map(|value| value.min(256))
|
||||
}
|
||||
|
||||
pub async fn aria2_is_torrent_verification(&self, id: &str) -> bool {
|
||||
self.aria2_payloads
|
||||
.lock()
|
||||
@@ -4046,14 +4091,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
}
|
||||
|
||||
pub fn aria2_allocation_phase_eligible(payload: &SpawnPayload) -> bool {
|
||||
if payload.is_media || payload.torrent_verify_only {
|
||||
if payload.is_media || payload.is_torrent || payload.torrent_verify_only {
|
||||
return false;
|
||||
}
|
||||
if !payload.is_torrent {
|
||||
return true;
|
||||
}
|
||||
normalize_torrent_file_allocation(payload.torrent_file_allocation.as_deref())
|
||||
.is_ok_and(|allocation| allocation != "none")
|
||||
true
|
||||
}
|
||||
|
||||
async fn begin_aria2_allocation(
|
||||
@@ -8798,7 +8839,7 @@ mod tests {
|
||||
..SpawnPayload::default()
|
||||
}
|
||||
));
|
||||
assert!(QueueManager::<tauri::Wry>::aria2_allocation_phase_eligible(
|
||||
assert!(!QueueManager::<tauri::Wry>::aria2_allocation_phase_eligible(
|
||||
&SpawnPayload {
|
||||
is_torrent: true,
|
||||
torrent_file_allocation: Some("prealloc".to_string()),
|
||||
|
||||
@@ -697,6 +697,14 @@ fn normalized_magnet_trackers(parsed: &url::Url) -> Result<Vec<String>, String>
|
||||
Ok(trackers)
|
||||
}
|
||||
|
||||
/// Return the bounded tracker count from a Magnet without exposing the
|
||||
/// tracker values or the source URI to diagnostics.
|
||||
pub fn magnet_tracker_count(source: &str) -> Result<usize, String> {
|
||||
let parsed = url::Url::parse(source.trim()).map_err(|_| "invalid magnet URI".to_string())?;
|
||||
validate_magnet_authority(&parsed)?;
|
||||
Ok(normalized_magnet_trackers(&parsed)?.len())
|
||||
}
|
||||
|
||||
/// Return the Magnet URI form that Firelink may hand to Aria2. Direct source
|
||||
/// parameters can make Aria2 fetch arbitrary HTTP/FTP/SFTP resources during
|
||||
/// metadata resolution, so keep the peer/tracker identity parameters but
|
||||
@@ -1394,6 +1402,7 @@ mod tests {
|
||||
let valid = "magnet:?xt=urn:btih:0123456789012345678901234567890123456789&tr=https%3A%2F%2Ftracker.example%2Fannounce";
|
||||
assert!(magnet_allows_cached_metadata(valid));
|
||||
assert!(sanitize_magnet_uri_for_aria2(valid).is_ok());
|
||||
assert_eq!(magnet_tracker_count(valid).unwrap(), 1);
|
||||
|
||||
for suffix in [
|
||||
"&tr=ftp%3A%2F%2Ftracker.example%2Fannounce",
|
||||
|
||||
@@ -108,6 +108,13 @@ fn headless_queue_lifecycle_eligibility_and_retry_contracts_hold() {
|
||||
..SpawnPayload::default()
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!QueueManager::<tauri::Wry>::aria2_allocation_phase_eligible(&SpawnPayload {
|
||||
is_torrent: true,
|
||||
torrent_file_allocation: Some("prealloc".to_string()),
|
||||
..SpawnPayload::default()
|
||||
})
|
||||
);
|
||||
assert_eq!(backoff_for(0), Duration::from_secs(2));
|
||||
assert_eq!(backoff_for(usize::MAX), Duration::from_secs(10));
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user