chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:04:26 +08:00
commit 7b3816441c
320 changed files with 76813 additions and 0 deletions
@@ -0,0 +1,198 @@
use super::{
BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate,
BtPieceBlockUpdate, BtPieceMutationResult, BtRuntimeTickResult, BtTrackerInfo, CoreError,
DownloadEngine, DownloadId, Result,
};
impl DownloadEngine {
/// Replaces the full BT peer snapshot for a download.
pub fn apply_bt_peer_snapshot(
&mut self,
gid: DownloadId,
peers: Vec<BtPeerInfo>,
) -> Result<()> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
let _ = group.replace_bt_peer_snapshot(peers);
Ok(())
}
/// Applies an incremental BT peer update to a download.
pub fn apply_bt_peer_update(
&mut self,
gid: DownloadId,
peer: BtPeerInfo,
) -> Result<BtPeerMutationResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_peer_update(peer))
}
/// Applies an incremental BT piece availability update to a download.
pub fn apply_bt_piece_availability_update(
&mut self,
gid: DownloadId,
update: BtPieceAvailabilityUpdate,
) -> Result<BtPieceAvailabilityMutationResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_piece_availability_update(update))
}
/// Applies an incremental BT block-completion update to a download.
pub fn apply_bt_piece_block_update(
&mut self,
gid: DownloadId,
update: BtPieceBlockUpdate,
) -> Result<BtPieceMutationResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_piece_block_update(update))
}
#[expect(
clippy::too_many_arguments,
reason = "BT runtime tick mirrors the RPC-visible counters updated together by one event"
)]
/// Applies a full BT runtime tick, including byte deltas, speeds, timers, and connection count.
pub fn apply_bt_runtime_tick(
&mut self,
gid: DownloadId,
downloaded_delta: u64,
uploaded_delta: u64,
download_speed: u64,
upload_speed: u64,
share_time_delta_secs: u64,
seeding_time_delta_secs: u64,
seeding: bool,
num_connections: Option<u32>,
) -> Result<BtRuntimeTickResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.apply_bt_runtime_tick(
downloaded_delta,
uploaded_delta,
download_speed,
upload_speed,
share_time_delta_secs,
seeding_time_delta_secs,
seeding,
num_connections,
))
}
/// Advances BT share/seeding timers to `now_unix_secs`.
pub fn tick_bt_runtime_clock(
&mut self,
gid: DownloadId,
now_unix_secs: u64,
seeding: bool,
) -> Result<BtRuntimeTickResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.tick_bt_runtime_clock(now_unix_secs, seeding))
}
/// Toggles BT seeding state while preserving share-runtime accounting.
pub fn set_bt_seeding_state(
&mut self,
gid: DownloadId,
seeding: bool,
at_unix_secs: Option<u64>,
) -> Result<BtRuntimeTickResult> {
let group = self.group_mut(gid)?;
if group.bt().is_none() {
return Err(CoreError::InvalidState(
"bt runtime state is not initialized",
));
}
Ok(group.set_bt_seeding_state(seeding, at_unix_secs))
}
/// Upserts a BT tracker runtime snapshot keyed by tracker URL.
pub fn apply_bt_tracker_snapshot(
&mut self,
gid: DownloadId,
tracker_url: &str,
tracker_id: Option<String>,
seeders: Option<u32>,
leechers: Option<u32>,
) -> Result<()> {
let group = self.group_mut(gid)?;
let bt = group.bt_mut().ok_or(CoreError::InvalidState(
"bt runtime state is not initialized",
))?;
if let Some(tracker) = bt
.trackers
.iter_mut()
.find(|tracker| tracker.url == tracker_url)
{
if let Some(tracker_id) = tracker_id {
tracker.id = Some(tracker_id);
}
if seeders.is_some() {
tracker.seeders = seeders;
}
if leechers.is_some() {
tracker.leechers = leechers;
}
} else {
bt.trackers.push(BtTrackerInfo {
url: tracker_url.to_owned(),
tier: None,
id: tracker_id,
seeders,
leechers,
});
}
Ok(())
}
/// Records BT byte deltas and timer deltas without changing live speed counters.
pub fn record_bt_runtime_tick(
&mut self,
gid: DownloadId,
downloaded_delta: u64,
uploaded_delta: u64,
share_time_delta_secs: u64,
seeding_time_delta_secs: u64,
seeding: bool,
) -> Result<()> {
let _ = self.apply_bt_runtime_tick(
gid,
downloaded_delta,
uploaded_delta,
0,
0,
share_time_delta_secs,
seeding_time_delta_secs,
seeding,
None,
)?;
Ok(())
}
}
@@ -0,0 +1,369 @@
use super::{
BtPressureSnapshot, BtRuntimeState, CoreError, DownloadEngine, DownloadId, DownloadRegistry,
DownloadStatus, GlobalStat, ProgressSnapshot, RequestGroup, Result, RuntimeConfig,
SchedulerActivityCounters, SchedulerPlanningObservation, SchedulerState, SegmentRuntimeStats,
usize_to_u32, usize_to_u64,
};
/// Runtime metrics for a single download at a specific sampling point.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DownloadRuntimeSnapshot {
/// Download identifier.
pub gid: DownloadId,
/// Current download state.
pub status: DownloadStatus,
/// Total payload length tracked by this snapshot.
pub total_length: u64,
/// Persisted completed length for the download.
pub completed_length: u64,
/// Remaining bytes derived from the tracked total and completed lengths.
pub remaining_length: u64,
/// Effective download throughput after engine-side capping.
pub download_speed: u64,
/// Effective upload throughput after engine-side capping.
pub upload_speed: u64,
/// Effective per-download download cap after global and local policy are merged.
pub effective_download_limit: Option<u64>,
/// Effective per-download upload cap after global and local policy are merged.
pub effective_upload_limit: Option<u64>,
/// Retry counter recorded on the request group.
pub retry_count: u32,
/// Number of active connections the request currently reports.
pub num_connections: u32,
/// Segment planner metrics exported from the request group.
pub segment_stats: SegmentRuntimeStats,
/// `BitTorrent` pressure metrics when the request has BT runtime state.
pub bt_pressure: Option<BtPressureSnapshot>,
}
/// Cross-download runtime counters used by diagnostics and pressure tests.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RuntimeInstrumentationSnapshot {
/// Number of registered downloads.
pub download_count: usize,
/// Number of active downloads.
pub active_download_count: usize,
/// Number of waiting downloads.
pub waiting_download_count: usize,
/// Number of paused or removed downloads still retained in memory.
pub stopped_download_count: usize,
/// Number of errored downloads.
pub error_download_count: usize,
/// Number of completed downloads.
pub complete_download_count: usize,
/// Total count of active or retrying segments across all groups.
pub total_active_segments: usize,
/// Total bytes planned across all runtime segment assignments.
pub total_planned_bytes: u64,
/// Total remaining bytes across all runtime segment assignments.
pub total_remaining_segment_bytes: u64,
/// Aggregate number of requestable BT pieces.
pub total_requestable_pieces: usize,
/// Aggregate number of scarce requestable BT pieces.
pub total_scarce_requestable_pieces: usize,
/// Aggregate number of observed BT peers.
pub total_bt_peers: usize,
/// Configured disk cache capacity in bytes.
pub configured_disk_cache_bytes: u64,
/// Global overall download limit configured in the runtime.
pub max_overall_download_limit: Option<u64>,
/// Global per-download download limit configured in the runtime.
pub max_download_limit: Option<u64>,
/// Global overall upload limit configured in the runtime.
pub max_overall_upload_limit: Option<u64>,
/// Global per-download upload limit configured in the runtime.
pub max_upload_limit: Option<u64>,
/// Current scheduler state snapshot.
pub scheduler_state: SchedulerState,
/// Scheduler activity counters accumulated so far.
pub scheduler_counters: SchedulerActivityCounters,
/// Last recorded planning observation, when available.
pub last_scheduler_plan: Option<SchedulerPlanningObservation>,
}
impl DownloadEngine {
/// Returns the current status of a specific download.
pub fn tell_status(&self, gid: DownloadId) -> Result<DownloadStatus> {
self.registry
.get(gid)
.map(|group| *group.status())
.ok_or(CoreError::UnknownDownloadId(gid))
}
/// Aggregates global counters and capped runtime speeds across all downloads.
#[must_use]
pub fn get_global_stat(&self) -> GlobalStat {
let mut stat = self.global_stat;
let active_count = active_runtime_group_count(&self.registry);
stat.num_active = 0;
stat.num_waiting = 0;
stat.num_stopped = 0;
stat.num_error = 0;
stat.num_complete = 0;
stat.total_length = 0;
stat.completed_length = 0;
stat.download_speed = 0;
stat.upload_speed = 0;
for group in self.registry.groups.values() {
let snapshot = build_progress_snapshot(group, self.runtime(), active_count);
match group.status() {
DownloadStatus::Active => stat.num_active += 1,
DownloadStatus::Waiting => stat.num_waiting += 1,
DownloadStatus::Paused | DownloadStatus::Removed => stat.num_stopped += 1,
DownloadStatus::Error => stat.num_error += 1,
DownloadStatus::Complete => stat.num_complete += 1,
}
stat.total_length = stat.total_length.saturating_add(snapshot.total_length);
stat.completed_length = stat
.completed_length
.saturating_add(snapshot.completed_length);
stat.download_speed = stat.download_speed.saturating_add(snapshot.download_speed);
stat.upload_speed = stat.upload_speed.saturating_add(snapshot.upload_speed);
}
if let Some(limit) = self.runtime().max_overall_download_limit {
stat.download_speed = stat.download_speed.min(limit);
}
if let Some(limit) = self.runtime().max_overall_upload_limit {
stat.upload_speed = stat.upload_speed.min(limit);
}
stat
}
/// Returns the number of registered downloads.
#[must_use]
pub fn download_count(&self) -> usize {
self.registry.len()
}
/// Returns the number of tasks exposed by the engine, matching `download_count`.
#[must_use]
pub fn task_count(&self) -> usize {
self.download_count()
}
/// Returns the gids of currently active downloads.
#[must_use]
pub fn active_downloads(&self) -> Vec<DownloadId> {
self.registry
.groups
.iter()
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
.collect()
}
}
/// Builds a progress snapshot using runtime caps, piece state, and BT-derived metrics.
pub(super) fn build_progress_snapshot(
group: &RequestGroup,
runtime: &RuntimeConfig,
active_count: usize,
) -> ProgressSnapshot {
let piece_length = effective_piece_length(group);
let total_length = infer_total_length(group, piece_length);
let completed_length = completed_length(group, piece_length, total_length);
let bt_selected_payload_length = group.bt_effective_target_length().unwrap_or(0);
let bt_remaining_payload_length = group.bt_remaining_work_length().unwrap_or(0);
let bt_true_seeding = group.bt_is_true_seeding();
let peer_metrics = group.bt_peer_runtime_stats();
let (_, queued, downloading, verified, missing, _) = group.piece_state_counts();
let (download_cap, upload_cap) = effective_speed_caps(group, runtime, active_count);
let download_speed = clamp_speed(
group
.download_speed()
.max(peer_metrics.total_download_speed),
download_cap,
);
let eta_seconds = if download_speed > 0 && total_length > completed_length {
Some((total_length - completed_length).div_ceil(download_speed))
} else {
None
};
ProgressSnapshot {
gid: group.gid(),
status: *group.status(),
total_length,
completed_length,
upload_length: group.upload_length(),
upload_speed: clamp_speed(
group.upload_speed().max(peer_metrics.total_upload_speed),
upload_cap,
),
download_speed,
num_connections: group
.num_connections()
.max(usize_to_u32(peer_metrics.peer_count)),
eta_seconds,
seeding: bt_true_seeding,
share_ratio_milli: compute_share_ratio_milli(group, completed_length),
share_time_secs: group.bt_share_time_secs(),
seeding_time_secs: group.bt_seeding_time_secs(),
bt_selected_payload_length,
bt_remaining_payload_length,
bt_true_seeding,
bt_total_peers: usize_to_u32(peer_metrics.peer_count),
bt_seeders: usize_to_u32(peer_metrics.seeder_count),
bt_leechers: usize_to_u32(peer_metrics.leecher_count),
bt_available_pieces: usize_to_u32(group.bt_available_piece_count()),
bt_verified_pieces: usize_to_u32(verified),
bt_downloading_pieces: usize_to_u32(downloading),
bt_queued_pieces: usize_to_u32(queued),
bt_missing_pieces: usize_to_u32(missing),
}
}
/// Counts active runtime groups, returning at least `1` for cap sharing math.
pub(super) fn active_runtime_group_count(registry: &DownloadRegistry) -> usize {
registry
.groups
.values()
.filter(|group| {
matches!(
group.status(),
DownloadStatus::Active | DownloadStatus::Waiting
)
})
.count()
.max(1)
}
/// Computes effective download and upload caps by combining global and per-group settings.
pub(super) fn effective_speed_caps(
group: &RequestGroup,
runtime: &RuntimeConfig,
active_count: usize,
) -> (Option<u64>, Option<u64>) {
let active_count = usize_to_u64(active_count.max(1));
let overall_download_share = runtime
.max_overall_download_limit
.and_then(|limit| limit.checked_div(active_count))
.map(|limit| limit.max(1));
let overall_upload_share = runtime
.max_overall_upload_limit
.and_then(|limit| limit.checked_div(active_count))
.map(|limit| limit.max(1));
let download_cap = combine_caps(
overall_download_share,
group
.option_limit("max-download-limit")
.or(runtime.max_download_limit),
);
let upload_cap = combine_caps(
overall_upload_share,
group
.option_limit("max-upload-limit")
.or(runtime.max_upload_limit),
);
(download_cap, upload_cap)
}
/// Intersects two optional bandwidth caps.
fn combine_caps(left: Option<u64>, right: Option<u64>) -> Option<u64> {
match (left, right) {
(Some(left), Some(right)) => Some(left.min(right)),
(Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
}
}
/// Applies an optional cap to a runtime speed sample.
pub(super) fn clamp_speed(value: u64, cap: Option<u64>) -> u64 {
cap.map_or(value, |limit| value.min(limit))
}
/// Returns the piece length used for segment and progress math, clamped to at least `1`.
pub(super) fn effective_piece_length(group: &RequestGroup) -> u64 {
group.piece_length().max(1)
}
/// Infers a request's total length from explicit metadata or known piece state.
pub(super) fn infer_total_length(group: &RequestGroup, piece_length: u64) -> u64 {
if group.total_length() > 0 {
return group.total_length();
}
group
.piece_map()
.iter()
.map(|(piece, _)| {
u64::from(piece.0)
.saturating_add(1)
.saturating_mul(piece_length)
})
.max()
.unwrap_or(0)
}
/// Computes the most trustworthy completed length for progress reporting.
fn completed_length(group: &RequestGroup, piece_length: u64, total_length: u64) -> u64 {
let reported = if total_length > 0 {
group.completed_length().min(total_length)
} else {
group.completed_length()
};
let from_verified = group
.piece_map()
.iter()
.filter(|(_, state)| **state == crate::piece::PieceState::Verified)
.map(|(piece, _)| {
let start = u64::from(piece.0).saturating_mul(piece_length);
if total_length == 0 {
piece_length
} else {
total_length.saturating_sub(start).min(piece_length)
}
})
.sum::<u64>();
let merged = reported.max(from_verified);
match group.status() {
DownloadStatus::Complete if total_length > 0 && completion_is_trustworthy(group) => {
total_length
}
_ => merged.min(total_length.max(merged)),
}
}
/// Returns whether a completed group can safely report its entire total length as finished.
fn completion_is_trustworthy(group: &RequestGroup) -> bool {
if let Some(bt) = group.bt() {
if bt.metadata_only {
return false;
}
let selected_total = bt_selected_total_length(bt);
if selected_total > 0 && group.completed_length() < selected_total {
return false;
}
}
true
}
/// Sums the selected BT payload length across all torrent files.
fn bt_selected_total_length(bt: &BtRuntimeState) -> u64 {
bt.files
.iter()
.filter(|file| file.selected)
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
}
/// Computes the BT share ratio in milli-units from live upload and effective payload size.
fn compute_share_ratio_milli(group: &RequestGroup, completed_length: u64) -> Option<u64> {
if let Some(ratio) = group.bt_share_ratio_milli() {
return Some(ratio);
}
let bt = group.bt()?;
let denominator = group
.bt_share_ratio_base_length()
.unwrap_or_else(|| bt_selected_total_length(bt).max(completed_length));
if denominator == 0 {
return None;
}
Some(
group
.upload_length()
.saturating_mul(1000)
.saturating_div(denominator),
)
}
@@ -0,0 +1,204 @@
use super::{
CoreError, DownloadEngine, DownloadId, DownloadStatus, QueuePositionMode, Result, RuntimeEvent,
RuntimeEventKind, usize_to_i64,
};
use std::cmp::Ordering;
impl DownloadEngine {
/// Pauses an active or waiting download and keeps it in the reserved queue.
pub fn pause(&mut self, gid: DownloadId) -> Result<()> {
let status = self
.registry
.get(gid)
.map(|group| *group.status())
.ok_or(CoreError::UnknownDownloadId(gid))?;
let was_active = matches!(status, DownloadStatus::Active);
if !matches!(status, DownloadStatus::Active | DownloadStatus::Waiting) {
return Err(CoreError::InvalidState("download cannot be paused now"));
}
let group = self.group_mut(gid)?;
group.set_status(DownloadStatus::Paused);
if was_active {
self.enqueue_reserved_front(gid);
} else if !self.is_in_reserved_queue(gid) {
self.enqueue_reserved_back(gid);
}
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadPaused).with_gid(gid));
Ok(())
}
/// Resumes a paused download by moving it back into the waiting state.
pub fn resume(&mut self, gid: DownloadId) -> Result<()> {
let status = self
.registry
.get(gid)
.map(|group| *group.status())
.ok_or(CoreError::UnknownDownloadId(gid))?;
if !matches!(status, DownloadStatus::Paused) {
return Err(CoreError::InvalidState("download cannot be unpaused now"));
}
let group = self.group_mut(gid)?;
group.set_status(DownloadStatus::Waiting);
if !self.is_in_reserved_queue(gid) {
self.enqueue_reserved_back(gid);
}
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadResumed).with_gid(gid));
Ok(())
}
/// Marks a download as removed and assigns it a stopped sequence.
pub fn remove(&mut self, gid: DownloadId) -> Result<()> {
self.remove_from_reserved_queue(gid);
self.transition_to_stopped_status(gid, DownloadStatus::Removed)?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadRemoved).with_gid(gid));
Ok(())
}
/// Permanently removes a stopped download result from the registry.
pub fn remove_download_result(&mut self, gid: DownloadId) -> Result<()> {
let Some(group) = self.registry.get(gid) else {
return Err(CoreError::UnknownDownloadId(gid));
};
if !matches!(
group.status(),
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
) {
return Err(CoreError::InvalidState(
"download result is not available for active or waiting downloads",
));
}
self.registry.remove(gid);
Ok(())
}
/// Repositions a waiting download within the reserved queue.
pub fn change_position(
&mut self,
gid: DownloadId,
offset: i64,
mode: QueuePositionMode,
) -> Result<usize> {
let Some(current_index) = self
.reserved_queue
.iter()
.position(|candidate| *candidate == gid)
else {
return Err(CoreError::InvalidState(
"download is not in the waiting queue",
));
};
let size = usize_to_i64(self.reserved_queue.len());
let current = usize_to_i64(current_index);
let mut dest = match mode {
QueuePositionMode::Set => offset,
QueuePositionMode::Cur => current.saturating_add(offset),
QueuePositionMode::End => size.saturating_sub(1).saturating_add(offset),
};
dest = dest.clamp(0, size.saturating_sub(1));
let dest_index = usize::try_from(dest).unwrap_or_default();
match current_index.cmp(&dest_index) {
Ordering::Less => {
let Some(window) = self.reserved_queue.get_mut(current_index..=dest_index) else {
return Err(CoreError::InvalidState(
"download is not in the waiting queue",
));
};
window.rotate_left(1);
}
Ordering::Greater => {
let Some(window) = self.reserved_queue.get_mut(dest_index..=current_index) else {
return Err(CoreError::InvalidState(
"download is not in the waiting queue",
));
};
window.rotate_right(1);
}
Ordering::Equal => {}
}
Ok(dest_index)
}
/// Removes every stopped download result and returns the number removed.
pub fn purge_download_results(&mut self) -> usize {
let stopped = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| {
matches!(
group.status(),
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
)
.then_some(*gid)
})
.collect::<Vec<_>>();
let removed = stopped.len();
for gid in stopped {
let _ = self.registry.remove(gid);
}
removed
}
/// Marks a download as complete and emits the completion event.
pub fn complete(&mut self, gid: DownloadId) -> Result<()> {
self.remove_from_reserved_queue(gid);
self.transition_to_stopped_status(gid, DownloadStatus::Complete)?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(gid));
Ok(())
}
/// Marks a download as errored and emits the failure event.
pub fn fail(&mut self, gid: DownloadId) -> Result<()> {
self.remove_from_reserved_queue(gid);
self.transition_to_stopped_status(gid, DownloadStatus::Error)?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadErrored).with_gid(gid));
Ok(())
}
/// Assigns a stopped sequence and terminal status to a request group.
fn transition_to_stopped_status(
&mut self,
gid: DownloadId,
status: DownloadStatus,
) -> Result<()> {
let sequence = self.next_stopped_sequence;
self.next_stopped_sequence = self.next_stopped_sequence.saturating_add(1);
let group = self.group_mut(gid)?;
group.set_stopped_sequence(Some(sequence));
group.set_status(status);
Ok(())
}
/// Returns whether `gid` currently appears in the reserved queue.
pub(super) fn is_in_reserved_queue(&self, gid: DownloadId) -> bool {
self.reserved_queue.contains(&gid)
}
/// Removes `gid` from the reserved queue when present.
pub(super) fn remove_from_reserved_queue(&mut self, gid: DownloadId) {
if let Some(index) = self
.reserved_queue
.iter()
.position(|candidate| *candidate == gid)
{
self.reserved_queue.remove(index);
}
}
/// Places `gid` at the back of the reserved queue, removing older duplicates first.
pub(super) fn enqueue_reserved_back(&mut self, gid: DownloadId) {
self.remove_from_reserved_queue(gid);
self.reserved_queue.push(gid);
}
/// Places `gid` at the front of the reserved queue, removing older duplicates first.
pub(super) fn enqueue_reserved_front(&mut self, gid: DownloadId) {
self.remove_from_reserved_queue(gid);
self.reserved_queue.insert(0, gid);
}
}
@@ -0,0 +1,167 @@
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
CoreError, DownloadEngine, DownloadId, DownloadStatus, RequestGroup, Result, RetryAttempt,
RetryHistoryEntry, RuntimeConfig, RuntimeEvent, RuntimeEventKind, ScheduleDecision,
SegmentAssignment, SegmentState, effective_piece_length, infer_total_length, usize_to_u64,
};
impl DownloadEngine {
/// Advances the scheduler clock and emits a scheduler tick event.
pub fn scheduler_tick(&mut self) -> Result<()> {
let _ = self.scheduler.tick();
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick));
Ok(())
}
/// Prepares a single HTTP download by applying the scheduler decision for its current state.
pub fn prepare_http_download(&mut self, gid: DownloadId) -> Result<RequestGroup> {
let decision = {
let group = self
.registry
.get(gid)
.ok_or(CoreError::UnknownDownloadId(gid))?;
match group.status() {
DownloadStatus::Waiting => ScheduleDecision::Queue(gid),
DownloadStatus::Active => ScheduleDecision::RunNow(gid),
DownloadStatus::Error => ScheduleDecision::RetryLater(gid),
DownloadStatus::Paused | DownloadStatus::Complete | DownloadStatus::Removed => {
ScheduleDecision::Noop
}
}
};
self.apply_schedule_decision(gid, &decision);
self.registry
.get(gid)
.cloned()
.ok_or(CoreError::UnknownDownloadId(gid))
}
/// Runs one scheduler pass and returns the first actionable decision.
#[must_use]
pub fn schedule_once(&mut self) -> ScheduleDecision {
self.scheduler.record_schedule_run();
let _ = self.scheduler.tick();
let mut gids = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
.collect::<Vec<_>>();
gids.sort_by_key(|gid| gid.as_u64());
gids.extend(
self.reserved_queue
.iter()
.copied()
.filter(|gid| self.registry.get(*gid).is_some()),
);
let mut retry_gids = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Error).then_some(*gid))
.collect::<Vec<_>>();
retry_gids.sort_by_key(|gid| gid.as_u64());
gids.extend(retry_gids);
for gid in gids {
let Some(group) = self.registry.get(gid) else {
continue;
};
let decision = self.scheduler.decide(group);
match decision {
ScheduleDecision::RunNow(_)
| ScheduleDecision::Queue(_)
| ScheduleDecision::RetryLater(_) => {
self.apply_schedule_decision(gid, &decision);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick).with_gid(gid));
return decision;
}
ScheduleDecision::Pause(_) | ScheduleDecision::Remove(_) => {
self.scheduler.record_decision(&decision);
return decision;
}
ScheduleDecision::Noop => {}
}
}
self.scheduler.record_decision(&ScheduleDecision::Noop);
ScheduleDecision::Noop
}
}
/// Converts retry attempts into scheduler-facing retry-history entries.
pub(super) fn retry_history_from_group(attempts: &[RetryAttempt]) -> Vec<RetryHistoryEntry> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs());
attempts
.iter()
.map(|attempt| RetryHistoryEntry {
at_unix_secs: now,
reason: attempt.error.clone().unwrap_or_else(|| "retry".to_string()),
})
.collect()
}
/// Builds runtime segment assignments for the scheduler-selected active segment count.
pub(super) fn build_segment_assignments(
group: &RequestGroup,
runtime: &RuntimeConfig,
desired_segments: usize,
) -> Vec<SegmentAssignment> {
if desired_segments == 0 {
return Vec::new();
}
let total_length = infer_total_length(group, effective_piece_length(group));
let start_offset = group
.resume_state()
.map_or(0, |state| state.resume_offset)
.max(group.completed_length())
.min(total_length);
let remaining = total_length.saturating_sub(start_offset);
if remaining == 0 {
return Vec::new();
}
let piece_length = effective_piece_length(group);
let alignment = piece_length.max(runtime.min_split_size.max(1));
let segment_count = desired_segments
.min(
usize::try_from(remaining.div_ceil(runtime.min_split_size.max(1)))
.unwrap_or(usize::MAX),
)
.max(1);
let target_span = remaining.div_ceil(usize_to_u64(segment_count));
let mut cursor = start_offset;
let mut assignments = Vec::with_capacity(segment_count);
for slot in 0..segment_count {
if cursor >= total_length {
break;
}
let end = if slot + 1 == segment_count {
total_length
} else {
let raw_end = cursor.saturating_add(target_span).min(total_length);
let aligned_end = raw_end
.div_ceil(alignment)
.saturating_mul(alignment)
.min(total_length);
aligned_end.max(cursor.saturating_add(1))
};
let mut assignment =
SegmentAssignment::new(slot, crate::piece::PieceRange::new(cursor, end));
assignment.state = SegmentState::Active;
assignments.push(assignment);
cursor = end;
}
assignments
}
@@ -0,0 +1,621 @@
use super::{
BtFileInfo, BtRuntimeState, ControlFileVersion, ControlMetadata, DownloadEngine, DownloadId,
DownloadRegistry, DownloadStatus, OptionKey, OptionValue, Path, PathBuf, RequestContext,
RequestGroup, SessionFile, SessionFileEntry, StoredDownloadFile, StoredPieceIndex,
StoredPieceState, infer_total_length, usize_to_u32,
};
use std::collections::BTreeMap;
use crate::{ResumeState, RetryAttempt};
impl DownloadEngine {
/// Builds a serializable session file from every persistable group in the registry.
pub(super) fn build_session_file(&self, session_path: &Path) -> SessionFile {
let entries = self
.registry
.groups
.values()
.filter(|group| should_persist_group(*group.status()))
.map(|group| build_session_entry(group, self.session.global_options(), session_path))
.collect();
SessionFile { entries }
}
/// Reconstructs the in-memory registry and waiting queue from a persisted session file.
pub(super) fn rebuild_registry_from_session_file(
&mut self,
session_path: &Path,
session_file: SessionFile,
) {
let mut registry = DownloadRegistry::new();
let mut reserved_queue = Vec::new();
let mut max_gid = 0_u64;
for entry in session_file.entries {
let mut context = RequestContext::new(entry.uri.clone());
if entry.uris.is_empty() {
context.replace_uris(vec![entry.uri.clone()]);
} else {
context.replace_uris(entry.uris.clone());
}
let mut group = if let Some(gid) = DownloadId::parse_hex(&entry.gid) {
max_gid = max_gid.max(gid.as_u64());
RequestGroup::with_context(gid, context)
} else {
let gid = registry.allocate_gid();
max_gid = max_gid.max(gid.as_u64());
RequestGroup::with_context(gid, context)
};
restore_group_metadata(&mut group, entry.metadata);
let control_path = entry
.metadata_path
.unwrap_or_else(|| control_path_for_session_entry(session_path, group.gid()));
if let Ok(control) = aria2_rust_pro_storage::read_aria2_control_file(&control_path) {
restore_control_metadata(&mut group, control);
}
if matches!(
group.status(),
DownloadStatus::Waiting | DownloadStatus::Paused
) {
reserved_queue.push(group.gid());
}
registry.insert(group);
}
registry.next_gid = max_gid.saturating_add(1).max(1);
self.registry = registry;
self.reserved_queue = reserved_queue;
}
}
/// Returns whether a group status should be persisted into session artifacts.
pub(super) fn should_persist_group(status: DownloadStatus) -> bool {
!matches!(status, DownloadStatus::Complete | DownloadStatus::Removed)
}
/// Builds one persisted session entry from a request group.
fn build_session_entry(
group: &RequestGroup,
global_options: &crate::session::GlobalOptions,
session_path: &Path,
) -> SessionFileEntry {
let target_path = resolve_target_path(group, global_options);
let metadata = Some(build_group_metadata(group));
SessionFileEntry {
gid: group.gid().to_string(),
uri: group.uri().to_owned(),
uris: group.uris().to_vec(),
target_path,
metadata_path: Some(control_path_for_session_entry(session_path, group.gid())),
metadata,
}
}
/// Serializes selected request-group runtime metadata into session-file string fields.
fn build_group_metadata(group: &RequestGroup) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
(
"status".to_owned(),
group.status().as_rpc_status().to_owned(),
),
(
"num_connections".to_owned(),
group.num_connections().to_string(),
),
(
"download_speed".to_owned(),
group.download_speed().to_string(),
),
(
"upload_length".to_owned(),
group.upload_length().to_string(),
),
(
"completed_length".to_owned(),
group.completed_length().to_string(),
),
("retry_count".to_owned(), group.retry_count().to_string()),
]);
if let Some(resume_state) = group.resume_state() {
metadata.insert(
"resume_state".to_owned(),
encode_resume_state_metadata(resume_state),
);
}
if !group.retry_attempts().is_empty() {
metadata.insert(
"retry_attempts".to_owned(),
encode_retry_attempts_metadata(group.retry_attempts()),
);
}
for (key, value) in group.options().entries() {
metadata.insert(format!("opt.{}", key.as_str()), option_value_text(value));
}
if let Some(bt) = group.bt() {
metadata.insert("bt.info_hash".to_owned(), bt.info_hash.clone());
metadata.insert("bt.metadata_only".to_owned(), bt.metadata_only.to_string());
if let Some(name) = &bt.name {
metadata.insert("bt.name".to_owned(), escape_metadata_field(name));
}
if let Some(magnet_uri) = &bt.magnet_uri {
metadata.insert(
"bt.magnet_uri".to_owned(),
escape_metadata_field(magnet_uri),
);
}
if let Some(creation_date) = &bt.creation_date {
metadata.insert(
"bt.creation_date".to_owned(),
escape_metadata_field(creation_date),
);
}
if let Some(comment) = &bt.comment {
metadata.insert("bt.comment".to_owned(), escape_metadata_field(comment));
}
metadata.insert("bt.files_count".to_owned(), bt.files.len().to_string());
for (index, file) in bt.files.iter().enumerate() {
metadata.insert(
format!("bt.file.{index}.path"),
escape_metadata_field(&file.path),
);
metadata.insert(format!("bt.file.{index}.length"), file.length.to_string());
metadata.insert(
format!("bt.file.{index}.selected"),
file.selected.to_string(),
);
if let Some(piece_offset) = file.piece_offset {
metadata.insert(
format!("bt.file.{index}.piece_offset"),
piece_offset.to_string(),
);
}
}
}
metadata
}
/// Resolves the output path that should be associated with a request group.
pub(super) fn resolve_target_path(
group: &RequestGroup,
global_options: &crate::session::GlobalOptions,
) -> PathBuf {
let dir = group
.options()
.get(&OptionKey::from("dir"))
.or_else(|| global_options.get(&OptionKey::from("dir")))
.and_then(OptionValue::as_text)
.map(PathBuf::from);
let file_name = group
.options()
.get(&OptionKey::from("out"))
.and_then(OptionValue::as_text)
.map(str::to_owned)
.or_else(|| uri_file_name(group.uri()))
.unwrap_or_else(|| group.gid().to_string());
match dir {
Some(dir) => dir.join(file_name),
None => PathBuf::from(file_name),
}
}
/// Extracts a best-effort file name from a URI path component.
fn uri_file_name(uri: &str) -> Option<String> {
let trimmed = uri
.split(['?', '#'])
.next()
.unwrap_or(uri)
.trim_end_matches('/');
let candidate = trimmed.rsplit('/').next()?;
if candidate.is_empty() {
None
} else {
Some(candidate.to_owned())
}
}
/// Resolves the per-download control-file path relative to a session file path.
pub(super) fn control_path_for_session_entry(session_path: &Path, gid: DownloadId) -> PathBuf {
let parent = session_path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
parent.join("control").join(format!("{gid}.aria2"))
}
/// Builds persisted control-file metadata for a request group.
pub(super) fn build_control_metadata(
group: &RequestGroup,
target_path: &Path,
piece_length: u64,
) -> ControlMetadata {
let resolved_piece_length = group.piece_length().max(piece_length);
let inferred_total_length = infer_total_length(group, resolved_piece_length);
ControlMetadata {
version: ControlFileVersion::CURRENT,
files: vec![StoredDownloadFile {
path: target_path.to_path_buf(),
length: inferred_total_length,
piece_length: resolved_piece_length,
}],
checksums: Vec::new(),
completed_length: group.completed_length(),
retry_count: group.retry_count(),
last_error: group
.retry_attempts()
.last()
.and_then(|attempt| attempt.error.clone()),
last_error_at_unix_ms: None,
last_retry_at_unix_ms: None,
next_retry_at_unix_ms: None,
consecutive_failure_count: Some(usize_to_u32(group.retry_attempts().len())),
active_segment_count: Some(group.num_connections()),
resume_verified_at_unix_ms: None,
resume_generation: group
.resume_state()
.and_then(|resume_state| resume_state.persisted.then_some(1)),
piece_states: group
.piece_map()
.iter()
.map(|(piece, state)| {
(
StoredPieceIndex(piece.0),
map_piece_state_to_storage(*state),
)
})
.collect(),
}
}
/// Maps in-memory piece states into storage-layer piece states.
fn map_piece_state_to_storage(state: crate::piece::PieceState) -> StoredPieceState {
match state {
crate::piece::PieceState::Verified => StoredPieceState::Verified,
crate::piece::PieceState::Queued | crate::piece::PieceState::Downloading => {
StoredPieceState::InFlight
}
crate::piece::PieceState::Pending
| crate::piece::PieceState::Missing
| crate::piece::PieceState::Skipped => StoredPieceState::Pending,
}
}
/// Maps storage-layer piece states back into in-memory piece states.
fn map_piece_state_from_storage(state: StoredPieceState) -> crate::piece::PieceState {
match state {
StoredPieceState::Pending => crate::piece::PieceState::Pending,
StoredPieceState::InFlight => crate::piece::PieceState::Downloading,
StoredPieceState::Verified => crate::piece::PieceState::Verified,
StoredPieceState::Failed => crate::piece::PieceState::Missing,
}
}
/// Serializes an option value into the session metadata text format.
fn option_value_text(value: &OptionValue) -> String {
match value {
OptionValue::Bool(value) => value.to_string(),
OptionValue::Int(value) => value.to_string(),
OptionValue::UInt(value) => value.to_string(),
OptionValue::Text(value) => value.clone(),
OptionValue::List(value) => value.join(","),
OptionValue::Map(value) => value
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(","),
OptionValue::Empty => String::default(),
}
}
/// Restores request-group runtime metadata from persisted session metadata fields.
fn restore_group_metadata(group: &mut RequestGroup, metadata: Option<BTreeMap<String, String>>) {
let Some(metadata) = metadata else {
return;
};
let mut bt = BtRuntimeState::default();
let mut bt_seen = false;
let mut bt_files: BTreeMap<usize, BtFileInfo> = BTreeMap::new();
for (key, value) in metadata {
if restore_group_metadata_field(group, &key, &value) {
continue;
}
if restore_bt_metadata_field(&mut bt, &mut bt_files, &key, &value) {
bt_seen = true;
}
}
if bt_seen {
bt.files = bt_files.into_values().collect();
group.set_bt(bt);
}
}
/// Restores one non-BitTorrent metadata field onto a request group.
fn restore_group_metadata_field(group: &mut RequestGroup, key: &str, value: &str) -> bool {
match key {
"status" => {
if let Some(status) = parse_status(value) {
group.set_status(status);
}
true
}
"num_connections" => {
if let Ok(parsed) = value.parse::<u32>() {
group.set_num_connections(parsed);
}
true
}
"download_speed" => {
if let Ok(parsed) = value.parse::<u64>() {
group.set_download_speed(parsed);
}
true
}
"upload_length" => {
if let Ok(parsed) = value.parse::<u64>() {
group.set_upload_length(parsed);
}
true
}
"completed_length" => {
if let Ok(parsed) = value.parse::<u64>() {
group.set_completed_length(parsed);
}
true
}
"retry_count" => {
if let Ok(parsed) = value.parse::<u32>() {
group.set_retry_count(parsed);
}
true
}
"resume_state" => {
if let Some(parsed) = decode_resume_state_metadata(value) {
group.set_resume_state(parsed);
}
true
}
"retry_attempts" => {
group.set_retry_attempts(decode_retry_attempts_metadata(value));
true
}
_ => key.strip_prefix("opt.").is_some_and(|option_key| {
group.set_option(option_key.to_owned(), value.to_owned());
true
}),
}
}
/// Restores one `BitTorrent` metadata field.
fn restore_bt_metadata_field(
bt: &mut BtRuntimeState,
bt_files: &mut BTreeMap<usize, BtFileInfo>,
key: &str,
value: &str,
) -> bool {
match key {
"bt.info_hash" => {
value.clone_into(&mut bt.info_hash);
true
}
"bt.metadata_only" => {
bt.metadata_only = value.parse::<bool>().unwrap_or(false);
true
}
"bt.name" => {
bt.name = Some(unescape_metadata_field(value));
true
}
"bt.magnet_uri" => {
bt.magnet_uri = Some(unescape_metadata_field(value));
true
}
"bt.creation_date" => {
bt.creation_date = Some(unescape_metadata_field(value));
true
}
"bt.comment" => {
bt.comment = Some(unescape_metadata_field(value));
true
}
_ => key
.strip_prefix("bt.file.")
.is_some_and(|rest| restore_bt_file_metadata_field(bt_files, rest, value)),
}
}
/// Restores one `BitTorrent` file metadata field.
fn restore_bt_file_metadata_field(
bt_files: &mut BTreeMap<usize, BtFileInfo>,
rest: &str,
value: &str,
) -> bool {
let mut parts = rest.split('.');
let Some(index_raw) = parts.next() else {
return false;
};
let Some(field) = parts.next() else {
return false;
};
if parts.next().is_some() {
return false;
}
let Ok(index) = index_raw.parse::<usize>() else {
return false;
};
let file = bt_files.entry(index).or_default();
match field {
"path" => file.path = unescape_metadata_field(value),
"length" => {
if let Ok(parsed) = value.parse::<u64>() {
file.length = parsed;
}
}
"selected" => {
file.selected = value.parse::<bool>().unwrap_or(false);
}
"piece_offset" => {
if let Ok(parsed) = value.parse::<u64>() {
file.piece_offset = Some(parsed);
}
}
_ => {}
}
true
}
/// Restores request-group progress and piece state from persisted control metadata.
fn restore_control_metadata(group: &mut RequestGroup, control: ControlMetadata) {
if let Some(file) = control.files.first() {
group.set_total_length(file.length);
group.set_piece_length(file.piece_length);
}
group.set_completed_length(control.completed_length);
group.set_retry_count(control.retry_count);
if control.retry_count > 0 && group.retry_attempts().is_empty() {
let mut attempt = RetryAttempt::new(control.retry_count, control.completed_length);
attempt.length = Some(control.completed_length);
attempt.error.clone_from(&control.last_error);
attempt.recoverable = true;
group.push_retry_attempt(attempt);
}
if control.completed_length > 0 || control.resume_generation.is_some() {
group.set_resume_state(ResumeState {
persisted: control.resume_generation.is_some(),
resume_offset: control.completed_length,
validated_length: Some(control.completed_length),
segment_cursor: None,
});
}
for (piece, state) in control.piece_states {
group.set_piece_state(
crate::piece::PieceId(piece.0),
map_piece_state_from_storage(state),
);
}
}
/// Encodes retry attempts into a compact session-metadata string.
fn encode_retry_attempts_metadata(attempts: &[RetryAttempt]) -> String {
attempts
.iter()
.map(|attempt| {
let length = attempt
.length
.map_or_else(|| "-".to_owned(), |value| value.to_string());
let error = attempt.error.as_deref().unwrap_or("-");
format!(
"{}:{}:{}:{}:{}",
attempt.attempt,
attempt.offset,
length,
u8::from(attempt.recoverable),
escape_metadata_field(error)
)
})
.collect::<Vec<_>>()
.join(",")
}
/// Decodes retry attempts from the compact session-metadata string.
fn decode_retry_attempts_metadata(raw: &str) -> Vec<RetryAttempt> {
raw.split(',')
.filter(|entry| !entry.trim().is_empty())
.filter_map(|entry| {
let mut parts = entry.splitn(5, ':');
let attempt = parts.next()?.parse().ok()?;
let offset = parts.next()?.parse().ok()?;
let length = match parts.next()? {
"-" => None,
value => value.parse().ok(),
};
let recoverable = matches!(parts.next()?, "1" | "true");
let error = match parts.next()? {
"-" => None,
value => Some(unescape_metadata_field(value)),
};
Some(RetryAttempt {
attempt,
offset,
length,
error,
recoverable,
})
})
.collect()
}
/// Encodes resume-state metadata into a compact session-metadata string.
fn encode_resume_state_metadata(resume_state: &ResumeState) -> String {
let validated_length = resume_state
.validated_length
.map_or_else(|| "-".to_owned(), |value| value.to_string());
let segment_cursor = resume_state
.segment_cursor
.map_or_else(|| "-".to_owned(), |piece| piece.0.to_string());
format!(
"{}:{}:{}:{}",
u8::from(resume_state.persisted),
resume_state.resume_offset,
validated_length,
segment_cursor
)
}
/// Decodes resume-state metadata from the compact session-metadata string.
fn decode_resume_state_metadata(raw: &str) -> Option<ResumeState> {
let mut parts = raw.splitn(4, ':');
let persisted = matches!(parts.next()?, "1" | "true");
let resume_offset = parts.next()?.parse().ok()?;
let validated_length = match parts.next()? {
"-" => None,
value => value.parse().ok(),
};
let segment_cursor = match parts.next()? {
"-" => None,
value => value.parse().ok().map(crate::piece::PieceId),
};
Some(ResumeState {
persisted,
resume_offset,
validated_length,
segment_cursor,
})
}
/// Escapes reserved delimiters used by compact metadata encodings.
fn escape_metadata_field(raw: &str) -> String {
raw.replace('\\', "\\\\")
.replace(',', "\\c")
.replace(':', "\\d")
}
/// Reverses `escape_metadata_field`.
fn unescape_metadata_field(raw: &str) -> String {
let mut out = String::new();
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('c') => out.push(','),
Some('d') => out.push(':'),
Some('\\') | None => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
}
} else {
out.push(ch);
}
}
out
}
/// Parses the persisted RPC-style download status string.
fn parse_status(value: &str) -> Option<DownloadStatus> {
match value {
"active" => Some(DownloadStatus::Active),
"waiting" => Some(DownloadStatus::Waiting),
"paused" => Some(DownloadStatus::Paused),
"error" => Some(DownloadStatus::Error),
"complete" => Some(DownloadStatus::Complete),
"removed" => Some(DownloadStatus::Removed),
_ => None,
}
}
File diff suppressed because it is too large Load Diff