chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:16:57 +08:00
commit 17688c3e34
321 changed files with 76859 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "aria2-rust-pro-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
rust-version.workspace = true
[lib]
name = "aria2_rust_pro_core"
path = "src/lib.rs"
[dependencies]
aria2-rust-pro-storage.workspace = true
[lints]
workspace = true
+679
View File
@@ -0,0 +1,679 @@
//! Download-engine orchestration, queue management, and session persistence glue.
#![expect(
clippy::arithmetic_side_effects,
reason = "engine counters and scheduler math are guarded by domain tests rather than checked arithmetic at every step"
)]
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use aria2_rust_pro_storage::{
ControlFileVersion, ControlMetadata, DownloadFile as StoredDownloadFile,
PieceIndex as StoredPieceIndex, PieceState as StoredPieceState, SessionFile, SessionFileEntry,
load_session_file, save_session_file, write_aria2_control_file,
};
use crate::{
error::{CoreError, Result},
events::{EventBus, EventListener, RuntimeEvent, RuntimeEventKind},
options::{OptionKey, OptionPatch, OptionValue},
progress::{GlobalStat, ProgressSnapshot},
request::{
BtFileInfo, BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult,
BtPieceAvailabilityUpdate, BtPieceBlockUpdate, BtPieceMutationResult, BtPressureSnapshot,
BtRuntimeState, BtRuntimeTickResult, BtTrackerInfo, DownloadId, DownloadStatus,
RequestContext, RequestGroup, RetryAttempt, SegmentAssignment, SegmentRuntimeStats,
SegmentState,
},
runtime::RuntimeConfig,
scheduler::{
RetryHistoryEntry, ScheduleDecision, Scheduler, SchedulerActivityCounters,
SchedulerPlanningObservation, SchedulerState,
},
session::{SaveSessionTarget, Session, SessionState},
};
/// Session-file and control-file persistence helpers for the download engine.
mod session_persistence;
use self::session_persistence::{
build_control_metadata, control_path_for_session_entry, resolve_target_path,
should_persist_group,
};
/// `BitTorrent` runtime mutation helpers attached to the download engine.
mod bt_runtime;
/// Runtime inspection snapshots and progress aggregation helpers.
mod inspection;
/// Waiting-queue and stopped-result management helpers for the engine.
mod queue;
/// Scheduler integration and segment-assignment helpers for the engine.
mod scheduling;
pub use self::inspection::{DownloadRuntimeSnapshot, RuntimeInstrumentationSnapshot};
use self::inspection::{
active_runtime_group_count, build_progress_snapshot, clamp_speed, effective_piece_length,
effective_speed_caps, infer_total_length,
};
use self::scheduling::{build_segment_assignments, retry_history_from_group};
/// Converts a `usize` into `i64`, saturating to `i64::MAX` when it does not fit.
fn usize_to_i64(value: usize) -> i64 {
i64::try_from(value).unwrap_or(i64::MAX)
}
/// Converts a `usize` into `u32`, saturating to `u32::MAX` when it does not fit.
fn usize_to_u32(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
/// Converts a `usize` into `u64`.
fn usize_to_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
/// Converts a `u32` into `usize`, saturating to `usize::MAX` on unsupported targets.
fn u32_to_usize(value: u32) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
/// Stable handle used by higher layers to refer to a download in the engine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DownloadHandle {
/// Download identifier carried by this handle.
gid: DownloadId,
}
impl DownloadHandle {
/// Creates a new handle for the provided download identifier.
#[must_use]
pub const fn new(gid: DownloadId) -> Self {
Self { gid }
}
/// Returns the identifier carried by this handle.
#[must_use]
pub const fn gid(self) -> DownloadId {
self.gid
}
}
/// In-memory registry that owns all tracked request groups.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DownloadRegistry {
/// Next synthetic gid assigned when callers add a new request.
next_gid: u64,
/// Stored downloads keyed by gid.
groups: HashMap<DownloadId, RequestGroup>,
}
impl Default for DownloadRegistry {
fn default() -> Self {
Self::new()
}
}
impl DownloadRegistry {
/// Creates an empty registry with gid allocation starting at `1`.
#[must_use]
pub fn new() -> Self {
Self {
next_gid: 1,
groups: HashMap::new(),
}
}
/// Allocates the next gid without inserting a request group.
pub fn allocate_gid(&mut self) -> DownloadId {
let gid = DownloadId::new(self.next_gid);
self.next_gid = self.next_gid.saturating_add(1);
gid
}
/// Inserts an existing request group and returns its external handle.
pub fn insert(&mut self, group: RequestGroup) -> DownloadHandle {
let gid = group.gid();
self.groups.insert(gid, group);
DownloadHandle::new(gid)
}
/// Creates a simple URI-backed request group and inserts it into the registry.
pub fn add_uri(&mut self, uri: impl Into<String>) -> DownloadHandle {
let gid = self.allocate_gid();
self.insert(RequestGroup::new(gid, uri))
}
/// Returns the immutable request group for `gid` when present.
#[must_use]
pub fn get(&self, gid: DownloadId) -> Option<&RequestGroup> {
self.groups.get(&gid)
}
/// Returns the mutable request group for `gid` when present.
pub fn get_mut(&mut self, gid: DownloadId) -> Option<&mut RequestGroup> {
self.groups.get_mut(&gid)
}
/// Removes and returns the request group associated with `gid`.
pub fn remove(&mut self, gid: DownloadId) -> Option<RequestGroup> {
self.groups.remove(&gid)
}
/// Returns the number of tracked downloads.
#[must_use]
pub fn len(&self) -> usize {
self.groups.len()
}
/// Returns whether the registry is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
/// Iterates over handles for every currently registered gid.
pub fn handles(&self) -> impl Iterator<Item = DownloadHandle> + '_ {
self.groups.keys().copied().map(DownloadHandle::new)
}
}
/// Reference frame used when changing a waiting download's queue position.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QueuePositionMode {
/// Treat the supplied offset as an absolute queue index.
Set,
/// Apply the supplied offset relative to the current queue index.
Cur,
/// Apply the supplied offset relative to the end of the waiting queue.
End,
}
/// High-level in-memory engine that coordinates downloads, scheduling, session IO, and events.
#[derive(Debug)]
pub struct DownloadEngine {
/// Registry holding all live request groups.
registry: DownloadRegistry,
/// Waiting queue order for paused and waiting downloads.
reserved_queue: Vec<DownloadId>,
/// Shared scheduler used to plan active segments and retry flow.
scheduler: Scheduler,
/// Embedded session/runtime bridge.
session: Session,
/// Cached global stat record updated on demand.
global_stat: GlobalStat,
/// Event bus used by RPC and other observers.
events: EventBus,
/// Engine lifecycle state.
state: SessionState,
/// Monotonic sequence assigned to stopped downloads for tellStopped ordering.
next_stopped_sequence: u64,
}
impl Default for DownloadEngine {
fn default() -> Self {
Self::new()
}
}
impl DownloadEngine {
/// Creates a new engine with the default runtime configuration.
#[must_use]
pub fn new() -> Self {
Self::with_runtime(RuntimeConfig::default())
}
/// Creates a new engine backed by the provided runtime configuration.
#[must_use]
pub fn with_runtime(runtime: RuntimeConfig) -> Self {
Self {
registry: DownloadRegistry::new(),
reserved_queue: Vec::new(),
scheduler: Scheduler::new(),
global_stat: GlobalStat::default(),
events: EventBus::new(),
state: SessionState::Idle,
session: Session::new(runtime),
next_stopped_sequence: 1,
}
}
/// Returns the runtime configuration currently attached to the session bridge.
#[must_use]
pub fn runtime(&self) -> &RuntimeConfig {
self.session.runtime()
}
/// Returns the underlying download registry.
#[must_use]
pub fn registry(&self) -> &DownloadRegistry {
&self.registry
}
/// Returns a mutable reference to the underlying download registry.
pub fn registry_mut(&mut self) -> &mut DownloadRegistry {
&mut self.registry
}
/// Returns the scheduler used by the engine.
#[must_use]
pub fn scheduler(&self) -> &Scheduler {
&self.scheduler
}
/// Returns a mutable reference to the scheduler used by the engine.
pub fn scheduler_mut(&mut self) -> &mut Scheduler {
&mut self.scheduler
}
/// Returns the engine event bus.
#[must_use]
pub fn events(&self) -> &EventBus {
&self.events
}
/// Returns a mutable reference to the engine event bus.
pub fn events_mut(&mut self) -> &mut EventBus {
&mut self.events
}
/// Returns the embedded session bridge.
#[must_use]
pub fn session(&self) -> &Session {
&self.session
}
/// Returns a mutable reference to the embedded session bridge.
pub fn session_mut(&mut self) -> &mut Session {
&mut self.session
}
/// Returns the current engine lifecycle state.
#[must_use]
pub fn state(&self) -> &SessionState {
&self.state
}
/// Returns the cached global stat structure.
#[must_use]
pub fn global_stat(&self) -> &GlobalStat {
&self.global_stat
}
/// Adds a URI download to the registry and enqueues it at the back of the waiting queue.
pub fn add_uri(&mut self, uri: impl Into<String>) -> DownloadHandle {
let handle = self.registry.add_uri(uri);
self.enqueue_reserved_back(handle.gid());
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadAdded).with_gid(handle.gid()));
handle
}
/// Adds a fully-formed request context to the registry and waiting queue.
pub fn add_request(&mut self, context: RequestContext) -> DownloadHandle {
let gid = self.registry.allocate_gid();
let handle = self
.registry
.insert(RequestGroup::with_context(gid, context));
self.enqueue_reserved_back(handle.gid());
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadAdded).with_gid(handle.gid()));
handle
}
/// Requests an orderly shutdown through the session bridge.
pub fn shutdown(&mut self) -> Result<()> {
self.state = SessionState::ShuttingDown;
self.scheduler.set_state(SchedulerState::ShuttingDown);
self.session.shutdown()?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::ShutdownRequested));
Ok(())
}
/// Requests an immediate forced shutdown through the session bridge.
pub fn force_shutdown(&mut self) -> Result<()> {
self.state = SessionState::ForceShuttingDown;
self.scheduler.set_state(SchedulerState::Stopped);
self.session.force_shutdown()?;
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::ForceShutdownRequested));
Ok(())
}
/// Persists the in-memory session and, for file targets, control-file metadata.
pub fn save_session(&mut self, target: SaveSessionTarget) -> Result<()> {
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SessionSaving));
self.session.save_session(target.clone())?;
if let SaveSessionTarget::Path(path) = &target {
let session_file = self.build_session_file(path);
save_session_file(path, &session_file)
.map_err(|_| CoreError::StorageUnavailable("failed to write session file"))?;
for group in self.registry.groups.values() {
if !should_persist_group(*group.status()) {
continue;
}
let target_path = resolve_target_path(group, self.session.global_options());
let control_path = control_path_for_session_entry(path, group.gid());
let control =
build_control_metadata(group, &target_path, self.runtime().piece_length);
if let Some(parent) = control_path.parent() {
std::fs::create_dir_all(parent).map_err(|_| {
CoreError::StorageUnavailable("failed to create control-file directory")
})?;
}
write_aria2_control_file(&control_path, &control)
.map_err(|_| CoreError::StorageUnavailable("failed to write control file"))?;
}
}
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::SessionSaved));
Ok(())
}
/// Loads session state from memory or disk and rebuilds the registry when needed.
pub fn load_session(&mut self, source: SaveSessionTarget) -> Result<()> {
match source {
SaveSessionTarget::Memory => self.session.load_session(SaveSessionTarget::Memory),
SaveSessionTarget::Path(path) => {
let session_file = load_session_file(&path)
.map_err(|_| CoreError::StorageUnavailable("failed to read session file"))?;
self.rebuild_registry_from_session_file(&path, session_file);
if let Err(error) = self
.session
.load_session(SaveSessionTarget::Path(path.clone()))
{
match error {
CoreError::StorageUnavailable(_) => self.session.mark_external_load(path),
other => return Err(other),
}
}
Ok(())
}
}
}
/// Subscribes a listener to engine events.
pub fn register_listener(&mut self, listener: impl EventListener + 'static) {
self.events.subscribe(listener);
}
/// Sets one global option on the embedded session bridge.
pub fn set_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
self.session.set_global_option(key, value);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::OptionChanged));
}
/// Applies a batch global-option patch to the embedded session bridge.
pub fn apply_options(&mut self, patch: OptionPatch) {
self.session.apply_global_option_patch(patch);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::OptionChanged));
}
/// Builds a progress snapshot for a specific download.
pub fn progress_snapshot(&self, gid: DownloadId) -> Result<ProgressSnapshot> {
let group = self
.registry
.get(gid)
.ok_or(CoreError::UnknownDownloadId(gid))?;
Ok(build_progress_snapshot(
group,
self.runtime(),
active_runtime_group_count(&self.registry),
))
}
/// Builds an engine-level runtime snapshot for a specific download.
pub fn download_runtime_snapshot(&self, gid: DownloadId) -> Result<DownloadRuntimeSnapshot> {
let group = self
.registry
.get(gid)
.ok_or(CoreError::UnknownDownloadId(gid))?;
let total_length = group
.bt_effective_target_length()
.unwrap_or_else(|| group.total_length());
let active_count = active_runtime_group_count(&self.registry);
let (effective_download_limit, effective_upload_limit) =
effective_speed_caps(group, self.runtime(), active_count);
Ok(DownloadRuntimeSnapshot {
gid,
status: *group.status(),
total_length,
completed_length: group.completed_length(),
remaining_length: total_length
.saturating_sub(group.completed_length().min(total_length)),
download_speed: clamp_speed(group.download_speed(), effective_download_limit),
upload_speed: clamp_speed(group.upload_speed(), effective_upload_limit),
effective_download_limit,
effective_upload_limit,
retry_count: group.retry_count(),
num_connections: group.num_connections(),
segment_stats: group.segment_runtime_stats(),
bt_pressure: group.bt_pressure_snapshot(),
})
}
/// Returns aggregate scheduler and resource instrumentation for all downloads.
#[must_use]
pub fn runtime_instrumentation_snapshot(&self) -> RuntimeInstrumentationSnapshot {
let mut snapshot = RuntimeInstrumentationSnapshot {
download_count: self.registry.len(),
active_download_count: 0,
waiting_download_count: 0,
stopped_download_count: 0,
error_download_count: 0,
complete_download_count: 0,
total_active_segments: 0,
total_planned_bytes: 0,
total_remaining_segment_bytes: 0,
total_requestable_pieces: 0,
total_scarce_requestable_pieces: 0,
total_bt_peers: 0,
configured_disk_cache_bytes: self.runtime().disk_cache_bytes,
max_overall_download_limit: self.runtime().max_overall_download_limit,
max_download_limit: self.runtime().max_download_limit,
max_overall_upload_limit: self.runtime().max_overall_upload_limit,
max_upload_limit: self.runtime().max_upload_limit,
scheduler_state: self.scheduler.state(),
scheduler_counters: *self.scheduler.activity_counters(),
last_scheduler_plan: self.scheduler.last_planning_observation().copied(),
};
for group in self.registry.groups.values() {
match group.status() {
DownloadStatus::Active => snapshot.active_download_count += 1,
DownloadStatus::Waiting => snapshot.waiting_download_count += 1,
DownloadStatus::Paused | DownloadStatus::Removed => {
snapshot.stopped_download_count += 1;
}
DownloadStatus::Error => snapshot.error_download_count += 1,
DownloadStatus::Complete => snapshot.complete_download_count += 1,
}
let segment_stats = group.segment_runtime_stats();
snapshot.total_active_segments +=
segment_stats.active_count + segment_stats.retrying_count;
snapshot.total_planned_bytes = snapshot
.total_planned_bytes
.saturating_add(segment_stats.planned_bytes);
snapshot.total_remaining_segment_bytes = snapshot
.total_remaining_segment_bytes
.saturating_add(segment_stats.remaining_bytes);
if let Some(pressure) = group.bt_pressure_snapshot() {
snapshot.total_requestable_pieces += pressure.requestable_pieces;
snapshot.total_scarce_requestable_pieces += pressure.scarce_requestable_pieces;
snapshot.total_bt_peers += pressure.peer_count;
}
}
snapshot
}
/// Returns handles for active downloads.
#[must_use]
pub fn tell_active(&self) -> Vec<DownloadHandle> {
self.registry
.groups
.iter()
.filter_map(|(gid, group)| {
(group.status() == &DownloadStatus::Active).then_some(DownloadHandle::new(*gid))
})
.collect()
}
/// Returns handles for waiting and paused downloads in reserved-queue order.
#[must_use]
pub fn tell_waiting(&self) -> Vec<DownloadHandle> {
self.reserved_queue
.iter()
.filter_map(|gid| {
self.registry.get(*gid).and_then(|group| {
matches!(
group.status(),
DownloadStatus::Waiting | DownloadStatus::Paused
)
.then_some(DownloadHandle::new(*gid))
})
})
.collect()
}
/// Returns handles for stopped downloads ordered by stopped-sequence.
#[must_use]
pub fn tell_stopped(&self) -> Vec<DownloadHandle> {
let mut stopped = self
.registry
.groups
.iter()
.filter_map(|(gid, group)| {
matches!(
group.status(),
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
)
.then_some((group.stopped_sequence().unwrap_or_default(), *gid))
})
.collect::<Vec<_>>();
stopped.sort_by_key(|(sequence, gid)| (*sequence, *gid));
stopped
.into_iter()
.map(|(_, gid)| DownloadHandle::new(gid))
.collect()
}
/// Returns the total number of downloads that have entered a stopped terminal state.
#[must_use]
pub const fn num_stopped_total(&self) -> u64 {
self.next_stopped_sequence.saturating_sub(1)
}
/// Emits a prebuilt runtime event through the engine event bus.
pub fn emit(&mut self, event: RuntimeEvent) {
self.events.emit(event);
}
/// Returns a lightweight handle when the download exists.
#[must_use]
pub fn handle(&self, gid: DownloadId) -> Option<DownloadHandle> {
self.registry.get(gid).map(|_| DownloadHandle::new(gid))
}
/// Returns a mutable request group when the download exists.
pub fn handle_mut(&mut self, gid: DownloadId) -> Option<&mut RequestGroup> {
self.registry.get_mut(gid)
}
/// Resolves a mutable request group or returns `UnknownDownloadId`.
fn group_mut(&mut self, gid: DownloadId) -> Result<&mut RequestGroup> {
self.registry
.get_mut(gid)
.ok_or(CoreError::UnknownDownloadId(gid))
}
/// Applies one scheduler decision to a specific group and synchronizes the session bridge.
fn apply_schedule_decision(&mut self, gid: DownloadId, decision: &ScheduleDecision) {
self.scheduler.record_decision(decision);
match decision {
ScheduleDecision::RunNow(_) | ScheduleDecision::Queue(_) => {
let runtime = self.runtime().clone();
self.remove_from_reserved_queue(gid);
let Some(group) = self.registry.get_mut(gid) else {
return;
};
group.set_status(DownloadStatus::Active);
let segments = self.scheduler.plan_active_segments(group, &runtime);
let assignments = build_segment_assignments(group, &runtime, segments);
group.set_segment_assignments(assignments);
self.scheduler.observe_plan(group, &runtime, segments);
let completed = group.completed_length();
let retry_count = group.retry_count();
let retry_attempts = group.retry_attempts().to_vec();
let active_segments = u32_to_usize(group.num_connections());
self.sync_runtime_bridge(completed, retry_count, retry_attempts, active_segments);
self.events
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadStarted).with_gid(gid));
}
ScheduleDecision::RetryLater(_) => {
let (completed, retry_count, retry_attempts, active_segments) = {
let Some(group) = self.registry.get_mut(gid) else {
return;
};
group.increment_retry_count();
let mut attempt =
RetryAttempt::new(group.retry_count(), group.completed_length());
attempt.length = Some(
group
.total_length()
.saturating_sub(group.completed_length()),
);
attempt.error = Some("schedule-retry:error-state".to_string());
attempt.recoverable = true;
group.push_retry_attempt(attempt);
group.clear_segment_assignments();
group.set_status(DownloadStatus::Waiting);
(
group.completed_length(),
group.retry_count(),
group.retry_attempts().to_vec(),
u32_to_usize(group.num_connections()),
)
};
if !self.is_in_reserved_queue(gid) {
self.enqueue_reserved_back(gid);
}
self.sync_runtime_bridge(completed, retry_count, retry_attempts, active_segments);
}
ScheduleDecision::Pause(_) | ScheduleDecision::Remove(_) | ScheduleDecision::Noop => {}
}
}
/// Mirrors scheduler planning state into the embedded session bridge.
fn sync_runtime_bridge(
&mut self,
completed_length: u64,
retry_count: u32,
retry_attempts: Vec<RetryAttempt>,
active_segments: usize,
) {
let segment_plan = self.scheduler.bridge_segment_plan(self.runtime());
self.session.set_segment_plan(segment_plan);
let retry_history = retry_history_from_group(&retry_attempts);
let runtime_state = self.scheduler.bridge_runtime_state(
completed_length,
retry_count,
retry_history,
active_segments,
);
self.session.apply_runtime_schedule_state(runtime_state);
self.session.apply_scheduler_instrumentation(
*self.scheduler.activity_counters(),
self.scheduler.last_planning_observation().copied(),
);
}
}
#[cfg(test)]
/// Engine-focused tests covering registry flow, persistence, snapshots, and BT runtime helpers.
mod tests;
@@ -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
+66
View File
@@ -0,0 +1,66 @@
//! Error codes and error values emitted by the core crate.
use std::fmt::{Display, Formatter};
use crate::request::DownloadId;
/// Standard result type returned by core APIs.
pub type Result<T> = std::result::Result<T, CoreError>;
/// Stable error categories for mapping runtime failures to RPC-facing codes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorCode {
/// The requested operation is not supported by the current implementation.
Unsupported,
/// The supplied download id does not resolve to a tracked request group.
UnknownDownload,
/// The requested state transition is not valid for the current runtime state.
InvalidState,
/// The runtime is shutting down and cannot accept the requested operation.
ShutdownInProgress,
/// A required persistence or storage action failed.
StorageUnavailable,
}
/// Concrete errors returned by the core engine and state surfaces.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreError {
/// The referenced download id is not present in the registry.
UnknownDownloadId(DownloadId),
/// The caller requested a feature that is not yet implemented.
UnsupportedOperation(&'static str),
/// The caller requested an invalid state transition or runtime action.
InvalidState(&'static str),
/// Shutdown has started and the runtime is no longer accepting work.
ShutdownInProgress,
/// Session or control-file storage was unavailable.
StorageUnavailable(&'static str),
}
impl CoreError {
/// Returns the stable error code associated with this error value.
#[must_use]
pub const fn code(&self) -> ErrorCode {
match self {
Self::UnknownDownloadId(_) => ErrorCode::UnknownDownload,
Self::UnsupportedOperation(_) => ErrorCode::Unsupported,
Self::InvalidState(_) => ErrorCode::InvalidState,
Self::ShutdownInProgress => ErrorCode::ShutdownInProgress,
Self::StorageUnavailable(_) => ErrorCode::StorageUnavailable,
}
}
}
impl Display for CoreError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownDownloadId(gid) => write!(f, "unknown download id: {gid}"),
Self::UnsupportedOperation(msg) => write!(f, "unsupported operation: {msg}"),
Self::InvalidState(msg) => write!(f, "invalid runtime state: {msg}"),
Self::ShutdownInProgress => write!(f, "engine shutdown is in progress"),
Self::StorageUnavailable(msg) => write!(f, "storage unavailable: {msg}"),
}
}
}
impl std::error::Error for CoreError {}
+143
View File
@@ -0,0 +1,143 @@
//! Runtime event definitions and the in-memory event bus.
use std::collections::VecDeque;
use crate::{progress::ProgressSnapshot, request::DownloadId};
/// Event categories emitted by the engine as downloads and sessions evolve.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeEventKind {
/// A download was added to the registry.
DownloadAdded,
/// A download moved into the active set.
DownloadStarted,
/// A download was paused.
DownloadPaused,
/// A paused download was resumed.
DownloadResumed,
/// A download was removed.
DownloadRemoved,
/// A download completed successfully.
DownloadCompleted,
/// A download entered the error state.
DownloadErrored,
/// Global or per-download options changed.
OptionChanged,
/// Session persistence is starting.
SessionSaving,
/// Session persistence finished.
SessionSaved,
/// Graceful shutdown was requested.
ShutdownRequested,
/// Forced shutdown was requested.
ForceShutdownRequested,
/// The scheduler advanced a planning tick.
SchedulerTick,
/// Aggregated statistics were refreshed.
StatisticsUpdated,
/// Piece-level state changed.
PieceUpdated,
}
/// Runtime event payload queued by the in-memory event bus.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeEvent {
/// The event category.
pub kind: RuntimeEventKind,
/// The affected download id, when applicable.
pub gid: Option<DownloadId>,
/// Optional human-readable message text.
pub message: Option<String>,
/// Optional progress snapshot captured for the event.
pub snapshot: Option<ProgressSnapshot>,
}
impl RuntimeEvent {
/// Creates a new event with the provided kind.
#[must_use]
pub fn new(kind: RuntimeEventKind) -> Self {
Self {
kind,
gid: None,
message: None,
snapshot: None,
}
}
/// Attaches the download id affected by the event.
#[must_use]
pub fn with_gid(mut self, gid: DownloadId) -> Self {
self.gid = Some(gid);
self
}
/// Attaches a human-readable message to the event.
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
}
/// Listener interface for consumers that want push-style event delivery.
pub trait EventListener: Send {
/// Handles a newly emitted event.
fn on_event(&mut self, event: &RuntimeEvent);
}
/// FIFO event queue with immediate listener fan-out.
#[derive(Default)]
pub struct EventBus {
/// Registered listeners that receive push-style fan-out.
listeners: Vec<Box<dyn EventListener>>,
/// FIFO queue of emitted events waiting to be drained.
queue: VecDeque<RuntimeEvent>,
}
impl std::fmt::Debug for EventBus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventBus")
.field("listener_count", &self.listeners.len())
.field("queue_len", &self.queue.len())
.finish()
}
}
impl EventBus {
/// Creates an empty event bus.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Registers a new listener that will receive future events.
pub fn subscribe(&mut self, listener: impl EventListener + 'static) {
self.listeners.push(Box::new(listener));
}
/// Emits an event to listeners and stores it in the queue.
pub fn emit(&mut self, event: RuntimeEvent) {
for listener in &mut self.listeners {
listener.on_event(&event);
}
self.queue.push_back(event);
}
/// Drains and returns all queued events in FIFO order.
#[must_use]
pub fn drain(&mut self) -> Vec<RuntimeEvent> {
self.queue.drain(..).collect()
}
/// Returns the number of queued events.
#[must_use]
pub fn len(&self) -> usize {
self.queue.len()
}
/// Returns whether the event queue is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
}
+58
View File
@@ -0,0 +1,58 @@
#![doc = "Core runtime, scheduling, and request-state primitives for aria2-rust-pro."]
#![forbid(unsafe_code)]
#![expect(
clippy::if_not_else,
clippy::missing_const_for_fn,
clippy::missing_errors_doc,
clippy::must_use_candidate,
clippy::needless_pass_by_value,
clippy::struct_excessive_bools,
clippy::struct_field_names,
clippy::use_self,
reason = "core crate exposes compatibility-oriented runtime models where strict style lints add noise"
)]
/// Download engine orchestration, queue management, and session persistence.
mod engine;
/// Error types returned by the core crate.
mod error;
/// Runtime event types and the in-memory event bus.
mod events;
/// Typed option keys, values, and patches.
mod options;
/// Piece identifiers, piece ranges, and piece-state storage.
mod piece;
/// Aggregated progress and statistics snapshots.
mod progress;
/// Request, `BitTorrent`, and segment runtime state models.
mod request;
/// Runtime configuration and human-readable size parsing helpers.
mod runtime;
/// Download scheduling policies, planning, and observations.
mod scheduler;
/// Session state, global options, and persistence bridge data.
mod session;
pub use engine::{
DownloadEngine, DownloadHandle, DownloadRegistry, DownloadRuntimeSnapshot, QueuePositionMode,
RuntimeInstrumentationSnapshot,
};
pub use error::{CoreError, ErrorCode, Result};
pub use events::{EventBus, EventListener, RuntimeEvent, RuntimeEventKind};
pub use options::{OptionKey, OptionPatch, OptionValue};
pub use piece::{PieceId, PieceMap, PieceRange, PieceState};
pub use progress::{GlobalStat, GoalProgress, ProgressSnapshot, WorkState};
pub use request::{
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtPressureSnapshot, BtRuntimeState,
BtTrackerInfo, DownloadId, DownloadStatus, RequestContext, RequestGroup, ResumeState,
RetryAttempt, SegmentAssignment, SegmentRuntimeStats, SegmentState,
};
pub use runtime::RuntimeConfig;
pub use scheduler::{
ScheduleDecision, ScheduleDecisionKind, Scheduler, SchedulerActivityCounters,
SchedulerPlanningObservation, SchedulerPolicy, SchedulerState,
};
pub use session::{GlobalOptions, SaveSessionTarget, Session, SessionState};
/// Convenience alias for the primary download task model.
pub type DownloadTask = RequestGroup;
+149
View File
@@ -0,0 +1,149 @@
//! Typed option keys, values, and patch collections.
use std::collections::BTreeMap;
/// Strongly typed option key used by session and request surfaces.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct OptionKey(
/// Raw option-key text stored by the runtime.
pub String,
);
impl OptionKey {
/// Builds a new owned option key.
#[must_use]
pub fn new(key: impl Into<String>) -> Self {
Self(key.into())
}
/// Returns the raw string representation of the key.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Supported option value shapes accepted by the core surfaces.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum OptionValue {
/// Boolean option value.
Bool(bool),
/// Signed integer option value.
Int(i64),
/// Unsigned integer option value.
UInt(u64),
/// Text option value.
Text(String),
/// Repeated text values.
List(Vec<String>),
/// String-keyed string map values.
Map(BTreeMap<String, String>),
/// Explicit empty value.
Empty,
}
impl OptionValue {
/// Returns the inner string when the value is textual.
#[must_use]
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(value) => Some(value.as_str()),
_ => None,
}
}
}
/// Mergeable collection of option overrides.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct OptionPatch {
/// Ordered option entries applied by the patch.
entries: BTreeMap<OptionKey, OptionValue>,
}
impl OptionPatch {
/// Creates an empty patch.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Inserts or replaces a value in the patch.
pub fn insert(
&mut self,
key: impl Into<OptionKey>,
value: impl Into<OptionValue>,
) -> Option<OptionValue> {
self.entries.insert(key.into(), value.into())
}
/// Returns the value for a given key when present.
#[must_use]
pub fn get(&self, key: &OptionKey) -> Option<&OptionValue> {
self.entries.get(key)
}
/// Returns whether the patch contains any entries.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Merges another patch into this one, overwriting matching keys.
pub fn merge(&mut self, other: OptionPatch) {
self.entries.extend(other.entries);
}
/// Returns the underlying ordered patch entries.
#[must_use]
pub fn entries(&self) -> &BTreeMap<OptionKey, OptionValue> {
&self.entries
}
}
impl From<&str> for OptionKey {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for OptionKey {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<bool> for OptionValue {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<i64> for OptionValue {
fn from(value: i64) -> Self {
Self::Int(value)
}
}
impl From<u64> for OptionValue {
fn from(value: u64) -> Self {
Self::UInt(value)
}
}
impl From<String> for OptionValue {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for OptionValue {
fn from(value: &str) -> Self {
Self::Text(value.to_owned())
}
}
impl From<Vec<String>> for OptionValue {
fn from(value: Vec<String>) -> Self {
Self::List(value)
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Piece identifiers, piece states, and in-memory piece maps.
use std::collections::BTreeMap;
/// Stable identifier for a single piece within a download.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PieceId(
/// Zero-based piece index within the download.
pub u32,
);
/// Current lifecycle state of a piece.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PieceState {
/// The piece has not been queued yet.
Pending,
/// The piece is queued and ready to be assigned.
Queued,
/// The piece is currently being downloaded.
Downloading,
/// The piece has been verified successfully.
Verified,
/// The piece is missing and should be retried.
Missing,
/// The piece is intentionally skipped.
Skipped,
}
/// Half-open byte range occupied by a piece or segment.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PieceRange {
/// Inclusive starting byte offset.
pub start: u64,
/// Exclusive ending byte offset.
pub end: u64,
}
impl PieceRange {
/// Creates a new half-open byte range.
#[must_use]
pub const fn new(start: u64, end: u64) -> Self {
Self { start, end }
}
/// Returns the byte length of the range.
#[must_use]
pub const fn len(&self) -> u64 {
self.end.saturating_sub(self.start)
}
/// Returns whether the range contains no bytes.
#[must_use]
pub const fn is_empty(&self) -> bool {
self.start >= self.end
}
}
/// Ordered in-memory map from piece ids to piece states.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PieceMap {
/// Ordered mapping from piece ids to their current states.
pieces: BTreeMap<PieceId, PieceState>,
}
impl PieceMap {
/// Creates an empty piece map.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Inserts or replaces a piece state.
pub fn insert(&mut self, id: PieceId, state: PieceState) -> Option<PieceState> {
self.pieces.insert(id, state)
}
/// Returns the state for a piece when present.
#[must_use]
pub fn get(&self, id: &PieceId) -> Option<PieceState> {
self.pieces.get(id).copied()
}
/// Sets the state for a piece id.
pub fn set_state(&mut self, id: PieceId, state: PieceState) {
self.pieces.insert(id, state);
}
/// Iterates over all tracked pieces in key order.
pub fn iter(&self) -> impl Iterator<Item = (&PieceId, &PieceState)> {
self.pieces.iter()
}
/// Returns the number of tracked pieces.
#[must_use]
pub fn len(&self) -> usize {
self.pieces.len()
}
/// Returns whether the map is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.pieces.is_empty()
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Progress snapshots and aggregate statistics exposed by the core runtime.
use crate::request::{DownloadId, DownloadStatus};
/// High-level work state used for coarse progress reporting.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkState {
/// Work is planned but not yet implemented.
Planned,
/// Work has been implemented.
Implemented,
/// Work has been verified.
Verified,
}
/// Coarse progress information for a multi-phase goal.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GoalProgress {
/// Overall completion percent across the whole goal.
overall_percent: u8,
/// Human-readable name of the current phase.
phase_name: String,
/// Completion percent within the current phase.
phase_percent: u8,
/// Coarse progress state for the current phase.
state: WorkState,
}
impl GoalProgress {
/// Creates a new progress tracker for the given phase.
#[must_use]
pub fn new(phase_name: impl Into<String>) -> Self {
Self {
overall_percent: 1,
phase_name: phase_name.into(),
phase_percent: 20,
state: WorkState::Planned,
}
}
/// Returns the overall completion percentage.
#[must_use]
pub const fn overall_percent(&self) -> u8 {
self.overall_percent
}
/// Returns the current phase name.
#[must_use]
pub fn phase_name(&self) -> &str {
&self.phase_name
}
/// Returns the current phase completion percentage.
#[must_use]
pub const fn phase_percent(&self) -> u8 {
self.phase_percent
}
/// Returns the coarse work state.
#[must_use]
pub const fn state(&self) -> WorkState {
self.state
}
}
/// Aggregated global transfer statistics.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct GlobalStat {
/// Aggregate download throughput in bytes per second.
pub download_speed: u64,
/// Aggregate upload throughput in bytes per second.
pub upload_speed: u64,
/// Number of downloads currently active.
pub num_active: u32,
/// Number of downloads queued and waiting.
pub num_waiting: u32,
/// Number of downloads stopped without error.
pub num_stopped: u32,
/// Number of downloads currently in an error state.
pub num_error: u32,
/// Number of downloads completed successfully.
pub num_complete: u32,
/// Total tracked payload length across downloads.
pub total_length: u64,
/// Total completed payload length across downloads.
pub completed_length: u64,
}
impl GlobalStat {
/// Creates an empty statistics snapshot.
#[must_use]
pub const fn new() -> Self {
Self {
download_speed: 0,
upload_speed: 0,
num_active: 0,
num_waiting: 0,
num_stopped: 0,
num_error: 0,
num_complete: 0,
total_length: 0,
completed_length: 0,
}
}
}
/// Detailed progress snapshot for a single download.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ProgressSnapshot {
/// Download id associated with this snapshot.
pub gid: DownloadId,
/// Current lifecycle status of the download.
pub status: DownloadStatus,
/// Total payload length in bytes.
pub total_length: u64,
/// Completed payload length in bytes.
pub completed_length: u64,
/// Uploaded payload length in bytes.
pub upload_length: u64,
/// Current upload throughput in bytes per second.
pub upload_speed: u64,
/// Current download throughput in bytes per second.
pub download_speed: u64,
/// Number of active connections assigned to the download.
pub num_connections: u32,
/// Estimated seconds remaining when known.
pub eta_seconds: Option<u64>,
/// Whether the runtime currently considers the download seeding.
pub seeding: bool,
/// Share ratio expressed in milli-units when available.
pub share_ratio_milli: Option<u64>,
/// Accumulated share time in seconds when available.
pub share_time_secs: Option<u64>,
/// Accumulated seeding time in seconds when available.
pub seeding_time_secs: Option<u64>,
/// Total selected `BitTorrent` payload length in bytes.
pub bt_selected_payload_length: u64,
/// Remaining selected `BitTorrent` payload length in bytes.
pub bt_remaining_payload_length: u64,
/// Whether the torrent has completed selected work and is truly seeding.
pub bt_true_seeding: bool,
/// Number of peers in the current swarm snapshot.
pub bt_total_peers: u32,
/// Number of peers currently identified as seeders.
pub bt_seeders: u32,
/// Number of peers currently identified as leechers.
pub bt_leechers: u32,
/// Number of pieces with non-zero availability.
pub bt_available_pieces: u32,
/// Number of verified pieces.
pub bt_verified_pieces: u32,
/// Number of actively downloading pieces.
pub bt_downloading_pieces: u32,
/// Number of queued pieces.
pub bt_queued_pieces: u32,
/// Number of missing pieces.
pub bt_missing_pieces: u32,
}
impl ProgressSnapshot {
/// Creates an empty progress snapshot for the given download id and status.
#[must_use]
pub fn new(gid: DownloadId, status: DownloadStatus) -> Self {
Self {
gid,
status,
total_length: 0,
completed_length: 0,
upload_length: 0,
upload_speed: 0,
download_speed: 0,
num_connections: 0,
eta_seconds: None,
seeding: false,
share_ratio_milli: None,
share_time_secs: None,
seeding_time_secs: None,
bt_selected_payload_length: 0,
bt_remaining_payload_length: 0,
bt_true_seeding: false,
bt_total_peers: 0,
bt_seeders: 0,
bt_leechers: 0,
bt_available_pieces: 0,
bt_verified_pieces: 0,
bt_downloading_pieces: 0,
bt_queued_pieces: 0,
bt_missing_pieces: 0,
}
}
/// Returns whether the snapshot has a non-zero payload length.
#[must_use]
pub const fn has_payload_length(&self) -> bool {
self.total_length > 0
}
/// Returns the remaining payload length in bytes.
#[must_use]
pub fn remaining_length(&self) -> u64 {
self.total_length
.saturating_sub(self.completed_length.min(self.total_length))
}
/// Returns whether the payload transfer is complete.
#[must_use]
pub fn transfer_complete(&self) -> bool {
self.has_payload_length() && self.remaining_length() == 0
}
/// Returns whether the transfer is complete or actively seeding.
#[must_use]
pub fn bt_transfer_complete_or_seeding(&self) -> bool {
self.transfer_complete() || self.seeding
}
/// Returns whether any `BitTorrent` share-runtime data is present.
#[must_use]
pub const fn bt_has_share_runtime(&self) -> bool {
self.share_time_secs.is_some() || self.share_ratio_milli.is_some()
}
/// Returns whether peer or piece-availability activity exists.
#[must_use]
pub const fn bt_has_swarm_activity(&self) -> bool {
self.bt_total_peers > 0 || self.bt_available_pieces > 0
}
/// Returns the total number of active `BitTorrent` pieces.
#[must_use]
pub const fn bt_active_piece_count(&self) -> u32 {
self.bt_downloading_pieces
.saturating_add(self.bt_queued_pieces)
}
/// Returns whether the selected `BitTorrent` payload is complete.
#[must_use]
pub const fn bt_payload_complete(&self) -> bool {
self.bt_selected_payload_length > 0
&& self.bt_remaining_payload_length == 0
&& self.completed_length >= self.bt_selected_payload_length
}
/// Returns completion percent in milli-units.
#[must_use]
pub fn completion_percent_milli(&self) -> u64 {
if self.total_length == 0 {
return 0;
}
self.completed_length
.min(self.total_length)
.saturating_mul(1000)
.checked_div(self.total_length)
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_snapshot_new_initializes_bt_share_fields() {
let snapshot = ProgressSnapshot::new(DownloadId::new(0x42), DownloadStatus::Waiting);
assert!(!snapshot.seeding);
assert_eq!(snapshot.share_ratio_milli, None);
assert_eq!(snapshot.upload_speed, 0);
assert_eq!(snapshot.share_time_secs, None);
assert!(!snapshot.bt_has_swarm_activity());
assert!(!snapshot.bt_has_share_runtime());
}
#[test]
fn progress_snapshot_bt_completion_semantics_avoid_false_completion() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x43), DownloadStatus::Active);
snapshot.total_length = 10_000;
snapshot.completed_length = 9_000;
assert_eq!(snapshot.remaining_length(), 1_000);
assert!(!snapshot.transfer_complete());
assert!(!snapshot.bt_transfer_complete_or_seeding());
snapshot.seeding = true;
assert!(snapshot.bt_transfer_complete_or_seeding());
assert!(!snapshot.transfer_complete());
}
#[test]
fn progress_snapshot_completion_percent_milli_caps_completed_length() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x44), DownloadStatus::Active);
snapshot.total_length = 2_000;
snapshot.completed_length = 2_500;
assert_eq!(snapshot.remaining_length(), 0);
assert_eq!(snapshot.completion_percent_milli(), 1000);
snapshot.total_length = 0;
assert_eq!(snapshot.completion_percent_milli(), 0);
}
#[test]
fn progress_snapshot_bt_runtime_metrics_report_activity() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x45), DownloadStatus::Active);
snapshot.bt_total_peers = 2;
snapshot.bt_seeders = 1;
snapshot.bt_leechers = 1;
snapshot.bt_available_pieces = 3;
snapshot.bt_downloading_pieces = 2;
snapshot.bt_queued_pieces = 1;
snapshot.bt_missing_pieces = 4;
assert!(snapshot.bt_has_swarm_activity());
assert_eq!(snapshot.bt_active_piece_count(), 3);
assert_eq!(snapshot.bt_seeders + snapshot.bt_leechers, 2);
}
#[test]
fn progress_snapshot_bt_share_runtime_helpers_report_true_seeding() {
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x46), DownloadStatus::Complete);
snapshot.completed_length = 4_096;
snapshot.share_ratio_milli = Some(1250);
snapshot.share_time_secs = Some(120);
snapshot.seeding_time_secs = Some(90);
snapshot.bt_selected_payload_length = 4_096;
snapshot.bt_remaining_payload_length = 0;
snapshot.bt_true_seeding = true;
snapshot.seeding = true;
assert!(snapshot.bt_has_share_runtime());
assert!(snapshot.bt_payload_complete());
assert!(snapshot.bt_transfer_complete_or_seeding());
assert!(snapshot.bt_true_seeding);
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Request, segment, and BitTorrent runtime state models.
#[cfg(test)]
use std::collections::BTreeMap;
#[cfg(test)]
use crate::piece::{PieceId, PieceRange, PieceState};
/// `BitTorrent` runtime metadata, peer state, and mutation result types.
mod bt;
/// Ordered URI lists, headers, and request metadata for one download.
mod context;
/// Request-group state and request/BT helper submodules.
mod group;
/// Stable download identifiers, statuses, and resume metadata.
mod identity;
/// Segment-assignment models and aggregated segment runtime counters.
mod segment;
#[expect(
clippy::redundant_pub_crate,
reason = "these BT helper types stay crate-internal while sibling modules import them through crate::request"
)]
pub(crate) use self::bt::{
BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceBlockUpdate,
BtPieceMutationResult, BtRuntimeTickResult, BtShareRuntimeState,
};
pub use self::{
bt::{
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtPressureSnapshot, BtRuntimeState,
BtTrackerInfo,
},
context::RequestContext,
group::RequestGroup,
identity::{DownloadId, DownloadStatus, ResumeState, RetryAttempt},
segment::{SegmentAssignment, SegmentRuntimeStats, SegmentState},
};
#[cfg(test)]
mod request_tests;
@@ -0,0 +1,414 @@
use std::collections::BTreeMap;
use crate::piece::{PieceId, PieceState};
/// File entry exposed by torrent metadata and BT RPC responses.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtFileInfo {
/// Output path or logical file name for the torrent entry.
pub path: String,
/// Declared file length in bytes.
pub length: u64,
/// Piece-aligned offset where this file begins, when known.
pub piece_offset: Option<u64>,
/// Whether the file is selected for download.
pub selected: bool,
}
impl BtFileInfo {
/// Returns whether the torrent file is selected for transfer.
#[must_use]
pub const fn is_selected(&self) -> bool {
self.selected
}
}
/// Tracker entry associated with a torrent.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtTrackerInfo {
/// Tracker announce URL.
pub url: String,
/// Optional tracker tier index.
pub tier: Option<u32>,
/// Optional tracker identifier reported by the server.
pub id: Option<String>,
/// Seeder count reported by the tracker, when available.
pub seeders: Option<u32>,
/// Leecher count reported by the tracker, when available.
pub leechers: Option<u32>,
}
/// Peer entry associated with BT swarm runtime state.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtPeerInfo {
/// Optional peer ID from the handshake.
pub peer_id: Option<String>,
/// Peer IP address or host.
pub ip: String,
/// Peer listening port.
pub port: u16,
/// Optional peer client identification string.
pub client_name: Option<String>,
/// Whether the peer is interested in our pieces.
pub interested: bool,
/// Whether the peer currently chokes us.
pub choked: bool,
/// Reported or inferred peer-to-local download speed.
pub download_speed: u64,
/// Reported or inferred local-to-peer upload speed.
pub upload_speed: u64,
/// Whether the peer appears to have the full payload.
pub seeder: bool,
}
/// Piece block completion update emitted by the BT runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceBlockUpdate {
/// Piece being updated.
pub piece_id: PieceId,
/// Number of completed blocks inside the piece.
pub completed_blocks: u32,
/// Total number of blocks in the piece.
pub total_blocks: u32,
}
/// Piece availability update emitted by the BT runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceAvailabilityUpdate {
/// Piece being updated.
pub piece_id: PieceId,
/// Number of peers advertising the piece.
pub peers_with_piece: u32,
}
/// Result of applying one piece state transition.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceMutationResult {
/// Change in verified completed length caused by the mutation.
pub completed_length_delta: i64,
/// Whether the piece transitioned into the verified state.
pub transitioned_to_verified: bool,
/// Previous piece state, if one existed.
pub previous_state: Option<PieceState>,
/// Resulting piece state after the mutation.
pub next_state: PieceState,
/// Byte span covered by the piece.
pub piece_span_length: u64,
/// Number of completed blocks after the mutation.
pub completed_blocks: u32,
/// Total number of blocks in the piece.
pub total_blocks: u32,
/// Block completion ratio in thousandths.
pub block_completion_milli: u64,
}
impl Default for BtPieceMutationResult {
fn default() -> Self {
Self {
completed_length_delta: 0,
transitioned_to_verified: false,
previous_state: None,
next_state: PieceState::Pending,
piece_span_length: 0,
completed_blocks: 0,
total_blocks: 0,
block_completion_milli: 0,
}
}
}
/// Result of applying one piece availability update.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtPieceAvailabilityMutationResult {
/// Peer count currently advertising the piece.
pub peers_with_piece: u32,
/// Number of pieces currently available from at least one peer.
pub available_piece_count: usize,
/// Whether the updated piece is requestable right now.
pub piece_is_requestable: bool,
/// Whether the updated piece is already verified locally.
pub piece_is_verified: bool,
}
/// Aggregated swarm counters after a peer mutation.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtPeerMutationResult {
/// Total connected peer count after the mutation.
pub peer_count: usize,
/// Seeder count after the mutation.
pub seeder_count: usize,
/// Leecher count after the mutation.
pub leecher_count: usize,
/// Aggregate download speed after the mutation.
pub total_download_speed: u64,
/// Aggregate upload speed after the mutation.
pub total_upload_speed: u64,
/// Whether the mutation replaced an existing peer entry.
pub replaced_existing: bool,
}
/// Transfer and seeding counters observed for one BT runtime tick.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtRuntimeTickResult {
/// Completed payload length after the tick.
pub completed_length: u64,
/// Uploaded payload length after the tick.
pub upload_length: u64,
/// Download speed observed during the tick.
pub download_speed: u64,
/// Upload speed observed during the tick.
pub upload_speed: u64,
/// Number of active peer connections.
pub num_connections: u32,
/// Whether the torrent is currently seeding.
pub seeding: bool,
/// Share ratio in thousandths, when it can be derived.
pub share_ratio_milli: Option<u64>,
/// Total share time in seconds.
pub share_time_secs: u64,
/// Total seeding time in seconds.
pub seeding_time_secs: u64,
}
/// Snapshot used by BT heuristics and diagnostics to describe swarm pressure.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtPressureSnapshot {
/// Total piece count in the torrent.
pub total_pieces: usize,
/// Number of pieces currently requestable by the local client.
pub requestable_pieces: usize,
/// Number of pieces actively being worked on.
pub active_pieces: usize,
/// Number of requestable pieces that at least one peer can serve.
pub available_requestable_pieces: usize,
/// Number of requestable pieces served by very few peers.
pub scarce_requestable_pieces: usize,
/// Total connected peer count.
pub peer_count: usize,
/// Number of peers believed to be complete seeders.
pub seeder_count: usize,
/// Number of peers still downloading pieces.
pub leecher_count: usize,
}
/// BitTorrent-specific metadata and swarm state attached to a request group.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct BtRuntimeState {
/// Uppercase hexadecimal torrent info hash.
pub info_hash: String,
/// Optional torrent display name.
pub name: Option<String>,
/// Original magnet URI when the torrent was bootstrapped from magnet.
pub magnet_uri: Option<String>,
/// Whether the runtime is still waiting for full torrent metadata.
pub metadata_only: bool,
/// Total `.torrent` metadata size announced through BEP 10 / BEP 9, when known.
pub metadata_size: Option<u32>,
/// Known per-peer `ut_metadata` extension ids keyed by `host:port`.
pub metadata_extension_ids: BTreeMap<String, u8>,
/// Buffered metadata pieces keyed by metadata piece index.
pub metadata_piece_payloads: BTreeMap<u32, Vec<u8>>,
/// Optional torrent creation date string.
pub creation_date: Option<String>,
/// Optional torrent comment string.
pub comment: Option<String>,
/// Known DHT bootstrap or discovered nodes.
pub dht_nodes: Vec<String>,
/// Torrent file entries.
pub files: Vec<BtFileInfo>,
/// Tracker entries associated with the torrent.
pub trackers: Vec<BtTrackerInfo>,
/// Connected or recently seen peers.
pub peers: Vec<BtPeerInfo>,
}
impl BtRuntimeState {
/// Returns the current DHT node list associated with the torrent runtime.
#[must_use]
pub fn dht_nodes(&self) -> &[String] {
&self.dht_nodes
}
/// Returns the torrent file entries currently attached to the runtime state.
#[must_use]
pub fn files(&self) -> &[BtFileInfo] {
&self.files
}
/// Iterates over torrent file entries that are currently selected.
pub fn selected_files(&self) -> impl Iterator<Item = &BtFileInfo> + '_ {
self.files.iter().filter(|file| file.is_selected())
}
/// Returns the number of torrent file entries currently selected.
#[must_use]
pub fn selected_file_count(&self) -> usize {
self.selected_files().count()
}
/// Returns the sum of selected torrent file lengths.
#[must_use]
pub fn selected_total_length(&self) -> u64 {
self.selected_files()
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
}
/// Returns whether at least one torrent file entry is selected.
#[must_use]
pub fn has_selected_files(&self) -> bool {
self.files.iter().any(BtFileInfo::is_selected)
}
/// Returns the selected total length, or the full torrent length when nothing is selected.
#[must_use]
pub fn selected_or_all_total_length(&self) -> u64 {
if self.files.is_empty() {
return 0;
}
let selected = self.selected_total_length();
if selected > 0 {
selected
} else {
self.files
.iter()
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
}
}
}
/// Mutable seeding and share-ratio counters for one torrent session.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct BtShareRuntimeState {
/// Whether the runtime currently considers the torrent to be seeding.
pub seeding: bool,
/// Share ratio in thousandths, when derivable.
pub share_ratio_milli: Option<u64>,
/// Total share time in seconds.
pub share_time_secs: u64,
/// Total seeding time in seconds.
pub seeding_time_secs: u64,
/// Wall-clock second when seeding most recently began.
pub seeding_started_at_secs: Option<u64>,
/// Wall-clock second of the last runtime tick update.
pub last_runtime_tick_secs: Option<u64>,
}
impl BtShareRuntimeState {
/// Builds a zeroed BT share-state snapshot.
#[must_use]
pub const fn new() -> Self {
Self {
seeding: false,
share_ratio_milli: None,
share_time_secs: 0,
seeding_time_secs: 0,
seeding_started_at_secs: None,
last_runtime_tick_secs: None,
}
}
/// Returns whether the torrent is currently in a seeding state.
#[must_use]
pub const fn is_seeding(&self) -> bool {
self.seeding
}
/// Updates the seeding flag and clears the start timestamp when seeding stops.
pub fn set_seeding(&mut self, value: bool) {
self.seeding = value;
if !value {
self.seeding_started_at_secs = None;
}
}
/// Returns the current share ratio in thousandths, when it is known.
#[must_use]
pub const fn share_ratio_milli(&self) -> Option<u64> {
self.share_ratio_milli
}
/// Sets the current share ratio in thousandths.
pub fn set_share_ratio_milli(&mut self, value: Option<u64>) {
self.share_ratio_milli = value;
}
/// Returns the total accumulated share time in seconds.
#[must_use]
pub const fn share_time_secs(&self) -> u64 {
self.share_time_secs
}
/// Overwrites the total accumulated share time in seconds.
pub fn set_share_time_secs(&mut self, value: u64) {
self.share_time_secs = value;
}
/// Adds to the accumulated share time using saturating arithmetic.
pub fn add_share_time_secs(&mut self, delta: u64) {
self.share_time_secs = self.share_time_secs.saturating_add(delta);
}
/// Returns the total accumulated seeding time in seconds.
#[must_use]
pub const fn seeding_time_secs(&self) -> u64 {
self.seeding_time_secs
}
/// Overwrites the accumulated seeding time in seconds.
pub fn set_seeding_time_secs(&mut self, value: u64) {
self.seeding_time_secs = value;
}
/// Adds to the accumulated seeding time using saturating arithmetic.
pub fn add_seeding_time_secs(&mut self, delta: u64) {
self.seeding_time_secs = self.seeding_time_secs.saturating_add(delta);
}
/// Starts seeding bookkeeping at the provided unix timestamp.
pub fn start_seeding(&mut self, at_unix_secs: u64) {
if self.seeding {
self.last_runtime_tick_secs = Some(at_unix_secs);
return;
}
self.seeding = true;
self.seeding_started_at_secs = Some(at_unix_secs);
self.last_runtime_tick_secs = Some(at_unix_secs);
}
/// Stops seeding bookkeeping after first accounting for elapsed runtime.
pub fn stop_seeding(&mut self, at_unix_secs: u64) {
self.tick_runtime(at_unix_secs);
self.seeding = false;
self.seeding_started_at_secs = None;
}
/// Advances share and seeding runtime counters to the provided unix timestamp.
pub fn tick_runtime(&mut self, now_unix_secs: u64) {
let Some(last_tick) = self.last_runtime_tick_secs else {
self.last_runtime_tick_secs = Some(now_unix_secs);
return;
};
let delta = now_unix_secs.saturating_sub(last_tick);
self.last_runtime_tick_secs = Some(now_unix_secs);
self.share_time_secs = self.share_time_secs.saturating_add(delta);
if self.seeding {
self.seeding_time_secs = self.seeding_time_secs.saturating_add(delta);
}
}
/// Derives a share ratio in thousandths from uploaded and completed byte counts.
#[must_use]
pub fn derive_share_ratio_milli(uploaded: u64, completed_base: u64) -> Option<u64> {
if completed_base == 0 {
return None;
}
uploaded.saturating_mul(1000).checked_div(completed_base)
}
/// Refreshes the stored share ratio using the provided length counters.
pub fn refresh_share_ratio_from_lengths(&mut self, uploaded: u64, completed_base: u64) {
self.share_ratio_milli = Self::derive_share_ratio_milli(uploaded, completed_base);
}
}
@@ -0,0 +1,117 @@
use std::collections::BTreeSet;
/// Source URIs and request headers associated with a download group.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RequestContext {
/// Optional origin label describing how the request was seeded.
pub source: Option<String>,
/// Primary URI shown on RPC and CLI surfaces.
pub uri: String,
/// Full ordered URI list associated with the request.
pub uris: Vec<String>,
/// Optional HTTP referer applied to outbound requests.
pub referer: Option<String>,
/// Additional request headers carried with the request.
pub headers: Vec<(String, String)>,
/// Optional higher-level group identifier from imported session/config data.
pub group_id: Option<String>,
/// Optional diagnostic or migration note carried with the request.
pub note: Option<String>,
}
impl RequestContext {
/// Builds a request context seeded with one primary URI candidate.
#[must_use]
pub fn new(uri: impl Into<String>) -> Self {
let uris = Self::normalize_uris(vec![uri.into()]);
let uri = uris.first().cloned().unwrap_or_default();
Self {
source: None,
uri,
uris,
referer: None,
headers: Vec::new(),
group_id: None,
note: None,
}
}
/// Returns the primary URI currently exposed for the request.
#[must_use]
pub fn uri(&self) -> &str {
&self.uri
}
/// Returns the ordered URI list associated with the request.
#[must_use]
pub fn uris(&self) -> &[String] {
&self.uris
}
/// Replaces the full URI list after normalizing blank entries and duplicates.
pub fn replace_uris(&mut self, uris: Vec<String>) {
self.uris = Self::normalize_uris(uris);
self.sync_primary_uri();
}
/// Appends a URI to the end of the ordered candidate list.
pub fn append_uri(&mut self, uri: impl Into<String>) {
self.insert_uri(self.uris.len(), uri);
}
/// Inserts or repositions a URI at the requested index.
pub fn insert_uri(&mut self, index: usize, uri: impl Into<String>) {
let Some(uri) = Self::normalize_uri(uri.into()) else {
return;
};
let mut index = index.min(self.uris.len());
if let Some(existing_index) = self.uris.iter().position(|current| current == &uri) {
self.uris.remove(existing_index);
if existing_index < index {
index = index.saturating_sub(1);
}
}
self.uris.insert(index, uri);
self.sync_primary_uri();
}
/// Removes the first URI exactly matching the provided string.
pub fn remove_first_matching_uri(&mut self, uri: &str) -> bool {
if let Some(index) = self.uris.iter().position(|current| current == uri) {
self.uris.remove(index);
self.sync_primary_uri();
return true;
}
false
}
/// Adds one request header pair to the context.
pub fn push_header(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.headers.push((key.into(), value.into()));
}
/// Normalizes an ordered URI list by dropping blank entries and duplicates.
fn normalize_uris(uris: Vec<String>) -> Vec<String> {
let mut normalized = Vec::with_capacity(uris.len());
let mut seen = BTreeSet::new();
for uri in uris {
let Some(uri) = Self::normalize_uri(uri) else {
continue;
};
if seen.insert(uri.clone()) {
normalized.push(uri);
}
}
normalized
}
/// Returns `Some(uri)` when the supplied URI text is non-blank after trimming.
fn normalize_uri(uri: String) -> Option<String> {
(!uri.trim().is_empty()).then_some(uri)
}
/// Synchronizes the primary URI field with the first normalized URI entry.
fn sync_primary_uri(&mut self) {
self.uri = self.uris.first().cloned().unwrap_or_default();
}
}
@@ -0,0 +1,153 @@
#![expect(
clippy::arithmetic_side_effects,
reason = "request state uses compact counter math over bounded scheduler/runtime fields"
)]
pub(super) use std::collections::{BTreeMap, BTreeSet};
pub(super) use crate::{
options::{OptionKey, OptionPatch, OptionValue},
piece::{PieceId, PieceMap, PieceRange, PieceState},
runtime::parse_human_size_text,
};
pub(super) use super::{
BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate,
BtPieceBlockUpdate, BtPieceMutationResult, BtPressureSnapshot, BtRuntimeState,
BtRuntimeTickResult, BtShareRuntimeState, DownloadId, DownloadStatus, RequestContext,
ResumeState, RetryAttempt, SegmentAssignment, SegmentRuntimeStats, SegmentState,
};
/// `BitTorrent` peer snapshot and peer-mutation helpers for a request group.
mod bt_peers;
/// `BitTorrent` piece availability, verification, and pressure helpers.
mod bt_pieces;
/// `BitTorrent` share-ratio, share-time, and seeding-time helpers.
mod bt_share;
/// Core `RequestGroup` data model and field layout.
mod model;
/// General request-group state mutation, accessors, and option helpers.
mod state;
pub use self::model::RequestGroup;
impl RequestGroup {
/// Returns the byte span covered by a piece after clamping the tail piece to the target length.
fn bt_piece_span_length(&self, piece: PieceId) -> u64 {
let piece_length = self.piece_length.max(1);
let start = u64::from(piece.0).saturating_mul(piece_length);
match self.bt_effective_target_length() {
Some(target) if target > 0 => target.saturating_sub(start).min(piece_length),
_ => piece_length,
}
}
/// Applies a piece-state transition and refreshes completion and share-ratio counters accordingly.
fn bt_set_piece_state_and_refresh_completion(
&mut self,
piece: PieceId,
next_state: PieceState,
) -> BtPieceMutationResult {
let previous = self.piece_state(piece);
let span = self.bt_piece_span_length(piece);
if previous != Some(next_state) {
self.set_piece_state(piece, next_state);
}
let was_verified = matches!(previous, Some(PieceState::Verified));
let is_verified = next_state == PieceState::Verified;
let mut delta = 0_i64;
let signed_span = i64::try_from(span).unwrap_or(i64::MAX);
if previous != Some(next_state) && !was_verified && is_verified {
self.add_completed_length(span);
delta = signed_span;
} else if previous != Some(next_state) && was_verified && !is_verified {
self.completed_length = self.completed_length.saturating_sub(span);
delta = signed_span.saturating_neg();
}
self.refresh_bt_share_ratio_from_lengths();
BtPieceMutationResult {
completed_length_delta: delta,
transitioned_to_verified: !was_verified && is_verified,
previous_state: previous,
next_state,
piece_span_length: span,
completed_blocks: 0,
total_blocks: 0,
block_completion_milli: 0,
}
}
/// Builds the final mutation result for a block-progress update after applying the new piece state.
fn bt_apply_piece_progress_result(
&mut self,
update: BtPieceBlockUpdate,
next_state: PieceState,
) -> BtPieceMutationResult {
let mut result =
self.bt_set_piece_state_and_refresh_completion(update.piece_id, next_state);
result.completed_blocks = update.completed_blocks.min(update.total_blocks);
result.total_blocks = update.total_blocks;
result.block_completion_milli =
Self::bt_block_completion_milli(result.completed_blocks, result.total_blocks);
result
}
/// Converts completed block counts into a per-thousand completion ratio for UI and RPC reporting.
fn bt_block_completion_milli(completed_blocks: u32, total_blocks: u32) -> u64 {
if total_blocks == 0 {
return 0;
}
u64::from(completed_blocks.min(total_blocks))
.saturating_mul(1000)
.saturating_div(u64::from(total_blocks))
}
/// Recomputes the active `BitTorrent` share ratio from the latest uploaded and base lengths.
fn refresh_bt_share_ratio_from_lengths(&mut self) {
let Some(denominator) = self.bt_share_ratio_base_length() else {
return;
};
if let Some(share_state) = self.bt_share_state.as_mut() {
share_state.refresh_share_ratio_from_lengths(self.upload_length, denominator);
}
}
/// Mirrors the current peer count into the generic connection counter exposed by the request group.
fn sync_bt_num_connections_to_peer_count(&mut self) {
let peer_count = self.bt.as_ref().map_or(0, |bt| bt.peers.len());
self.num_connections = u32::try_from(peer_count).unwrap_or(u32::MAX);
}
/// Produces aggregate peer counters and bandwidth totals after a peer mutation step.
fn bt_peer_runtime_stats_with_replaced(&self, replaced_existing: bool) -> BtPeerMutationResult {
let Some(bt) = self.bt() else {
return BtPeerMutationResult::default();
};
let peer_count = bt.peers.len();
let seeder_count = bt.peers.iter().filter(|peer| peer.seeder).count();
BtPeerMutationResult {
peer_count,
seeder_count,
leecher_count: peer_count.saturating_sub(seeder_count),
total_download_speed: bt.peers.iter().map(|peer| peer.download_speed).sum(),
total_upload_speed: bt.peers.iter().map(|peer| peer.upload_speed).sum(),
replaced_existing,
}
}
/// Snapshots the current `BitTorrent` runtime counters for periodic scheduler and RPC updates.
fn bt_runtime_tick_result(&self) -> BtRuntimeTickResult {
let share_state = self.bt_share_state();
BtRuntimeTickResult {
completed_length: self.completed_length(),
upload_length: self.upload_length(),
download_speed: self.download_speed(),
upload_speed: self.upload_speed(),
num_connections: self.num_connections(),
seeding: self.bt_is_true_seeding(),
share_ratio_milli: self.bt_share_ratio_milli(),
share_time_secs: share_state.map_or(0, BtShareRuntimeState::share_time_secs),
seeding_time_secs: share_state.map_or(0, BtShareRuntimeState::seeding_time_secs),
}
}
}
@@ -0,0 +1,107 @@
#![expect(
missing_docs,
reason = "RequestGroup BT peer helpers keep the established compatibility facade while isolating peer-state logic"
)]
use super::{BtPeerInfo, BtPeerMutationResult, BtRuntimeState, RequestGroup};
impl RequestGroup {
#[must_use]
pub fn bt(&self) -> Option<&BtRuntimeState> {
self.bt.as_ref()
}
pub fn bt_mut(&mut self) -> Option<&mut BtRuntimeState> {
self.bt.as_mut()
}
pub fn set_bt(&mut self, bt: BtRuntimeState) {
let had_bt = self.bt.is_some();
self.bt = Some(bt);
if had_bt {
self.refresh_bt_share_ratio_from_lengths();
}
}
pub fn clear_bt(&mut self) {
self.bt = None;
}
pub fn replace_bt_peer_snapshot(&mut self, peers: Vec<BtPeerInfo>) -> BtPeerMutationResult {
let Some(bt) = self.bt_mut() else {
return BtPeerMutationResult::default();
};
bt.peers = peers;
self.sync_bt_num_connections_to_peer_count();
self.bt_peer_runtime_stats()
}
#[must_use]
pub fn bt_peer_runtime_stats(&self) -> BtPeerMutationResult {
self.bt_peer_runtime_stats_with_replaced(false)
}
pub fn apply_bt_peer_update(&mut self, peer: BtPeerInfo) -> BtPeerMutationResult {
let Some(bt) = self.bt_mut() else {
return BtPeerMutationResult::default();
};
let key_peer_id = peer.peer_id.as_deref();
let key_ip = peer.ip.as_str();
let key_port = peer.port;
let replaced_existing = if let Some(existing) = bt.peers.iter_mut().find(|candidate| {
(key_peer_id.is_some() && candidate.peer_id.as_deref() == key_peer_id)
|| (candidate.ip == key_ip && candidate.port == key_port)
}) {
*existing = peer;
true
} else {
bt.peers.push(peer);
false
};
self.sync_bt_num_connections_to_peer_count();
self.bt_peer_runtime_stats_with_replaced(replaced_existing)
}
pub fn remove_bt_peer(
&mut self,
peer_id: Option<&str>,
ip: Option<&str>,
port: Option<u16>,
) -> bool {
let Some(bt) = self.bt_mut() else {
return false;
};
let before = bt.peers.len();
bt.peers.retain(|peer| {
let peer_id_match = peer_id.is_some() && peer.peer_id.as_deref() == peer_id;
let endpoint_match = matches!(
(ip, port),
(Some(expected_ip), Some(expected_port))
if peer.ip == expected_ip && peer.port == expected_port
);
!(peer_id_match || endpoint_match)
});
let removed = bt.peers.len() != before;
if removed {
self.sync_bt_num_connections_to_peer_count();
}
removed
}
#[must_use]
pub fn bt_selected_file_count(&self) -> Option<usize> {
self.bt.as_ref().map(BtRuntimeState::selected_file_count)
}
#[must_use]
pub fn bt_selected_total_length(&self) -> Option<u64> {
self.bt.as_ref().map(BtRuntimeState::selected_total_length)
}
#[must_use]
pub fn bt_has_selected_files(&self) -> bool {
self.bt
.as_ref()
.is_some_and(BtRuntimeState::has_selected_files)
}
}
@@ -0,0 +1,166 @@
#![expect(
missing_docs,
reason = "RequestGroup BT piece helpers keep piece-selection behavior stable while isolating swarm-facing state logic"
)]
use super::{
BTreeSet, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate, BtPieceBlockUpdate,
BtPieceMutationResult, BtPressureSnapshot, PieceId, PieceState, RequestGroup,
};
impl RequestGroup {
#[must_use]
pub fn piece_availability(&self) -> &std::collections::BTreeMap<PieceId, u32> {
&self.piece_availability
}
pub fn apply_bt_piece_availability_update(
&mut self,
update: BtPieceAvailabilityUpdate,
) -> BtPieceAvailabilityMutationResult {
if update.peers_with_piece == 0 {
self.piece_availability.remove(&update.piece_id);
} else {
self.piece_availability
.insert(update.piece_id, update.peers_with_piece);
}
let piece_state = self.piece_state(update.piece_id);
BtPieceAvailabilityMutationResult {
peers_with_piece: update.peers_with_piece,
available_piece_count: self.bt_available_piece_count(),
piece_is_requestable: matches!(
piece_state,
Some(PieceState::Pending | PieceState::Queued | PieceState::Missing)
) && update.peers_with_piece > 0,
piece_is_verified: piece_state == Some(PieceState::Verified),
}
}
pub fn clear_piece_availability(&mut self) {
self.piece_availability.clear();
}
pub fn apply_bt_piece_block_update(
&mut self,
update: BtPieceBlockUpdate,
) -> BtPieceMutationResult {
if update.total_blocks == 0 {
return self.bt_apply_piece_progress_result(update, PieceState::Missing);
}
if update.completed_blocks >= update.total_blocks {
return self.bt_apply_piece_progress_result(update, PieceState::Verified);
}
if update.completed_blocks > 0 {
return self.bt_apply_piece_progress_result(update, PieceState::Downloading);
}
self.bt_apply_piece_progress_result(update, PieceState::Queued)
}
pub fn mark_bt_piece_verified(&mut self, piece: PieceId) -> BtPieceMutationResult {
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Verified)
}
pub fn mark_bt_piece_missing(&mut self, piece: PieceId) -> BtPieceMutationResult {
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Missing)
}
pub fn mark_bt_piece_downloading(&mut self, piece: PieceId) -> BtPieceMutationResult {
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Downloading)
}
#[must_use]
pub fn piece_state_counts(&self) -> (usize, usize, usize, usize, usize, usize) {
let mut pending = 0;
let mut queued = 0;
let mut downloading = 0;
let mut verified = 0;
let mut missing = 0;
let mut skipped = 0;
for (_, state) in self.pieces.iter() {
match state {
PieceState::Pending => pending += 1,
PieceState::Queued => queued += 1,
PieceState::Downloading => downloading += 1,
PieceState::Verified => verified += 1,
PieceState::Missing => missing += 1,
PieceState::Skipped => skipped += 1,
}
}
(pending, queued, downloading, verified, missing, skipped)
}
#[must_use]
pub fn bt_verified_piece_count(&self) -> usize {
self.pieces
.iter()
.filter(|(_, state)| matches!(state, PieceState::Verified))
.count()
}
#[must_use]
pub fn bt_requestable_piece_ids(&self, endgame: bool, limit: usize) -> Vec<PieceId> {
if limit == 0 {
return Vec::new();
}
let mut primary = BTreeSet::new();
let mut endgame_candidates = BTreeSet::new();
for (piece_id, state) in self.pieces.iter() {
match state {
PieceState::Pending | PieceState::Queued | PieceState::Missing => {
primary.insert(*piece_id);
}
PieceState::Downloading if endgame => {
endgame_candidates.insert(*piece_id);
}
PieceState::Verified | PieceState::Skipped | PieceState::Downloading => {}
}
}
let mut selected = Vec::with_capacity(limit);
selected.extend(primary.into_iter().take(limit));
if selected.len() < limit {
selected.extend(endgame_candidates.into_iter().take(limit - selected.len()));
}
selected
}
#[must_use]
pub fn bt_available_piece_count(&self) -> usize {
self.piece_availability
.iter()
.filter(|(_, peers)| **peers > 0)
.count()
}
#[must_use]
pub fn bt_pressure_snapshot(&self) -> Option<BtPressureSnapshot> {
self.bt.as_ref()?;
let piece_count = self.pieces.iter().count();
let requestable = self.bt_requestable_piece_ids(false, piece_count.max(1));
let (_, queued, downloading, _, _, _) = self.piece_state_counts();
let peer_stats = self.bt_peer_runtime_stats();
let mut available_requestable_pieces = 0;
let mut scarce_requestable_pieces = 0;
for piece_id in &requestable {
let peers = self.piece_availability.get(piece_id).copied().unwrap_or(0);
if peers > 0 {
available_requestable_pieces += 1;
}
if peers > 0 && peers <= 1 {
scarce_requestable_pieces += 1;
}
}
Some(BtPressureSnapshot {
total_pieces: piece_count,
requestable_pieces: requestable.len(),
active_pieces: downloading.saturating_add(queued),
available_requestable_pieces,
scarce_requestable_pieces,
peer_count: peer_stats.peer_count,
seeder_count: peer_stats.seeder_count,
leecher_count: peer_stats.leecher_count,
})
}
}
@@ -0,0 +1,177 @@
#![expect(
missing_docs,
reason = "RequestGroup BT share/runtime helpers keep seeding counters and ratio semantics stable while isolating runtime bookkeeping"
)]
use super::{BtRuntimeTickResult, BtShareRuntimeState, RequestGroup};
impl RequestGroup {
#[must_use]
pub fn bt_share_state(&self) -> Option<&BtShareRuntimeState> {
self.bt_share_state.as_ref()
}
pub fn bt_share_state_mut(&mut self) -> Option<&mut BtShareRuntimeState> {
self.bt_share_state.as_mut()
}
pub fn set_bt_share_state(&mut self, state: BtShareRuntimeState) {
self.bt_share_state = Some(state);
}
pub fn clear_bt_share_state(&mut self) {
self.bt_share_state = None;
}
#[must_use]
pub fn bt_is_seeding(&self) -> bool {
self.bt_share_state
.as_ref()
.is_some_and(BtShareRuntimeState::is_seeding)
}
#[must_use]
pub fn bt_share_ratio_milli(&self) -> Option<u64> {
self.bt_share_state
.as_ref()
.and_then(BtShareRuntimeState::share_ratio_milli)
}
#[must_use]
pub fn bt_share_time_secs(&self) -> Option<u64> {
self.bt_share_state
.as_ref()
.map(BtShareRuntimeState::share_time_secs)
}
#[must_use]
pub fn bt_seeding_time_secs(&self) -> Option<u64> {
self.bt_share_state
.as_ref()
.map(BtShareRuntimeState::seeding_time_secs)
}
#[must_use]
pub fn bt_share_ratio_base_length(&self) -> Option<u64> {
let target = self.bt_effective_target_length()?;
if target == 0 {
return Some(0);
}
Some(target.max(self.completed_length))
}
#[must_use]
pub fn bt_effective_target_length(&self) -> Option<u64> {
let bt = self.bt.as_ref()?;
if bt.metadata_only {
return Some(0);
}
let selected_or_all = bt.selected_or_all_total_length();
if selected_or_all == 0 {
return Some(self.total_length);
}
if self.total_length == 0 {
Some(selected_or_all)
} else {
Some(selected_or_all.min(self.total_length))
}
}
#[must_use]
pub fn bt_remaining_work_length(&self) -> Option<u64> {
let target = self.bt_effective_target_length()?;
Some(target.saturating_sub(self.completed_length.min(target)))
}
#[must_use]
pub fn bt_is_true_seeding(&self) -> bool {
self.bt_is_seeding()
&& matches!(self.bt_effective_target_length(), Some(target) if target > 0)
&& self.bt_remaining_work_length() == Some(0)
}
pub fn ensure_bt_share_state(&mut self) -> Option<&mut BtShareRuntimeState> {
self.bt.as_ref()?;
if self.bt_share_state.is_none() {
self.bt_share_state = Some(BtShareRuntimeState::default());
}
self.bt_share_state.as_mut()
}
pub fn refresh_bt_share_runtime(&mut self) -> BtRuntimeTickResult {
self.refresh_bt_share_ratio_from_lengths();
self.bt_runtime_tick_result()
}
pub fn set_bt_seeding_state(
&mut self,
seeding: bool,
at_unix_secs: Option<u64>,
) -> BtRuntimeTickResult {
if let Some(share_state) = self.ensure_bt_share_state() {
match (seeding, at_unix_secs) {
(true, Some(now)) => share_state.start_seeding(now),
(false, Some(now)) => share_state.stop_seeding(now),
(value, None) => share_state.set_seeding(value),
}
}
self.refresh_bt_share_runtime()
}
pub fn tick_bt_runtime_clock(
&mut self,
now_unix_secs: u64,
seeding: bool,
) -> BtRuntimeTickResult {
if let Some(share_state) = self.ensure_bt_share_state() {
if share_state.is_seeding() != seeding {
if seeding {
share_state.start_seeding(now_unix_secs);
} else {
share_state.stop_seeding(now_unix_secs);
}
} else {
share_state.tick_runtime(now_unix_secs);
}
}
self.refresh_bt_share_runtime()
}
#[expect(
clippy::too_many_arguments,
reason = "BT runtime tick input mirrors the grouped counters provided by the dispatcher"
)]
pub fn apply_bt_runtime_tick(
&mut self,
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>,
) -> BtRuntimeTickResult {
if downloaded_delta > 0 {
self.add_completed_length(downloaded_delta);
}
if uploaded_delta > 0 {
self.set_upload_length(self.upload_length().saturating_add(uploaded_delta));
}
self.set_download_speed(download_speed);
self.set_upload_speed(upload_speed);
if let Some(num_connections) = num_connections {
self.set_num_connections(num_connections);
}
if let Some(share_state) = self.ensure_bt_share_state() {
share_state.set_seeding(seeding);
if share_time_delta_secs > 0 {
share_state.add_share_time_secs(share_time_delta_secs);
}
if seeding_time_delta_secs > 0 {
share_state.add_seeding_time_secs(seeding_time_delta_secs);
}
}
self.refresh_bt_share_runtime()
}
}
@@ -0,0 +1,51 @@
use super::{
BTreeMap, BtRuntimeState, BtShareRuntimeState, DownloadId, DownloadStatus, OptionPatch,
PieceId, PieceMap, RequestContext, ResumeState, RetryAttempt, SegmentAssignment,
};
/// Canonical in-memory request group model used by the runtime and RPC layers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RequestGroup {
/// Stable identifier exposed on the RPC surface.
pub(super) gid: DownloadId,
/// Source URIs, headers, and request metadata for the transfer.
pub(super) context: RequestContext,
/// Current lifecycle state of the transfer.
pub(super) status: DownloadStatus,
/// Piece map tracking completed and pending ranges.
pub(super) pieces: PieceMap,
/// Per-download option overrides layered over runtime defaults.
pub(super) options: OptionPatch,
/// Total expected payload length in bytes.
pub(super) total_length: u64,
/// Piece size used for segmented scheduling and control-file state.
pub(super) piece_length: u64,
/// Total uploaded payload length in bytes.
pub(super) upload_length: u64,
/// Last observed upload speed in bytes per second.
pub(super) upload_speed: u64,
/// Last observed download speed in bytes per second.
pub(super) download_speed: u64,
/// Number of currently active source connections.
pub(super) num_connections: u32,
/// Verified completed payload length in bytes.
pub(super) completed_length: u64,
/// Monotonic sequence used to order stopped downloads for RPC listing.
pub(super) stopped_sequence: Option<u64>,
/// Number of retry cycles already consumed by this request group.
pub(super) retry_count: u32,
/// Recorded retry attempts for diagnostics and RPC status reporting.
pub(super) retry_attempts: Vec<RetryAttempt>,
/// Resume metadata recovered from storage or prior runtime state.
pub(super) resume_state: Option<ResumeState>,
/// DHT token cached for the next announce-peer exchange.
pub(super) dht_token: Option<Vec<u8>>,
/// Planned and active segment assignments for split transfers.
pub(super) segment_assignments: Vec<SegmentAssignment>,
/// Availability counters for each piece observed from swarm peers.
pub(super) piece_availability: BTreeMap<PieceId, u32>,
/// BitTorrent-specific runtime state when the group is BT-backed.
pub(super) bt: Option<BtRuntimeState>,
/// Seeding and share-ratio counters when the BT runtime is active.
pub(super) bt_share_state: Option<BtShareRuntimeState>,
}
@@ -0,0 +1,345 @@
#![expect(
missing_docs,
reason = "RequestGroup state accessors intentionally keep the aria2-compatible surface flat and stable"
)]
use super::{
DownloadId, DownloadStatus, OptionKey, OptionPatch, OptionValue, PieceId, PieceMap, PieceRange,
PieceState, RequestContext, RequestGroup, ResumeState, RetryAttempt, SegmentAssignment,
SegmentRuntimeStats, SegmentState, parse_human_size_text,
};
impl RequestGroup {
#[must_use]
pub fn new(gid: DownloadId, uri: impl Into<String>) -> Self {
Self {
gid,
context: RequestContext::new(uri),
status: DownloadStatus::Waiting,
pieces: PieceMap::new(),
options: OptionPatch::new(),
total_length: 0,
piece_length: 0,
upload_length: 0,
upload_speed: 0,
download_speed: 0,
num_connections: 0,
completed_length: 0,
stopped_sequence: None,
retry_count: 0,
retry_attempts: Vec::new(),
resume_state: None,
dht_token: None,
segment_assignments: Vec::new(),
piece_availability: std::collections::BTreeMap::new(),
bt: None,
bt_share_state: None,
}
}
#[must_use]
pub fn with_context(gid: DownloadId, context: RequestContext) -> Self {
let mut context = context;
let uris = if context.uris.is_empty() {
vec![context.uri.clone()]
} else {
std::mem::take(&mut context.uris)
};
context.replace_uris(uris);
Self {
gid,
context,
status: DownloadStatus::Waiting,
pieces: PieceMap::new(),
options: OptionPatch::new(),
total_length: 0,
piece_length: 0,
upload_length: 0,
upload_speed: 0,
download_speed: 0,
num_connections: 0,
completed_length: 0,
stopped_sequence: None,
retry_count: 0,
retry_attempts: Vec::new(),
resume_state: None,
dht_token: None,
segment_assignments: Vec::new(),
piece_availability: std::collections::BTreeMap::new(),
bt: None,
bt_share_state: None,
}
}
#[must_use]
pub const fn gid(&self) -> DownloadId {
self.gid
}
#[must_use]
pub fn uri(&self) -> &str {
self.context.uri()
}
#[must_use]
pub fn uris(&self) -> &[String] {
self.context.uris()
}
#[must_use]
pub const fn status(&self) -> &DownloadStatus {
&self.status
}
#[must_use]
pub fn context(&self) -> &RequestContext {
&self.context
}
pub fn context_mut(&mut self) -> &mut RequestContext {
&mut self.context
}
pub fn set_status(&mut self, status: DownloadStatus) {
self.status = status;
}
#[must_use]
pub fn piece_map(&self) -> &PieceMap {
&self.pieces
}
pub fn piece_map_mut(&mut self) -> &mut PieceMap {
&mut self.pieces
}
pub fn set_piece_state(&mut self, piece: PieceId, state: PieceState) {
self.pieces.set_state(piece, state);
}
#[must_use]
pub fn piece_state(&self, piece: PieceId) -> Option<PieceState> {
self.pieces.get(&piece)
}
#[must_use]
pub fn options(&self) -> &OptionPatch {
&self.options
}
pub fn options_mut(&mut self) -> &mut OptionPatch {
&mut self.options
}
pub fn set_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
self.options.insert(key, value);
}
#[must_use]
pub fn option_limit(&self, key: &str) -> Option<u64> {
parse_option_limit(self.options.get(&OptionKey::new(key)))
}
#[must_use]
pub const fn total_length(&self) -> u64 {
self.total_length
}
pub fn set_total_length(&mut self, value: u64) {
self.total_length = value;
self.refresh_bt_share_ratio_from_lengths();
}
#[must_use]
pub const fn piece_length(&self) -> u64 {
self.piece_length
}
pub fn set_piece_length(&mut self, value: u64) {
self.piece_length = value;
}
#[must_use]
pub const fn upload_length(&self) -> u64 {
self.upload_length
}
pub fn set_upload_length(&mut self, value: u64) {
self.upload_length = value;
self.refresh_bt_share_ratio_from_lengths();
}
#[must_use]
pub const fn upload_speed(&self) -> u64 {
self.upload_speed
}
pub fn set_upload_speed(&mut self, value: u64) {
self.upload_speed = value;
}
#[must_use]
pub const fn download_speed(&self) -> u64 {
self.download_speed
}
pub fn set_download_speed(&mut self, value: u64) {
self.download_speed = value;
}
#[must_use]
pub const fn num_connections(&self) -> u32 {
self.num_connections
}
pub fn set_num_connections(&mut self, value: u32) {
self.num_connections = value;
}
#[must_use]
pub const fn completed_length(&self) -> u64 {
self.completed_length
}
pub fn set_completed_length(&mut self, value: u64) {
self.completed_length = value;
self.refresh_bt_share_ratio_from_lengths();
}
pub fn add_completed_length(&mut self, delta: u64) {
self.completed_length = self.completed_length.saturating_add(delta);
self.refresh_bt_share_ratio_from_lengths();
}
#[must_use]
pub const fn stopped_sequence(&self) -> Option<u64> {
self.stopped_sequence
}
pub fn set_stopped_sequence(&mut self, value: Option<u64>) {
self.stopped_sequence = value;
}
#[must_use]
pub const fn retry_count(&self) -> u32 {
self.retry_count
}
pub fn set_retry_count(&mut self, value: u32) {
self.retry_count = value;
}
pub fn increment_retry_count(&mut self) {
self.retry_count = self.retry_count.saturating_add(1);
}
#[must_use]
pub fn retry_attempts(&self) -> &[RetryAttempt] {
&self.retry_attempts
}
pub fn retry_attempts_mut(&mut self) -> &mut Vec<RetryAttempt> {
&mut self.retry_attempts
}
pub fn set_retry_attempts(&mut self, attempts: Vec<RetryAttempt>) {
self.retry_attempts = attempts;
}
pub fn push_retry_attempt(&mut self, attempt: RetryAttempt) {
self.retry_attempts.push(attempt);
}
pub fn clear_retry_attempts(&mut self) {
self.retry_attempts.clear();
}
#[must_use]
pub fn resume_state(&self) -> Option<&ResumeState> {
self.resume_state.as_ref()
}
pub fn resume_state_mut(&mut self) -> Option<&mut ResumeState> {
self.resume_state.as_mut()
}
pub fn set_resume_state(&mut self, state: ResumeState) {
self.resume_state = Some(state);
}
pub fn clear_resume_state(&mut self) {
self.resume_state = None;
}
#[must_use]
pub fn dht_token(&self) -> Option<&[u8]> {
self.dht_token.as_deref()
}
pub fn set_dht_token(&mut self, token: Option<Vec<u8>>) {
self.dht_token = token;
}
#[must_use]
pub fn segment_assignments(&self) -> &[SegmentAssignment] {
&self.segment_assignments
}
pub fn segment_assignments_mut(&mut self) -> &mut Vec<SegmentAssignment> {
&mut self.segment_assignments
}
pub fn set_segment_assignments(&mut self, assignments: Vec<SegmentAssignment>) {
self.num_connections = u32::try_from(assignments.len()).unwrap_or(u32::MAX);
self.segment_assignments = assignments;
}
pub fn clear_segment_assignments(&mut self) {
self.num_connections = 0;
self.segment_assignments.clear();
}
#[must_use]
pub fn segment_runtime_stats(&self) -> SegmentRuntimeStats {
let mut stats = SegmentRuntimeStats::default();
let mut covered_start: Option<u64> = None;
let mut covered_end: Option<u64> = None;
for assignment in &self.segment_assignments {
stats.segment_count += 1;
match assignment.state {
SegmentState::Active => stats.active_count += 1,
SegmentState::Retrying => stats.retrying_count += 1,
SegmentState::Complete => stats.complete_count += 1,
SegmentState::Planned => {}
}
stats.planned_bytes = stats.planned_bytes.saturating_add(assignment.range.len());
stats.completed_bytes = stats
.completed_bytes
.saturating_add(assignment.completed_length.min(assignment.range.len()));
stats.remaining_bytes = stats
.remaining_bytes
.saturating_add(assignment.remaining_length());
covered_start = Some(covered_start.map_or(assignment.range.start, |start| {
start.min(assignment.range.start)
}));
covered_end =
Some(covered_end.map_or(assignment.range.end, |end| end.max(assignment.range.end)));
}
stats.covered_range = covered_start
.zip(covered_end)
.map(|(start, end)| PieceRange::new(start, end));
stats
}
}
/// Parses positive numeric option values from integer or human-size option forms.
fn parse_option_limit(value: Option<&OptionValue>) -> Option<u64> {
match value {
Some(OptionValue::UInt(value)) => (*value > 0).then_some(*value),
Some(OptionValue::Int(value)) => u64::try_from(*value).ok().filter(|value| *value > 0),
Some(OptionValue::Text(value)) => parse_human_size_text(value).filter(|limit| *limit > 0),
_ => None,
}
}
@@ -0,0 +1,107 @@
use std::fmt::{Display, Formatter};
use crate::piece::PieceId;
/// Stable identifier for a download group.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DownloadId(u64);
impl DownloadId {
/// Wraps the raw numeric identifier used internally and on the RPC surface.
#[must_use]
pub const fn new(raw: u64) -> Self {
Self(raw)
}
/// Returns the raw numeric identifier.
#[must_use]
pub const fn as_u64(self) -> u64 {
self.0
}
/// Parses the hexadecimal GID representation used by aria2 RPC clients.
#[must_use]
pub fn parse_hex(raw: &str) -> Option<Self> {
u64::from_str_radix(raw, 16).ok().map(Self)
}
}
impl Display for DownloadId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:016x}", self.0)
}
}
/// User-visible lifecycle state for a download group.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DownloadStatus {
/// The group is actively transferring data.
Active,
/// The group is queued and waiting to start.
Waiting,
/// The group is paused by user or scheduler action.
Paused,
/// The group stopped because the last attempt failed.
Error,
/// The group finished successfully.
Complete,
/// The group was removed from runtime state.
Removed,
}
impl DownloadStatus {
/// Returns the lowercase RPC status token expected by aria2-compatible clients.
#[must_use]
pub const fn as_rpc_status(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Waiting => "waiting",
Self::Paused => "paused",
Self::Error => "error",
Self::Complete => "complete",
Self::Removed => "removed",
}
}
}
/// Captures one retry decision for a request or segment.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RetryAttempt {
/// Retry attempt ordinal, starting at one for the first retry.
pub attempt: u32,
/// Byte offset at which the retry resumes.
pub offset: u64,
/// Optional retry length when the retry only covers one segment.
pub length: Option<u64>,
/// Human-readable error that triggered the retry.
pub error: Option<String>,
/// Whether the error is considered recoverable by the scheduler.
pub recoverable: bool,
}
impl RetryAttempt {
/// Builds a recoverable retry record for the provided attempt number and offset.
#[must_use]
pub const fn new(attempt: u32, offset: u64) -> Self {
Self {
attempt,
offset,
length: None,
error: None,
recoverable: true,
}
}
}
/// Resume metadata recovered from persisted session state.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ResumeState {
/// Whether this resume snapshot came from persisted storage.
pub persisted: bool,
/// Byte offset from which the resumed transfer should continue.
pub resume_offset: u64,
/// Verified payload length recovered from prior state, if known.
pub validated_length: Option<u64>,
/// Optional piece cursor used to continue segmented scheduling.
pub segment_cursor: Option<PieceId>,
}
@@ -0,0 +1,929 @@
use super::*;
#[test]
fn request_context_replace_uris_filters_blank_entries_and_duplicates() {
let mut context = RequestContext::new(String::new());
assert_eq!(context.uri(), "");
assert!(context.uris().is_empty());
context.replace_uris(vec![
String::new(),
"https://example.org/file.iso".to_owned(),
"https://example.org/file.iso".to_owned(),
" ".to_owned(),
"https://mirror.example.org/file.iso".to_owned(),
]);
assert_eq!(context.uri(), "https://example.org/file.iso");
assert_eq!(
context.uris(),
&[
"https://example.org/file.iso".to_owned(),
"https://mirror.example.org/file.iso".to_owned(),
]
);
}
#[test]
fn request_context_insert_and_append_reposition_existing_uris_without_duplicates() {
let mut context = RequestContext::new("https://example.org/file.iso");
context.replace_uris(vec![
"https://example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-b.example.org/file.iso".to_owned(),
]);
context.insert_uri(0, "https://mirror-b.example.org/file.iso");
assert_eq!(context.uri(), "https://mirror-b.example.org/file.iso");
assert_eq!(
context.uris(),
&[
"https://mirror-b.example.org/file.iso".to_owned(),
"https://example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
]
);
context.append_uri("https://example.org/file.iso");
context.append_uri(" ");
assert_eq!(context.uri(), "https://mirror-b.example.org/file.iso");
assert_eq!(
context.uris(),
&[
"https://mirror-b.example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://example.org/file.iso".to_owned(),
]
);
}
#[test]
fn request_context_remove_last_uri_clears_primary_uri() {
let mut context = RequestContext::new("https://example.org/last.iso");
assert!(context.remove_first_matching_uri("https://example.org/last.iso"));
assert_eq!(context.uri(), "");
assert!(context.uris().is_empty());
assert!(!context.remove_first_matching_uri("https://example.org/last.iso"));
}
#[test]
fn request_group_with_context_normalizes_stale_primary_and_uri_list() {
let group = RequestGroup::with_context(
DownloadId::new(0x77),
RequestContext {
source: None,
uri: "https://stale.example.org/file.iso".to_owned(),
uris: vec![
String::new(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-b.example.org/file.iso".to_owned(),
],
referer: None,
headers: Vec::new(),
group_id: None,
note: None,
},
);
assert_eq!(group.uri(), "https://mirror-a.example.org/file.iso");
assert_eq!(
group.uris(),
&[
"https://mirror-a.example.org/file.iso".to_owned(),
"https://mirror-b.example.org/file.iso".to_owned(),
]
);
}
#[test]
fn request_group_tracks_retry_attempt_history() {
let mut group = RequestGroup::new(DownloadId::new(0x1234), "https://example.test/file");
group.increment_retry_count();
group.push_retry_attempt(RetryAttempt {
attempt: group.retry_count(),
offset: 8192,
length: Some(4096),
error: Some("connection reset".to_string()),
recoverable: true,
});
assert_eq!(group.retry_count(), 1);
let [attempt] = group.retry_attempts() else {
panic!("retry_attempts should contain exactly one entry");
};
assert_eq!(attempt.offset, 8192);
assert_eq!(attempt.length, Some(4096));
assert_eq!(attempt.error.as_deref(), Some("connection reset"));
}
#[test]
fn request_group_resume_state_roundtrip() {
let mut group = RequestGroup::new(DownloadId::new(0x66), "https://example.test/file");
group.set_resume_state(ResumeState {
persisted: true,
resume_offset: 32768,
validated_length: Some(4096),
segment_cursor: Some(PieceId(8)),
});
let resume = group.resume_state().expect("resume state should exist");
assert!(resume.persisted);
assert_eq!(resume.resume_offset, 32768);
assert_eq!(resume.validated_length, Some(4096));
assert_eq!(resume.segment_cursor, Some(PieceId(8)));
group.clear_resume_state();
assert!(group.resume_state().is_none());
}
#[test]
fn request_group_dht_token_roundtrip() {
let mut group = RequestGroup::new(DownloadId::new(0x67), "magnet:?xt=urn:btih:token");
assert!(group.dht_token().is_none());
group.set_dht_token(Some(b"tok".to_vec()));
assert_eq!(group.dht_token(), Some(&b"tok"[..]));
group.set_dht_token(None);
assert!(group.dht_token().is_none());
}
#[test]
fn request_group_clear_retry_attempts() {
let mut group = RequestGroup::new(DownloadId::new(0x9), "https://example.test/file");
group.push_retry_attempt(RetryAttempt::new(1, 0));
group.push_retry_attempt(RetryAttempt::new(2, 1024));
assert_eq!(group.retry_attempts().len(), 2);
group.clear_retry_attempts();
assert!(group.retry_attempts().is_empty());
}
#[test]
fn request_group_tracks_segment_assignments() {
let mut group = RequestGroup::new(DownloadId::new(0xa), "https://example.test/file");
group.set_segment_assignments(vec![
SegmentAssignment::new(0, PieceRange::new(0, 1024)),
SegmentAssignment::new(1, PieceRange::new(1024, 2048)),
]);
assert_eq!(group.num_connections(), 2);
let [_, second_assignment] = group.segment_assignments() else {
panic!("segment_assignments should contain exactly two entries");
};
assert_eq!(second_assignment.range, PieceRange::new(1024, 2048));
group.clear_segment_assignments();
assert_eq!(group.num_connections(), 0);
assert!(group.segment_assignments().is_empty());
}
#[test]
fn request_group_bt_runtime_state_roundtrip_with_full_payload() {
let mut group = RequestGroup::new(DownloadId::new(0xb), "magnet:?xt=urn:btih:ABCDEF");
let bt = BtRuntimeState {
info_hash: "0123456789ABCDEF0123456789ABCDEF01234567".to_owned(),
name: Some("ubuntu.iso".to_owned()),
magnet_uri: Some("magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567".to_owned()),
metadata_only: true,
metadata_size: Some(32_768),
metadata_extension_ids: BTreeMap::from([("192.0.2.10:51413".to_owned(), 3_u8)]),
metadata_piece_payloads: BTreeMap::from([(0_u32, b"metadata-piece-0".to_vec())]),
creation_date: Some("2026-05-26T12:00:00Z".to_owned()),
comment: Some("bt runtime".to_owned()),
dht_nodes: vec!["router.bittorrent.com:6881".to_owned()],
files: vec![
BtFileInfo {
path: "ubuntu.iso".to_owned(),
length: 2048,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "readme.txt".to_owned(),
length: 128,
piece_offset: Some(2),
selected: false,
},
],
trackers: vec![
BtTrackerInfo {
url: "udp://tracker.example.org:6969/announce".to_owned(),
tier: Some(0),
id: Some("trk-0".to_owned()),
seeders: Some(10),
leechers: Some(3),
},
BtTrackerInfo {
url: "https://tracker2.example.org/announce".to_owned(),
tier: Some(1),
id: None,
seeders: None,
leechers: None,
},
],
peers: vec![BtPeerInfo {
peer_id: Some("-TR3000-ABCDEF123456".to_owned()),
ip: "192.0.2.10".to_owned(),
port: 51413,
client_name: Some("Transmission".to_owned()),
interested: true,
choked: false,
download_speed: 4096,
upload_speed: 2048,
seeder: false,
}],
};
group.set_bt(bt.clone());
let saved = group.bt().expect("bt runtime state should be set");
assert_eq!(saved, &bt);
assert_eq!(
saved.dht_nodes(),
&["router.bittorrent.com:6881".to_owned()]
);
assert_eq!(saved.files.len(), 2);
assert_eq!(saved.trackers.len(), 2);
assert_eq!(saved.peers.len(), 1);
}
#[test]
fn request_group_bt_runtime_state_mutation_via_bt_mut() {
let mut group = RequestGroup::new(DownloadId::new(0xc), "magnet:?xt=urn:btih:AAAA");
group.set_bt(BtRuntimeState {
info_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
name: Some("seed".to_owned()),
magnet_uri: Some("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned()),
metadata_only: true,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: vec!["dht.transmissionbt.com:6881".to_owned()],
files: vec![BtFileInfo {
path: "seed.bin".to_owned(),
length: 1,
piece_offset: Some(0),
selected: true,
}],
trackers: vec![],
peers: vec![],
});
let bt = group.bt_mut().expect("bt runtime state should be mutable");
bt.metadata_only = false;
bt.comment = Some("metadata complete".to_owned());
bt.dht_nodes.push("router.utorrent.com:6881".to_owned());
bt.files.push(BtFileInfo {
path: "extra.bin".to_owned(),
length: 512,
piece_offset: Some(1),
selected: true,
});
bt.trackers.push(BtTrackerInfo {
url: "https://tracker.example.org/announce".to_owned(),
tier: Some(0),
id: Some("trk-a".to_owned()),
seeders: Some(1),
leechers: Some(0),
});
bt.peers.push(BtPeerInfo {
peer_id: None,
ip: "198.51.100.20".to_owned(),
port: 60000,
client_name: None,
interested: true,
choked: true,
download_speed: 0,
upload_speed: 0,
seeder: true,
});
let after = group.bt().expect("bt runtime state should still exist");
assert!(!after.metadata_only);
assert_eq!(after.comment.as_deref(), Some("metadata complete"));
assert_eq!(after.dht_nodes.len(), 2);
assert_eq!(after.files.len(), 2);
assert_eq!(after.trackers.len(), 1);
assert_eq!(after.peers.len(), 1);
}
#[test]
fn request_group_bt_runtime_state_can_be_cleared() {
let mut group = RequestGroup::new(DownloadId::new(0xd), "magnet:?xt=urn:btih:BBBB");
group.set_bt(BtRuntimeState {
info_hash: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned(),
name: None,
magnet_uri: Some("magnet:?xt=urn:btih:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned()),
metadata_only: true,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: Vec::new(),
files: Vec::new(),
trackers: Vec::new(),
peers: Vec::new(),
});
assert!(group.bt().is_some());
assert!(group.bt_mut().is_some());
group.clear_bt();
assert!(group.bt().is_none());
assert!(group.bt_mut().is_none());
}
#[test]
fn bt_runtime_state_reports_selected_file_helpers() {
let bt = BtRuntimeState {
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
name: Some("example".to_owned()),
magnet_uri: None,
metadata_only: false,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: vec![
"router.bittorrent.com:6881".to_owned(),
"router.utorrent.com:6881".to_owned(),
],
files: vec![
BtFileInfo {
path: "selected.iso".to_owned(),
length: 2048,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "ignored.txt".to_owned(),
length: 128,
piece_offset: Some(2048),
selected: false,
},
],
trackers: vec![],
peers: vec![],
};
let [first_file, ..] = bt.files() else {
panic!("bt files should contain at least one entry");
};
assert!(first_file.is_selected());
assert_eq!(bt.dht_nodes().len(), 2);
assert!(bt.has_selected_files());
assert_eq!(bt.selected_file_count(), 1);
assert_eq!(bt.selected_total_length(), 2048);
let selected_paths: Vec<_> = bt.selected_files().map(|file| file.path.as_str()).collect();
assert_eq!(selected_paths, vec!["selected.iso"]);
}
#[test]
fn request_group_bt_share_state_roundtrip_and_accessors_work() {
let mut group = RequestGroup::new(DownloadId::new(0xe), "magnet:?xt=urn:btih:CCCC");
group.set_bt_share_state(BtShareRuntimeState {
seeding: true,
share_ratio_milli: Some(1500),
share_time_secs: 3600,
seeding_time_secs: 900,
seeding_started_at_secs: None,
last_runtime_tick_secs: None,
});
group.set_bt(BtRuntimeState {
info_hash: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC".to_owned(),
name: Some("payload".to_owned()),
magnet_uri: None,
metadata_only: false,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: vec!["router.bittorrent.com:6881".to_owned()],
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 4096,
piece_offset: Some(0),
selected: true,
}],
trackers: vec![],
peers: vec![],
});
assert!(group.bt_is_seeding());
assert_eq!(group.bt_share_ratio_milli(), Some(1500));
assert_eq!(group.bt_share_time_secs(), Some(3600));
assert_eq!(group.bt_seeding_time_secs(), Some(900));
assert_eq!(group.bt_selected_file_count(), Some(1));
assert_eq!(group.bt_selected_total_length(), Some(4096));
assert!(group.bt_has_selected_files());
let share = group
.bt_share_state_mut()
.expect("share state should exist");
share.add_share_time_secs(120);
share.add_seeding_time_secs(30);
share.set_seeding(false);
assert!(!group.bt_is_seeding());
assert_eq!(group.bt_share_time_secs(), Some(3720));
assert_eq!(group.bt_seeding_time_secs(), Some(930));
group.clear_bt_share_state();
assert!(group.bt_share_state().is_none());
assert!(!group.bt_is_seeding());
}
#[test]
fn bt_share_runtime_state_tracks_seeding_runtime_and_ratio_derivation() {
let mut state = BtShareRuntimeState::new();
state.start_seeding(100);
state.tick_runtime(130);
state.tick_runtime(170);
state.stop_seeding(200);
state.tick_runtime(250);
state.refresh_share_ratio_from_lengths(6000, 4000);
assert!(!state.is_seeding());
assert_eq!(state.share_time_secs(), 150);
assert_eq!(state.seeding_time_secs(), 100);
assert_eq!(state.share_ratio_milli(), Some(1500));
assert_eq!(BtShareRuntimeState::derive_share_ratio_milli(100, 0), None);
}
#[test]
fn request_group_bt_runtime_tick_updates_true_seeding_and_share_runtime() {
let mut group = RequestGroup::new(DownloadId::new(0x101), "magnet:?xt=urn:btih:RUNTIME");
group.set_total_length(2_048);
group.set_completed_length(2_048);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 2_048,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
let started = group.set_bt_seeding_state(true, Some(100));
assert!(group.bt_is_seeding());
assert!(group.bt_is_true_seeding());
assert!(started.seeding);
assert_eq!(started.share_ratio_milli, Some(0));
let advanced = group.tick_bt_runtime_clock(130, true);
assert_eq!(advanced.share_time_secs, 30);
assert_eq!(advanced.seeding_time_secs, 30);
assert!(advanced.seeding);
let tick = group.apply_bt_runtime_tick(0, 512, 64, 128, 10, 10, true, Some(8));
assert_eq!(group.upload_length(), 512);
assert_eq!(group.download_speed(), 64);
assert_eq!(group.upload_speed(), 128);
assert_eq!(group.num_connections(), 8);
assert_eq!(tick.share_time_secs, 40);
assert_eq!(tick.seeding_time_secs, 40);
assert_eq!(tick.share_ratio_milli, Some(250));
assert!(tick.seeding);
let stopped = group.tick_bt_runtime_clock(160, false);
assert!(!group.bt_is_seeding());
assert!(!group.bt_is_true_seeding());
assert!(!stopped.seeding);
assert_eq!(stopped.share_time_secs, 70);
assert_eq!(stopped.seeding_time_secs, 70);
}
#[test]
fn request_group_bt_share_ratio_base_length_follows_selected_payload() {
let mut group = RequestGroup::new(DownloadId::new(0x102), "magnet:?xt=urn:btih:BASE");
group.set_total_length(10_000);
group.set_completed_length(3_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "selected-a.bin".to_owned(),
length: 2_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "selected-b.bin".to_owned(),
length: 1_000,
piece_offset: Some(2),
selected: true,
},
BtFileInfo {
path: "ignored.bin".to_owned(),
length: 7_000,
piece_offset: Some(3),
selected: false,
},
],
..BtRuntimeState::default()
});
assert_eq!(group.bt_share_ratio_base_length(), Some(3_000));
group.set_completed_length(1_000);
assert_eq!(group.bt_share_ratio_base_length(), Some(3_000));
group.set_completed_length(5_000);
assert_eq!(group.bt_share_ratio_base_length(), Some(5_000));
}
#[test]
fn request_group_refresh_bt_share_runtime_uses_share_ratio_base_length() {
let mut group = RequestGroup::new(DownloadId::new(0x103), "magnet:?xt=urn:btih:RATIO");
group.set_total_length(10_000);
group.set_completed_length(6_000);
group.set_upload_length(3_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "selected.bin".to_owned(),
length: 4_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "ignored.bin".to_owned(),
length: 6_000,
piece_offset: Some(4),
selected: false,
},
],
..BtRuntimeState::default()
});
group.set_bt_share_state(BtShareRuntimeState::default());
let snapshot = group.refresh_bt_share_runtime();
assert_eq!(group.bt_share_ratio_base_length(), Some(6_000));
assert_eq!(snapshot.share_ratio_milli, Some(500));
assert_eq!(group.bt_share_ratio_milli(), Some(500));
}
#[test]
fn request_group_set_bt_refreshes_cached_share_ratio_after_selection_changes() {
let mut group = RequestGroup::new(DownloadId::new(0x104), "magnet:?xt=urn:btih:SELECT");
group.set_total_length(10_000);
group.set_completed_length(4_000);
group.set_upload_length(2_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "disc-a.bin".to_owned(),
length: 5_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "disc-b.bin".to_owned(),
length: 5_000,
piece_offset: Some(5),
selected: true,
},
],
..BtRuntimeState::default()
});
group.set_bt_share_state(BtShareRuntimeState::default());
assert_eq!(
group.refresh_bt_share_runtime().share_ratio_milli,
Some(200)
);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "disc-a.bin".to_owned(),
length: 4_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "disc-b.bin".to_owned(),
length: 6_000,
piece_offset: Some(4),
selected: false,
},
],
..BtRuntimeState::default()
});
assert_eq!(group.bt_selected_total_length(), Some(4_000));
assert_eq!(group.bt_share_ratio_milli(), Some(500));
}
#[test]
fn request_group_set_upload_length_refreshes_cached_bt_share_ratio() {
let mut group = RequestGroup::new(DownloadId::new(0x105), "magnet:?xt=urn:btih:UPLOAD");
group.set_total_length(4_000);
group.set_completed_length(4_000);
group.set_upload_length(1_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 4_000,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
group.set_bt_share_state(BtShareRuntimeState::default());
assert_eq!(
group.refresh_bt_share_runtime().share_ratio_milli,
Some(250)
);
group.set_upload_length(2_000);
assert_eq!(group.bt_share_ratio_milli(), Some(500));
}
#[test]
fn request_group_bt_piece_helpers_report_counts_and_requestable_ids() {
let mut group = RequestGroup::new(DownloadId::new(0xf), "magnet:?xt=urn:btih:DDDD");
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Queued);
group.set_piece_state(PieceId(3), PieceState::Downloading);
group.set_piece_state(PieceId(4), PieceState::Missing);
group.set_piece_state(PieceId(5), PieceState::Skipped);
assert_eq!(group.bt_verified_piece_count(), 1);
assert_eq!(group.piece_state_counts(), (1, 1, 1, 1, 1, 1));
assert_eq!(
group.bt_requestable_piece_ids(false, 8),
vec![PieceId(1), PieceId(2), PieceId(4)]
);
assert_eq!(
group.bt_requestable_piece_ids(true, 8),
vec![PieceId(1), PieceId(2), PieceId(4), PieceId(3)]
);
}
#[test]
fn request_group_bt_effective_target_and_remaining_length_follow_selection() {
let mut group = RequestGroup::new(DownloadId::new(0x10), "magnet:?xt=urn:btih:EEEE");
group.set_total_length(10_000);
group.set_completed_length(4_000);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![
BtFileInfo {
path: "wanted-a.bin".to_owned(),
length: 3_000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "wanted-b.bin".to_owned(),
length: 2_000,
piece_offset: Some(3),
selected: true,
},
BtFileInfo {
path: "ignored.bin".to_owned(),
length: 5_000,
piece_offset: Some(5),
selected: false,
},
],
..BtRuntimeState::default()
});
assert_eq!(group.bt_effective_target_length(), Some(5_000));
assert_eq!(group.bt_remaining_work_length(), Some(1_000));
group.set_completed_length(9_000);
assert_eq!(group.bt_remaining_work_length(), Some(0));
}
#[test]
fn request_group_bt_piece_block_update_tracks_piece_progress_and_selected_span() {
let mut group = RequestGroup::new(DownloadId::new(0x11), "magnet:?xt=urn:btih:FFFF");
group.set_total_length(4_096);
group.set_piece_length(1_024);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "wanted.bin".to_owned(),
length: 2_500,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
let partial = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
piece_id: PieceId(2),
completed_blocks: 2,
total_blocks: 4,
});
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Downloading));
assert_eq!(group.completed_length(), 0);
assert_eq!(partial.completed_length_delta, 0);
assert_eq!(partial.previous_state, None);
assert_eq!(partial.next_state, PieceState::Downloading);
assert_eq!(partial.piece_span_length, 452);
assert_eq!(partial.block_completion_milli, 500);
assert!(!partial.transitioned_to_verified);
let verified = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
piece_id: PieceId(2),
completed_blocks: 4,
total_blocks: 4,
});
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Verified));
assert_eq!(group.completed_length(), 452);
assert_eq!(verified.completed_length_delta, 452);
assert_eq!(verified.previous_state, Some(PieceState::Downloading));
assert_eq!(verified.next_state, PieceState::Verified);
assert_eq!(verified.block_completion_milli, 1000);
assert!(verified.transitioned_to_verified);
let missing = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
piece_id: PieceId(2),
completed_blocks: 0,
total_blocks: 0,
});
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Missing));
assert_eq!(group.completed_length(), 0);
assert_eq!(missing.completed_length_delta, -452);
assert_eq!(missing.previous_state, Some(PieceState::Verified));
assert_eq!(missing.next_state, PieceState::Missing);
assert_eq!(missing.piece_span_length, 452);
assert_eq!(missing.block_completion_milli, 0);
}
#[test]
fn request_group_bt_peer_and_availability_updates_report_runtime_stats() {
let mut group = RequestGroup::new(DownloadId::new(0x12), "magnet:?xt=urn:btih:9999");
group.set_piece_state(PieceId(0), PieceState::Missing);
group.set_piece_state(PieceId(1), PieceState::Queued);
group.set_piece_state(PieceId(2), PieceState::Verified);
group.set_bt(BtRuntimeState {
info_hash: "9999".to_owned(),
metadata_only: false,
..BtRuntimeState::default()
});
let available = group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(0),
peers_with_piece: 3,
});
assert_eq!(available.available_piece_count, 1);
assert_eq!(available.peers_with_piece, 3);
assert!(available.piece_is_requestable);
assert!(!available.piece_is_verified);
let verified_piece = group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(2),
peers_with_piece: 5,
});
assert_eq!(verified_piece.available_piece_count, 2);
assert!(!verified_piece.piece_is_requestable);
assert!(verified_piece.piece_is_verified);
let peer_a = group.apply_bt_peer_update(BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("client-a".to_owned()),
interested: true,
choked: false,
download_speed: 512,
upload_speed: 64,
seeder: false,
});
assert_eq!(peer_a.peer_count, 1);
assert_eq!(peer_a.seeder_count, 0);
assert_eq!(peer_a.leecher_count, 1);
assert_eq!(peer_a.total_download_speed, 512);
assert_eq!(peer_a.total_upload_speed, 64);
assert!(!peer_a.replaced_existing);
let peer_b = group.apply_bt_peer_update(BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("client-a2".to_owned()),
interested: false,
choked: true,
download_speed: 1_024,
upload_speed: 256,
seeder: true,
});
assert_eq!(peer_b.peer_count, 1);
assert_eq!(peer_b.seeder_count, 1);
assert_eq!(peer_b.leecher_count, 0);
assert_eq!(peer_b.total_download_speed, 1_024);
assert_eq!(peer_b.total_upload_speed, 256);
assert!(peer_b.replaced_existing);
}
#[test]
fn request_group_segment_runtime_stats_capture_assignment_load() {
let mut group = RequestGroup::new(DownloadId::new(0x13), "https://example.org/segments.bin");
group.set_segment_assignments(vec![
SegmentAssignment {
slot: 0,
range: PieceRange::new(0, 1024),
completed_length: 512,
state: SegmentState::Active,
},
SegmentAssignment {
slot: 1,
range: PieceRange::new(1024, 2048),
completed_length: 1024,
state: SegmentState::Complete,
},
SegmentAssignment {
slot: 2,
range: PieceRange::new(2048, 3072),
completed_length: 128,
state: SegmentState::Retrying,
},
]);
let stats = group.segment_runtime_stats();
assert_eq!(stats.segment_count, 3);
assert_eq!(stats.active_count, 1);
assert_eq!(stats.retrying_count, 1);
assert_eq!(stats.complete_count, 1);
assert_eq!(stats.planned_bytes, 3072);
assert_eq!(stats.completed_bytes, 1664);
assert_eq!(stats.remaining_bytes, 1408);
assert_eq!(stats.covered_range, Some(PieceRange::new(0, 3072)));
}
#[test]
fn request_group_bt_pressure_snapshot_reports_requestable_and_scarcity() {
let mut group = RequestGroup::new(DownloadId::new(0x14), "magnet:?xt=urn:btih:PRESSURE2");
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Downloading);
group.set_piece_state(PieceId(3), PieceState::Queued);
group.set_piece_state(PieceId(4), PieceState::Missing);
group.set_bt(BtRuntimeState {
metadata_only: false,
peers: vec![
BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "198.51.100.10".to_owned(),
port: 6881,
client_name: None,
interested: true,
choked: false,
download_speed: 256,
upload_speed: 64,
seeder: false,
},
BtPeerInfo {
peer_id: Some("peer-b".to_owned()),
ip: "198.51.100.11".to_owned(),
port: 6882,
client_name: None,
interested: false,
choked: true,
download_speed: 0,
upload_speed: 32,
seeder: true,
},
],
..BtRuntimeState::default()
});
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(1),
peers_with_piece: 1,
});
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
piece_id: PieceId(3),
peers_with_piece: 3,
});
let pressure = group
.bt_pressure_snapshot()
.expect("bt pressure snapshot should exist");
assert_eq!(pressure.total_pieces, 5);
assert_eq!(pressure.requestable_pieces, 3);
assert_eq!(pressure.active_pieces, 2);
assert_eq!(pressure.available_requestable_pieces, 2);
assert_eq!(pressure.scarce_requestable_pieces, 1);
assert_eq!(pressure.peer_count, 2);
assert_eq!(pressure.seeder_count, 1);
assert_eq!(pressure.leecher_count, 1);
}
@@ -0,0 +1,67 @@
use crate::piece::PieceRange;
/// Scheduling state for a single HTTP segment assignment.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SegmentState {
/// The segment is planned but no worker has claimed it yet.
Planned,
/// The segment is actively downloading.
Active,
/// The segment is waiting for a retry.
Retrying,
/// The segment finished successfully.
Complete,
}
/// Active or planned range assignment for a segmented transfer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SegmentAssignment {
/// Scheduler slot associated with this assignment.
pub slot: usize,
/// Piece range covered by the assignment.
pub range: PieceRange,
/// Number of bytes already completed inside the range.
pub completed_length: u64,
/// Current scheduling state of the assignment.
pub state: SegmentState,
}
impl SegmentAssignment {
/// Builds a planned assignment for the provided slot and range.
#[must_use]
pub const fn new(slot: usize, range: PieceRange) -> Self {
Self {
slot,
range,
completed_length: 0,
state: SegmentState::Planned,
}
}
/// Returns the remaining byte count inside the assigned range.
#[must_use]
pub const fn remaining_length(&self) -> u64 {
self.range.len().saturating_sub(self.completed_length)
}
}
/// Aggregated runtime counters for the segment scheduler.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SegmentRuntimeStats {
/// Total number of segment assignments known to the scheduler.
pub segment_count: usize,
/// Number of assignments currently marked active.
pub active_count: usize,
/// Number of assignments currently waiting for retry.
pub retrying_count: usize,
/// Number of assignments already completed.
pub complete_count: usize,
/// Total byte length covered by all planned ranges.
pub planned_bytes: u64,
/// Total byte length completed across all assignments.
pub completed_bytes: u64,
/// Remaining byte length across all assignments.
pub remaining_bytes: u64,
/// Smallest range spanning all scheduled segments, if one exists.
pub covered_range: Option<PieceRange>,
}
+206
View File
@@ -0,0 +1,206 @@
//! Runtime configuration defaults and human-readable size parsing helpers.
use std::path::PathBuf;
/// Runtime configuration used to build and operate the download engine.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeConfig {
/// Number of worker threads available to the runtime.
pub worker_threads: usize,
/// Maximum number of simultaneously active downloads.
pub max_active_downloads: usize,
/// Maximum number of tracked downloads.
pub max_downloads: usize,
/// Desired split count per download.
pub split: usize,
/// Canonical maximum connections per server option.
pub max_connections_per_server: usize,
/// Compatibility alias for the maximum connections per server option.
pub max_connection_per_server: usize,
/// Global download-rate cap in bytes per second.
pub max_overall_download_limit: Option<u64>,
/// Per-download download-rate cap in bytes per second.
pub max_download_limit: Option<u64>,
/// Global upload-rate cap in bytes per second.
pub max_overall_upload_limit: Option<u64>,
/// Per-download upload-rate cap in bytes per second.
pub max_upload_limit: Option<u64>,
/// Minimum size used when splitting work into segments.
pub min_split_size: u64,
/// Default piece length for newly created downloads.
pub piece_length: u64,
/// RPC listen port.
pub rpc_port: u16,
/// `BitTorrent` listen port.
pub listen_port: u16,
/// Configured disk-cache size in bytes.
pub disk_cache_bytes: u64,
/// Event queue buffer size.
pub event_buffer_size: usize,
/// Optional session file path.
pub session_path: Option<PathBuf>,
/// Interval between session saves in seconds.
pub save_session_interval_secs: u64,
/// Graceful shutdown timeout in seconds.
pub graceful_shutdown_timeout_secs: u64,
/// Whether XML-RPC endpoints are enabled.
pub allow_xmlrpc: bool,
/// Whether JSON-RPC endpoints are enabled.
pub allow_jsonrpc: bool,
/// Whether resume behavior is enabled.
pub allow_resume: bool,
/// Whether IPv6 support is enabled.
pub enable_ipv6: bool,
/// Whether HTTP 400 responses are retryable.
pub retry_on_400: bool,
/// Whether HTTP 403 responses are retryable.
pub retry_on_403: bool,
/// Whether HTTP 406 responses are retryable.
pub retry_on_406: bool,
/// Whether unknown failures are retryable.
pub retry_on_unknown: bool,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
worker_threads: 4,
max_active_downloads: 5,
max_downloads: 16,
split: 5,
max_connections_per_server: 1,
max_connection_per_server: 1,
max_overall_download_limit: None,
max_download_limit: None,
max_overall_upload_limit: None,
max_upload_limit: None,
min_split_size: 1_024,
piece_length: 1_024,
rpc_port: 6_800,
listen_port: 6_881,
disk_cache_bytes: 16 * 1_024 * 1_024,
event_buffer_size: 256,
session_path: None,
save_session_interval_secs: 30,
graceful_shutdown_timeout_secs: 10,
allow_xmlrpc: true,
allow_jsonrpc: true,
allow_resume: true,
enable_ipv6: false,
retry_on_400: true,
retry_on_403: true,
retry_on_406: true,
retry_on_unknown: true,
}
}
}
impl RuntimeConfig {
/// Returns a copy with the session path set.
#[must_use]
pub fn with_session_path(mut self, path: impl Into<PathBuf>) -> Self {
self.session_path = Some(path.into());
self
}
/// Returns a copy with the worker-thread count overridden.
#[must_use]
pub fn with_worker_threads(mut self, count: usize) -> Self {
self.worker_threads = count;
self
}
/// Returns a copy with the RPC port overridden.
#[must_use]
pub fn with_rpc_port(mut self, port: u16) -> Self {
self.rpc_port = port;
self
}
/// Returns a copy with the session path parsed from a string-like value.
#[must_use]
pub fn with_session_path_str(mut self, path: impl Into<String>) -> Self {
self.session_path = Some(PathBuf::from(path.into()));
self
}
/// Returns the effective max-connections-per-server setting.
#[must_use]
pub fn effective_max_connections_per_server(&self) -> usize {
self.max_connections_per_server
.max(self.max_connection_per_server)
.max(1)
}
/// Returns the effective split count.
#[must_use]
pub fn effective_split(&self) -> usize {
self.split.max(1)
}
/// Returns the effective maximum parallel segment count.
#[must_use]
pub fn effective_parallel_segments(&self) -> usize {
self.effective_split()
.min(self.effective_max_connections_per_server())
.max(1)
}
/// Returns retryability flags for the common HTTP error buckets.
#[must_use]
pub fn retryable_status_codes(&self) -> [bool; 4] {
[
self.retry_on_400,
self.retry_on_403,
self.retry_on_406,
self.retry_on_unknown,
]
}
}
/// Parses a human-readable byte-size string into a byte count.
#[must_use]
#[expect(
clippy::redundant_pub_crate,
reason = "session and request helpers reuse the parser while the runtime module remains crate-private"
)]
pub(crate) fn parse_human_size_text(value: &str) -> Option<u64> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let split_index = trimmed
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(trimmed.len());
let (digits, suffix) = trimmed.split_at(split_index);
let base = digits.parse::<u64>().ok()?;
let suffix = suffix.trim();
let factor = if suffix.is_empty() {
1
} else if suffix.eq_ignore_ascii_case("k") || suffix.eq_ignore_ascii_case("kb") {
1_024
} else if suffix.eq_ignore_ascii_case("m") || suffix.eq_ignore_ascii_case("mb") {
1_024 * 1_024
} else if suffix.eq_ignore_ascii_case("g") || suffix.eq_ignore_ascii_case("gb") {
1_024 * 1_024 * 1_024
} else {
return None;
};
Some(base.saturating_mul(factor))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_human_size_text_supports_plain_and_suffix_forms() {
assert_eq!(parse_human_size_text("2048"), Some(2048));
assert_eq!(parse_human_size_text("2K"), Some(2 * 1024));
assert_eq!(parse_human_size_text("4M"), Some(4 * 1024 * 1024));
assert_eq!(parse_human_size_text("3gb"), Some(3 * 1024 * 1024 * 1024));
assert_eq!(parse_human_size_text(""), None);
assert_eq!(parse_human_size_text("12T"), None);
}
}
+559
View File
@@ -0,0 +1,559 @@
//! Scheduling policies, planning state, and per-tick observations.
use crate::{
piece::{PieceId, PieceRange, PieceState},
request::{DownloadId, DownloadStatus, RequestGroup},
runtime::RuntimeConfig,
};
/// Policy used to choose the next runnable download.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SchedulerPolicy {
/// Fair scheduling that balances active and waiting work.
#[default]
Fair,
/// FIFO queue ordering.
FirstInFirstOut,
/// LIFO queue ordering.
LastInFirstOut,
/// Round-robin scheduling.
RoundRobin,
}
/// Current scheduler lifecycle state.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SchedulerState {
/// Scheduler is idle.
#[default]
Idle,
/// Scheduler is ready to plan work.
Ready,
/// Scheduler is actively running work.
Running,
/// Scheduler is paused.
Paused,
/// Scheduler is shutting down.
ShuttingDown,
/// Scheduler has stopped.
Stopped,
}
/// Action produced by a scheduling pass.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScheduleDecision {
/// Start the given download immediately.
RunNow(DownloadId),
/// Keep the given download in the waiting queue.
Queue(DownloadId),
/// Pause the given download.
Pause(DownloadId),
/// Remove the given download.
Remove(DownloadId),
/// Requeue the given download for later retry.
RetryLater(DownloadId),
/// No action was required.
Noop,
}
/// Copyable discriminator for schedule decisions.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScheduleDecisionKind {
/// Decision kind for starting a download immediately.
RunNow,
/// Decision kind for queueing a download.
Queue,
/// Decision kind for pausing a download.
Pause,
/// Decision kind for removing a download.
Remove,
/// Decision kind for retrying a download later.
RetryLater,
/// Decision kind for taking no action.
Noop,
}
/// Counters gathered while the scheduler is running.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SchedulerActivityCounters {
/// Number of scheduler ticks observed.
pub tick_count: u64,
/// Number of scheduling passes that started.
pub schedule_run_count: u64,
/// Number of immediate-run decisions emitted.
pub run_now_decision_count: u64,
/// Number of queue decisions emitted.
pub queue_decision_count: u64,
/// Number of pause decisions emitted.
pub pause_decision_count: u64,
/// Number of remove decisions emitted.
pub remove_decision_count: u64,
/// Number of retry-later decisions emitted.
pub retry_later_decision_count: u64,
/// Number of noop decisions emitted.
pub noop_decision_count: u64,
/// Most recent decision kind, when one has been recorded.
pub last_decision: Option<ScheduleDecisionKind>,
}
/// Snapshot of the most recent planning observation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SchedulerPlanningObservation {
/// Download id for the observation.
pub gid: DownloadId,
/// Total payload length reported by the group.
pub total_length: u64,
/// Payload length considered plannable by the scheduler.
pub plannable_length: u64,
/// Completed portion of the plannable length.
pub completed_length: u64,
/// Remaining plannable bytes.
pub remaining_bytes: u64,
/// Number of segments the scheduler planned.
pub planned_segments: usize,
/// Number of active segments already running.
pub active_segment_count: usize,
/// Number of pieces currently requestable.
pub requestable_pieces: usize,
/// Number of active pieces currently downloading or queued.
pub active_piece_count: usize,
/// Number of requestable pieces available from peers.
pub available_requestable_pieces: usize,
/// Number of requestable pieces available from scarce peers only.
pub scarce_requestable_pieces: usize,
/// Number of peers visible in the swarm snapshot.
pub peer_count: usize,
/// Whether the observation considers endgame mode ready.
pub bt_endgame_ready: bool,
}
/// Parameters used to split a download into active segments.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SegmentPlan {
/// Requested split count for the download.
pub split: usize,
/// Minimum byte size allowed for a segment.
pub min_split_size: u64,
/// Piece length used to align piece-aware work.
pub piece_length: u64,
/// Maximum connections allowed per server.
pub max_connections_per_server: usize,
}
impl SegmentPlan {
/// Builds a segment plan from the runtime configuration.
#[must_use]
pub fn from_runtime(runtime: &RuntimeConfig) -> Self {
Self {
split: runtime.effective_split(),
min_split_size: runtime.min_split_size.max(1),
piece_length: runtime.piece_length.max(1),
max_connections_per_server: runtime.effective_max_connections_per_server(),
}
}
}
/// Entry recorded for a retry in the scheduler bridge state.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RetryHistoryEntry {
/// Unix timestamp when the retry was recorded.
pub at_unix_secs: u64,
/// Human-readable retry reason.
pub reason: String,
}
/// Runtime state mirrored from the scheduler into the session bridge.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RuntimeScheduleState {
/// Completed payload length mirrored from runtime state.
pub completed_length: u64,
/// Total retry count mirrored from runtime state.
pub retry_count: u32,
/// Retry history mirrored from runtime state.
pub retry_history: Vec<RetryHistoryEntry>,
/// Number of active segments mirrored from runtime state.
pub active_segments: usize,
}
impl RuntimeScheduleState {
/// Records a retry entry in the bridge state.
pub fn record_retry(&mut self, at_unix_secs: u64, reason: impl Into<String>) {
self.retry_count = self.retry_count.saturating_add(1);
self.retry_history.push(RetryHistoryEntry {
at_unix_secs,
reason: reason.into(),
});
}
/// Updates the mirrored completed length.
pub fn set_completed_length(&mut self, completed_length: u64) {
self.completed_length = completed_length;
}
/// Updates the mirrored active-segment count.
pub fn set_active_segments(&mut self, active_segments: usize) {
self.active_segments = active_segments;
}
/// Returns whether the scheduler should enter endgame mode.
#[must_use]
pub fn is_endgame_ready(remaining_pieces: usize, endgame_threshold: usize) -> bool {
remaining_pieces > 0 && remaining_pieces <= endgame_threshold.max(1)
}
}
/// Piece-selection options used when choosing `BitTorrent` work.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BtPieceSelectionOptions {
/// Maximum number of candidate pieces to return.
pub max_candidates: usize,
/// Whether downloading pieces stay eligible during endgame.
pub include_downloading_in_endgame: bool,
/// Whether endgame mode is currently active.
pub endgame_mode: bool,
}
impl Default for BtPieceSelectionOptions {
fn default() -> Self {
Self {
max_candidates: 32,
include_downloading_in_endgame: true,
endgame_mode: false,
}
}
}
/// Main scheduler state machine and planning helper.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Scheduler {
/// Scheduling policy used for decisions.
policy: SchedulerPolicy,
/// Current scheduler lifecycle state.
state: SchedulerState,
/// Maximum number of active downloads allowed at once.
max_active: usize,
/// Accumulated activity counters.
activity_counters: SchedulerActivityCounters,
/// Most recent planning observation captured by the scheduler.
last_planning_observation: Option<SchedulerPlanningObservation>,
}
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}
impl Scheduler {
#[must_use]
/// Derives the number of bytes that are actually plannable for a group.
fn effective_plannable_total_length(group: &RequestGroup) -> u64 {
let total = group.total_length();
let Some(bt) = group.bt() else {
return total;
};
if bt.metadata_only {
return 0;
}
if bt.files.is_empty() {
return total;
}
let selected_total = bt
.files
.iter()
.filter(|file| file.selected)
.fold(0_u64, |acc, file| acc.saturating_add(file.length));
if selected_total == 0 {
return 0;
}
if total == 0 {
selected_total
} else {
selected_total.min(total)
}
}
/// Creates a scheduler with the default fair policy.
#[must_use]
pub fn new() -> Self {
Self {
policy: SchedulerPolicy::default(),
state: SchedulerState::default(),
max_active: 3,
activity_counters: SchedulerActivityCounters::default(),
last_planning_observation: None,
}
}
/// Returns a copy with the requested scheduling policy.
#[must_use]
pub fn with_policy(mut self, policy: SchedulerPolicy) -> Self {
self.policy = policy;
self
}
/// Returns the active scheduling policy.
#[must_use]
pub fn policy(&self) -> SchedulerPolicy {
self.policy
}
/// Returns the scheduler lifecycle state.
#[must_use]
pub fn state(&self) -> SchedulerState {
self.state
}
/// Updates the scheduler lifecycle state.
pub fn set_state(&mut self, state: SchedulerState) {
self.state = state;
}
/// Sets the maximum number of active downloads.
pub fn set_max_active(&mut self, max_active: usize) {
self.max_active = max_active;
}
/// Returns the configured max-active count.
#[must_use]
pub fn max_active(&self) -> usize {
self.max_active
}
/// Returns the accumulated activity counters.
#[must_use]
pub fn activity_counters(&self) -> &SchedulerActivityCounters {
&self.activity_counters
}
/// Returns the latest planning observation when available.
#[must_use]
pub fn last_planning_observation(&self) -> Option<&SchedulerPlanningObservation> {
self.last_planning_observation.as_ref()
}
/// Records that a scheduling pass started.
pub fn record_schedule_run(&mut self) {
self.activity_counters.schedule_run_count =
self.activity_counters.schedule_run_count.saturating_add(1);
}
/// Records a single scheduling decision in the activity counters.
pub fn record_decision(&mut self, decision: &ScheduleDecision) {
let kind = match decision {
ScheduleDecision::RunNow(_) => {
self.activity_counters.run_now_decision_count = self
.activity_counters
.run_now_decision_count
.saturating_add(1);
ScheduleDecisionKind::RunNow
}
ScheduleDecision::Queue(_) => {
self.activity_counters.queue_decision_count = self
.activity_counters
.queue_decision_count
.saturating_add(1);
ScheduleDecisionKind::Queue
}
ScheduleDecision::Pause(_) => {
self.activity_counters.pause_decision_count = self
.activity_counters
.pause_decision_count
.saturating_add(1);
ScheduleDecisionKind::Pause
}
ScheduleDecision::Remove(_) => {
self.activity_counters.remove_decision_count = self
.activity_counters
.remove_decision_count
.saturating_add(1);
ScheduleDecisionKind::Remove
}
ScheduleDecision::RetryLater(_) => {
self.activity_counters.retry_later_decision_count = self
.activity_counters
.retry_later_decision_count
.saturating_add(1);
ScheduleDecisionKind::RetryLater
}
ScheduleDecision::Noop => {
self.activity_counters.noop_decision_count =
self.activity_counters.noop_decision_count.saturating_add(1);
ScheduleDecisionKind::Noop
}
};
self.activity_counters.last_decision = Some(kind);
}
/// Chooses the next coarse action for the provided download group.
#[must_use]
pub fn decide(&self, group: &RequestGroup) -> ScheduleDecision {
match group.status() {
DownloadStatus::Waiting => ScheduleDecision::Queue(group.gid()),
DownloadStatus::Paused => ScheduleDecision::Pause(group.gid()),
DownloadStatus::Removed => ScheduleDecision::Remove(group.gid()),
DownloadStatus::Error => ScheduleDecision::RetryLater(group.gid()),
DownloadStatus::Complete => ScheduleDecision::Noop,
DownloadStatus::Active => ScheduleDecision::RunNow(group.gid()),
}
}
/// Advances the scheduler lifecycle by one tick.
#[must_use]
pub fn tick(&mut self) -> SchedulerState {
self.activity_counters.tick_count = self.activity_counters.tick_count.saturating_add(1);
self.state = match self.state {
SchedulerState::Idle => SchedulerState::Ready,
SchedulerState::Ready | SchedulerState::Running => SchedulerState::Running,
SchedulerState::Paused => SchedulerState::Paused,
SchedulerState::ShuttingDown | SchedulerState::Stopped => SchedulerState::Stopped,
};
self.state
}
/// Builds the segment-plan snapshot mirrored into session state.
#[must_use]
pub fn bridge_segment_plan(&self, runtime: &RuntimeConfig) -> SegmentPlan {
let _ = self;
SegmentPlan::from_runtime(runtime)
}
/// Builds the runtime-state snapshot mirrored into session state.
pub fn bridge_runtime_state(
&self,
completed_length: u64,
retry_count: u32,
retry_history: Vec<RetryHistoryEntry>,
active_segments: usize,
) -> RuntimeScheduleState {
let _ = self;
RuntimeScheduleState {
completed_length,
retry_count,
retry_history,
active_segments,
}
}
/// Computes the number of active segments the scheduler should plan.
#[must_use]
pub fn plan_active_segments(&self, group: &RequestGroup, runtime: &RuntimeConfig) -> usize {
if !matches!(
group.status(),
DownloadStatus::Active | DownloadStatus::Waiting
) {
return 0;
}
let split = runtime.effective_split();
let max_conn = runtime.effective_max_connections_per_server();
let max_parallel = split.min(max_conn).max(1);
let min_split_size = runtime.min_split_size.max(1);
let total = Self::effective_plannable_total_length(group);
let completed = group.completed_length().min(total);
let remaining = total.saturating_sub(completed);
if remaining == 0 {
return 0;
}
let by_size = usize::try_from(remaining.div_ceil(min_split_size)).unwrap_or(usize::MAX);
max_parallel.min(by_size.max(1))
}
/// Captures a planning observation for later inspection and persistence.
pub fn observe_plan(
&mut self,
group: &RequestGroup,
_runtime: &RuntimeConfig,
planned_segments: usize,
) {
let plannable_length = Self::effective_plannable_total_length(group);
let completed_length = group.completed_length().min(plannable_length);
let remaining_bytes = plannable_length.saturating_sub(completed_length);
let pressure = group.bt_pressure_snapshot();
self.last_planning_observation = Some(SchedulerPlanningObservation {
gid: group.gid(),
total_length: group.total_length(),
plannable_length,
completed_length,
remaining_bytes,
planned_segments,
active_segment_count: usize::try_from(group.num_connections()).unwrap_or(usize::MAX),
requestable_pieces: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.requestable_pieces),
active_piece_count: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.active_pieces),
available_requestable_pieces: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.available_requestable_pieces),
scarce_requestable_pieces: pressure
.as_ref()
.map_or(0, |snapshot| snapshot.scarce_requestable_pieces),
peer_count: pressure.as_ref().map_or(0, |snapshot| snapshot.peer_count),
bt_endgame_ready: pressure.as_ref().is_some_and(|snapshot| {
snapshot.requestable_pieces > 0
&& snapshot.requestable_pieces <= planned_segments.max(1)
}),
});
}
/// Selects candidate `BitTorrent` pieces that are eligible for requests.
pub fn select_bt_piece_candidates(
&self,
group: &RequestGroup,
options: BtPieceSelectionOptions,
) -> Vec<PieceId> {
let _ = self;
group.bt_requestable_piece_ids(
options.endgame_mode && options.include_downloading_in_endgame,
options.max_candidates.max(1),
)
}
/// Builds piece-aligned byte ranges for the selected `BitTorrent` pieces.
pub fn plan_bt_piece_request_ranges(
&self,
group: &RequestGroup,
runtime: &RuntimeConfig,
options: BtPieceSelectionOptions,
) -> Vec<PieceRange> {
let _ = self;
let piece_length = runtime.piece_length.max(1);
self.select_bt_piece_candidates(group, options)
.into_iter()
.map(|piece_id| {
let start = u64::from(piece_id.0).saturating_mul(piece_length);
PieceRange::new(start, start.saturating_add(piece_length))
})
.collect()
}
/// Counts pieces that still require `BitTorrent` work.
pub fn bt_remaining_piece_count(&self, group: &RequestGroup) -> usize {
let _ = self;
group
.piece_map()
.iter()
.filter(|(_, state)| {
matches!(
state,
PieceState::Pending
| PieceState::Queued
| PieceState::Missing
| PieceState::Downloading
)
})
.count()
}
}
#[cfg(test)]
mod scheduler_tests;
@@ -0,0 +1,287 @@
use super::*;
use crate::{
piece::{PieceId, PieceState},
request::{BtFileInfo, BtPeerInfo, BtRuntimeState, DownloadId},
};
#[test]
fn segment_plan_uses_runtime_limits() {
let runtime = RuntimeConfig {
split: 8,
max_connections_per_server: 3,
max_connection_per_server: 2,
min_split_size: 1024,
..RuntimeConfig::default()
};
let plan = SegmentPlan::from_runtime(&runtime);
assert_eq!(plan.split, 8);
assert_eq!(plan.max_connections_per_server, 3);
assert_eq!(plan.min_split_size, 1024);
}
#[test]
fn plan_active_segments_respects_remaining_size_and_limits() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 6,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(1), "https://example.org/file.bin");
group.set_status(DownloadStatus::Active);
group.set_total_length(10 * 1024);
group.set_completed_length(2 * 1024);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 4);
group.set_completed_length(9 * 1024 + 900);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 1);
group.set_completed_length(group.total_length());
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
}
#[test]
fn plan_active_segments_returns_zero_for_non_runnable_states() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig::default();
let mut group = RequestGroup::new(DownloadId::new(2), "https://example.org/file.bin");
group.set_total_length(2048);
group.set_completed_length(0);
group.set_status(DownloadStatus::Paused);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
group.set_status(DownloadStatus::Error);
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
}
#[test]
fn plan_active_segments_returns_zero_for_bt_metadata_only() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(3), "magnet:?xt=urn:btih:ABC");
group.set_status(DownloadStatus::Active);
group.set_total_length(8 * 1024);
group.set_completed_length(1024);
group.set_bt(BtRuntimeState {
metadata_only: true,
..BtRuntimeState::default()
});
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
}
#[test]
fn plan_active_segments_uses_bt_selected_files_length() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 8,
max_connections_per_server: 8,
max_connection_per_server: 8,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(4), "magnet:?xt=urn:btih:DEF");
group.set_status(DownloadStatus::Active);
group.set_total_length(10 * 1024);
group.set_completed_length(3500);
group.set_bt(BtRuntimeState {
files: vec![
BtFileInfo {
path: "wanted.bin".to_owned(),
length: 4 * 1024,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "unwanted.bin".to_owned(),
length: 6 * 1024,
piece_offset: Some(4),
selected: false,
},
],
..BtRuntimeState::default()
});
// Selected total is 4096; after completed 3500 only 596 bytes remain,
// so the scheduler should avoid over-planning and keep a single segment.
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 1);
}
#[test]
fn scheduler_selects_bt_piece_candidates_and_endgame_behavior() {
let scheduler = Scheduler::new();
let mut group = RequestGroup::new(DownloadId::new(5), "magnet:?xt=urn:btih:FFF");
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Missing);
group.set_piece_state(PieceId(3), PieceState::Downloading);
group.set_piece_state(PieceId(4), PieceState::Queued);
let normal = scheduler.select_bt_piece_candidates(
&group,
BtPieceSelectionOptions {
max_candidates: 8,
include_downloading_in_endgame: true,
endgame_mode: false,
},
);
assert_eq!(normal, vec![PieceId(1), PieceId(2), PieceId(4)]);
let endgame = scheduler.select_bt_piece_candidates(
&group,
BtPieceSelectionOptions {
max_candidates: 8,
include_downloading_in_endgame: true,
endgame_mode: true,
},
);
assert_eq!(
endgame,
vec![PieceId(1), PieceId(2), PieceId(4), PieceId(3)]
);
}
#[test]
fn scheduler_plans_bt_piece_ranges_from_runtime_piece_length() {
let scheduler = Scheduler::new();
let runtime = RuntimeConfig {
piece_length: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(6), "magnet:?xt=urn:btih:GGG");
group.set_piece_state(PieceId(2), PieceState::Pending);
group.set_piece_state(PieceId(5), PieceState::Missing);
let ranges = scheduler.plan_bt_piece_request_ranges(
&group,
&runtime,
BtPieceSelectionOptions {
max_candidates: 2,
include_downloading_in_endgame: false,
endgame_mode: false,
},
);
assert_eq!(
ranges,
vec![PieceRange::new(2048, 3072), PieceRange::new(5120, 6144)]
);
}
#[test]
fn runtime_schedule_state_reports_endgame_readiness() {
assert!(RuntimeScheduleState::is_endgame_ready(1, 3));
assert!(RuntimeScheduleState::is_endgame_ready(3, 3));
assert!(!RuntimeScheduleState::is_endgame_ready(4, 3));
assert!(!RuntimeScheduleState::is_endgame_ready(0, 3));
}
#[test]
fn scheduler_activity_counters_track_ticks_and_decisions() {
let mut scheduler = Scheduler::new();
assert_eq!(scheduler.activity_counters().tick_count, 0);
assert_eq!(scheduler.activity_counters().schedule_run_count, 0);
scheduler.record_schedule_run();
let _ = scheduler.tick();
scheduler.record_decision(&ScheduleDecision::Queue(DownloadId::new(0x21)));
scheduler.record_decision(&ScheduleDecision::RunNow(DownloadId::new(0x21)));
scheduler.record_decision(&ScheduleDecision::RetryLater(DownloadId::new(0x21)));
scheduler.record_decision(&ScheduleDecision::Noop);
let counters = scheduler.activity_counters();
assert_eq!(counters.tick_count, 1);
assert_eq!(counters.schedule_run_count, 1);
assert_eq!(counters.queue_decision_count, 1);
assert_eq!(counters.run_now_decision_count, 1);
assert_eq!(counters.retry_later_decision_count, 1);
assert_eq!(counters.noop_decision_count, 1);
assert_eq!(counters.last_decision, Some(ScheduleDecisionKind::Noop));
}
#[test]
fn scheduler_records_last_planning_observation_for_bt_pressure() {
let mut scheduler = Scheduler::new();
let runtime = RuntimeConfig {
split: 5,
max_connections_per_server: 3,
max_connection_per_server: 3,
min_split_size: 1024,
..RuntimeConfig::default()
};
let mut group = RequestGroup::new(DownloadId::new(0x22), "magnet:?xt=urn:btih:PRESSURE");
group.set_status(DownloadStatus::Active);
group.set_total_length(6 * 1024);
group.set_completed_length(1024);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Downloading);
group.set_piece_state(PieceId(3), PieceState::Queued);
group.set_piece_state(PieceId(4), PieceState::Missing);
group.set_bt(BtRuntimeState {
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 6 * 1024,
piece_offset: Some(0),
selected: true,
}],
peers: vec![
BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "192.0.2.1".to_owned(),
port: 6881,
client_name: None,
interested: true,
choked: false,
download_speed: 64,
upload_speed: 32,
seeder: false,
},
BtPeerInfo {
peer_id: Some("peer-b".to_owned()),
ip: "192.0.2.2".to_owned(),
port: 6882,
client_name: None,
interested: false,
choked: true,
download_speed: 0,
upload_speed: 16,
seeder: true,
},
],
..BtRuntimeState::default()
});
group.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
piece_id: PieceId(1),
peers_with_piece: 1,
});
group.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
piece_id: PieceId(3),
peers_with_piece: 2,
});
let planned = scheduler.plan_active_segments(&group, &runtime);
scheduler.observe_plan(&group, &runtime, planned);
let observation = scheduler
.last_planning_observation()
.expect("planning observation should be recorded");
assert_eq!(observation.gid, DownloadId::new(0x22));
assert_eq!(observation.planned_segments, 3);
assert_eq!(observation.remaining_bytes, 5 * 1024);
assert_eq!(observation.requestable_pieces, 3);
assert_eq!(observation.active_piece_count, 2);
assert_eq!(observation.available_requestable_pieces, 2);
assert_eq!(observation.scarce_requestable_pieces, 1);
assert_eq!(observation.peer_count, 2);
assert!(observation.bt_endgame_ready);
}
+564
View File
@@ -0,0 +1,564 @@
//! Session state, runtime option projection, and persistence bridge snapshots.
use std::{collections::BTreeMap, path::PathBuf};
use crate::{
error::{CoreError, Result},
options::{OptionKey, OptionPatch, OptionValue},
progress::GlobalStat,
runtime::{RuntimeConfig, parse_human_size_text},
scheduler::{
RetryHistoryEntry, RuntimeScheduleState, SchedulerActivityCounters,
SchedulerPlanningObservation, SegmentPlan,
},
};
/// High-level lifecycle state for the session and engine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionState {
/// No active runtime work is happening.
Idle,
/// The runtime is actively processing downloads.
Running,
/// Work is paused but can be resumed.
Paused,
/// Session data is currently being saved.
Saving,
/// Graceful shutdown has been requested.
ShuttingDown,
/// Forced shutdown has been requested.
ForceShuttingDown,
/// The runtime has stopped.
Stopped,
}
/// Global option store applied across downloads.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GlobalOptions {
/// Stored global option values keyed by option name.
values: BTreeMap<OptionKey, OptionValue>,
}
impl GlobalOptions {
/// Creates an empty global option store.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets or replaces a global option value.
pub fn set(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
self.values.insert(key.into(), value.into());
}
/// Returns a global option value when present.
#[must_use]
pub fn get(&self, key: &OptionKey) -> Option<&OptionValue> {
self.values.get(key)
}
/// Returns all stored global options.
#[must_use]
pub fn values(&self) -> &BTreeMap<OptionKey, OptionValue> {
&self.values
}
/// Applies every entry from the provided patch.
pub fn apply_patch(&mut self, patch: OptionPatch) {
self.values.extend(patch.entries().clone());
}
}
/// Target used when saving or loading a session snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SaveSessionTarget {
/// Use the in-memory snapshot slot.
Memory,
/// Use a snapshot associated with a persisted path.
Path(PathBuf),
}
/// In-memory session state plus persisted snapshots and scheduler bridge data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Session {
/// Runtime configuration projected from defaults and global options.
runtime: RuntimeConfig,
/// Current high-level session state.
state: SessionState,
/// Global option store applied to all downloads.
global_options: GlobalOptions,
/// Aggregated transfer statistics.
stats: GlobalStat,
/// Preferred session file path when persistence is configured.
session_file: Option<PathBuf>,
/// In-memory snapshot slot used for round trips.
memory_snapshot: Option<SessionSnapshot>,
/// Path-keyed snapshots saved during the current process lifetime.
path_snapshots: BTreeMap<PathBuf, SessionSnapshot>,
/// Mirrored scheduler and runtime bridge data.
bridge: SessionBridge,
}
/// Internal saved session payload used for in-memory and path snapshots.
#[derive(Clone, Debug, Eq, PartialEq)]
/// Saved session payload mirrored into in-memory and path snapshots.
struct SessionSnapshot {
/// Global options captured at save time.
global_options: GlobalOptions,
/// Global statistics captured at save time.
stats: GlobalStat,
/// Scheduler bridge data captured at save time.
bridge: SessionBridge,
}
/// Scheduler and runtime data mirrored into the session snapshot.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SessionBridge {
/// Effective split count mirrored from scheduler planning.
pub split: usize,
/// Last active segment plan when present.
pub segment_plan: Option<SegmentPlan>,
/// Completed payload length mirrored from runtime state.
pub completed_length: u64,
/// Accumulated retry count mirrored from runtime state.
pub retry_count: u32,
/// Retry history mirrored from runtime state.
pub retry_history: Vec<RetryHistoryEntry>,
/// Number of active segments mirrored from runtime state.
pub active_segments: usize,
/// Scheduler counters mirrored into the session snapshot.
pub scheduler_counters: SchedulerActivityCounters,
/// Most recent scheduler planning observation when available.
pub last_scheduler_plan: Option<SchedulerPlanningObservation>,
}
impl Session {
/// Creates a new session from runtime configuration defaults.
#[must_use]
pub fn new(runtime: RuntimeConfig) -> Self {
let bridge = SessionBridge::from_runtime(&runtime);
let session_file = runtime.session_path.clone();
Self {
session_file,
runtime,
state: SessionState::Idle,
global_options: GlobalOptions::default(),
stats: GlobalStat::default(),
memory_snapshot: None,
path_snapshots: BTreeMap::new(),
bridge,
}
}
/// Returns the projected runtime configuration.
#[must_use]
pub fn runtime(&self) -> &RuntimeConfig {
&self.runtime
}
/// Returns the current session state.
#[must_use]
pub fn state(&self) -> &SessionState {
&self.state
}
/// Returns the global option store.
#[must_use]
pub fn global_options(&self) -> &GlobalOptions {
&self.global_options
}
/// Returns a mutable reference to the global option store.
pub fn global_options_mut(&mut self) -> &mut GlobalOptions {
&mut self.global_options
}
/// Returns the global statistics snapshot.
#[must_use]
pub fn stats(&self) -> &GlobalStat {
&self.stats
}
/// Returns a mutable reference to the global statistics snapshot.
pub fn stats_mut(&mut self) -> &mut GlobalStat {
&mut self.stats
}
/// Returns the current session file path when configured.
#[must_use]
pub fn session_file(&self) -> Option<&PathBuf> {
self.session_file.as_ref()
}
/// Sets the session file path.
pub fn set_session_file(&mut self, path: impl Into<PathBuf>) {
self.session_file = Some(path.into());
}
/// Marks a session as loaded from an external source path.
pub fn mark_external_load(&mut self, path: impl Into<PathBuf>) {
self.session_file = Some(path.into());
self.state = SessionState::Idle;
}
/// Moves the session into the paused state.
pub fn pause(&mut self) -> Result<()> {
self.state = SessionState::Paused;
Ok(())
}
/// Moves the session into the running state.
pub fn resume(&mut self) -> Result<()> {
self.state = SessionState::Running;
Ok(())
}
/// Starts graceful shutdown.
pub fn shutdown(&mut self) -> Result<()> {
self.state = SessionState::ShuttingDown;
Ok(())
}
/// Starts forced shutdown.
pub fn force_shutdown(&mut self) -> Result<()> {
self.state = SessionState::ForceShuttingDown;
Ok(())
}
/// Saves the current session snapshot to memory or a path target.
pub fn save_session(&mut self, target: SaveSessionTarget) -> Result<()> {
self.state = SessionState::Saving;
let snapshot = SessionSnapshot {
global_options: self.global_options.clone(),
stats: self.stats,
bridge: self.bridge.clone(),
};
match target {
SaveSessionTarget::Memory => {
self.memory_snapshot = Some(snapshot);
Ok(())
}
SaveSessionTarget::Path(path) => {
self.path_snapshots.insert(path.clone(), snapshot);
self.session_file = Some(path);
Ok(())
}
}
}
/// Loads a previously saved session snapshot.
pub fn load_session(&mut self, source: SaveSessionTarget) -> Result<()> {
let snapshot = match source {
SaveSessionTarget::Memory => self
.memory_snapshot
.as_ref()
.ok_or(CoreError::StorageUnavailable(
"no in-memory session snapshot available",
))?
.clone(),
SaveSessionTarget::Path(path) => {
self.session_file = Some(path.clone());
self.path_snapshots
.get(&path)
.ok_or(CoreError::StorageUnavailable(
"no session snapshot available for requested path",
))?
.clone()
}
};
self.global_options = snapshot.global_options;
self.stats = snapshot.stats;
self.bridge = snapshot.bridge;
self.refresh_runtime_from_global_options();
self.state = SessionState::Idle;
Ok(())
}
/// Sets and immediately applies a single global option.
pub fn set_global_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
let key = key.into();
let value = value.into();
self.apply_runtime_option(&key, &value);
self.global_options.set(key, value);
}
/// Applies and stores a patch of global options.
pub fn apply_global_option_patch(&mut self, patch: OptionPatch) {
for (key, value) in patch.entries() {
self.apply_runtime_option(key, value);
}
self.global_options.apply_patch(patch);
}
/// Returns the mirrored scheduler bridge snapshot.
#[must_use]
pub fn bridge(&self) -> &SessionBridge {
&self.bridge
}
/// Returns a mutable scheduler bridge snapshot.
pub fn bridge_mut(&mut self) -> &mut SessionBridge {
&mut self.bridge
}
/// Updates the active segment plan stored in the session bridge.
pub fn set_segment_plan(&mut self, segment_plan: SegmentPlan) {
self.bridge.split = segment_plan.split;
self.bridge.segment_plan = Some(segment_plan);
}
/// Mirrors scheduler runtime state into the bridge snapshot.
pub fn apply_runtime_schedule_state(&mut self, state: RuntimeScheduleState) {
self.bridge.completed_length = state.completed_length;
self.bridge.retry_count = state.retry_count;
self.bridge.retry_history = state.retry_history;
self.bridge.active_segments = state.active_segments;
}
/// Mirrors scheduler instrumentation into the bridge snapshot.
pub fn apply_scheduler_instrumentation(
&mut self,
counters: SchedulerActivityCounters,
last_plan: Option<SchedulerPlanningObservation>,
) {
self.bridge.scheduler_counters = counters;
self.bridge.last_scheduler_plan = last_plan;
}
/// Applies a single stored option onto the projected runtime config.
fn apply_runtime_option(&mut self, key: &OptionKey, value: &OptionValue) {
match key.as_str() {
"max-overall-download-limit" => {
self.runtime.max_overall_download_limit = parse_optional_limit(value);
}
"max-download-limit" => {
self.runtime.max_download_limit = parse_optional_limit(value);
}
"max-overall-upload-limit" => {
self.runtime.max_overall_upload_limit = parse_optional_limit(value);
}
"max-upload-limit" => {
self.runtime.max_upload_limit = parse_optional_limit(value);
}
"disk-cache" => {
if let Some(value) = parse_option_size(value) {
self.runtime.disk_cache_bytes = value;
}
}
_ => {}
}
}
/// Rebuilds runtime projection from every stored global option.
fn refresh_runtime_from_global_options(&mut self) {
let entries = self.global_options.values().clone();
for (key, value) in &entries {
self.apply_runtime_option(key, value);
}
}
}
impl SessionBridge {
/// Builds a bridge snapshot from runtime defaults.
#[must_use]
pub fn from_runtime(runtime: &RuntimeConfig) -> Self {
Self {
split: runtime.effective_split(),
segment_plan: Some(SegmentPlan::from_runtime(runtime)),
completed_length: 0,
retry_count: 0,
retry_history: Vec::new(),
active_segments: 0,
scheduler_counters: SchedulerActivityCounters::default(),
last_scheduler_plan: None,
}
}
}
/// Parses a single option value into a byte count when possible.
fn parse_option_size(value: &OptionValue) -> Option<u64> {
match value {
OptionValue::UInt(value) => Some(*value),
OptionValue::Int(value) => u64::try_from(*value).ok(),
OptionValue::Text(value) => parse_human_size_text(value),
_ => None,
}
}
/// Parses a positive byte-sized limit value from an option.
fn parse_optional_limit(value: &OptionValue) -> Option<u64> {
parse_option_size(value).filter(|limit| *limit > 0)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::{
error::{CoreError, Result},
runtime::RuntimeConfig,
scheduler::{RetryHistoryEntry, RuntimeScheduleState, SegmentPlan},
session::{SaveSessionTarget, Session},
};
#[test]
fn session_round_trip_memory_snapshot() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
session.set_global_option("max-concurrent-downloads", "8");
session.stats_mut().download_speed = 1024;
session.save_session(SaveSessionTarget::Memory)?;
session.set_global_option("max-concurrent-downloads", "1");
session.stats_mut().download_speed = 1;
session.load_session(SaveSessionTarget::Memory)?;
assert_eq!(
session
.global_options()
.get(&"max-concurrent-downloads".into())
.and_then(|v| v.as_text()),
Some("8")
);
assert_eq!(session.stats().download_speed, 1024);
Ok(())
}
#[test]
fn session_load_path_snapshot_updates_runtime_target() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
let path = PathBuf::from("session-a2.txt");
session.set_global_option("dir", "/srv/aria2/a2");
session.save_session(SaveSessionTarget::Path(path.clone()))?;
session.set_global_option("dir", "/srv/aria2/override");
session.load_session(SaveSessionTarget::Path(path.clone()))?;
assert_eq!(session.session_file(), Some(&path));
assert_eq!(
session
.global_options()
.get(&"dir".into())
.and_then(|v| v.as_text()),
Some("/srv/aria2/a2")
);
Ok(())
}
#[test]
fn session_load_missing_snapshot_reports_storage_unavailable() {
let mut session = Session::new(RuntimeConfig::default());
let result = session.load_session(SaveSessionTarget::Path(PathBuf::from("missing.txt")));
assert_eq!(
result,
Err(CoreError::StorageUnavailable(
"no session snapshot available for requested path"
))
);
}
#[test]
fn session_bridge_round_trip_persists_completed_and_retry_state() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
session.set_segment_plan(SegmentPlan {
split: 6,
min_split_size: 1024,
piece_length: 1024,
max_connections_per_server: 3,
});
session.apply_runtime_schedule_state(RuntimeScheduleState {
completed_length: 8192,
retry_count: 2,
retry_history: vec![RetryHistoryEntry {
at_unix_secs: 1_700_000_000,
reason: "http 403".to_string(),
}],
active_segments: 2,
});
session.save_session(SaveSessionTarget::Memory)?;
session.apply_runtime_schedule_state(RuntimeScheduleState {
completed_length: 4,
retry_count: 0,
retry_history: Vec::new(),
active_segments: 0,
});
session.load_session(SaveSessionTarget::Memory)?;
assert_eq!(session.bridge().split, 6);
assert_eq!(session.bridge().completed_length, 8192);
assert_eq!(session.bridge().retry_count, 2);
assert_eq!(session.bridge().retry_history.len(), 1);
assert_eq!(session.bridge().active_segments, 2);
Ok(())
}
#[test]
fn session_bridge_round_trip_persists_scheduler_instrumentation() -> Result<()> {
let mut session = Session::new(RuntimeConfig::default());
let counters = crate::scheduler::SchedulerActivityCounters {
tick_count: 3,
schedule_run_count: 2,
queue_decision_count: 1,
run_now_decision_count: 1,
last_decision: Some(crate::scheduler::ScheduleDecisionKind::RunNow),
..Default::default()
};
session.bridge_mut().scheduler_counters = counters;
session.bridge_mut().last_scheduler_plan =
Some(crate::scheduler::SchedulerPlanningObservation {
gid: crate::request::DownloadId::new(0x44),
total_length: 4096,
plannable_length: 4096,
completed_length: 1024,
remaining_bytes: 3072,
planned_segments: 3,
active_segment_count: 2,
requestable_pieces: 2,
active_piece_count: 1,
available_requestable_pieces: 1,
scarce_requestable_pieces: 1,
peer_count: 4,
bt_endgame_ready: true,
});
session.save_session(SaveSessionTarget::Memory)?;
session.bridge_mut().scheduler_counters =
crate::scheduler::SchedulerActivityCounters::default();
session.bridge_mut().last_scheduler_plan = None;
session.load_session(SaveSessionTarget::Memory)?;
assert_eq!(session.bridge().scheduler_counters, counters);
assert_eq!(
session
.bridge()
.last_scheduler_plan
.as_ref()
.map(|plan| plan.remaining_bytes),
Some(3072)
);
Ok(())
}
#[test]
fn session_global_speed_and_cache_options_mutate_runtime_surface() {
let mut session = Session::new(RuntimeConfig::default());
session.set_global_option("max-overall-download-limit", "8M");
session.set_global_option("max-download-limit", "2M");
session.set_global_option("max-overall-upload-limit", "4M");
session.set_global_option("max-upload-limit", "1M");
session.set_global_option("disk-cache", "32M");
assert_eq!(
session.runtime().max_overall_download_limit,
Some(8 * 1024 * 1024)
);
assert_eq!(session.runtime().max_download_limit, Some(2 * 1024 * 1024));
assert_eq!(
session.runtime().max_overall_upload_limit,
Some(4 * 1024 * 1024)
);
assert_eq!(session.runtime().max_upload_limit, Some(1024 * 1024));
assert_eq!(session.runtime().disk_cache_bytes, 32 * 1024 * 1024);
}
}