chore: initial sanitized public snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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 {}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user