chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:16:57 +08:00
commit 17688c3e34
321 changed files with 76859 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "aria2-rust-pro-tests"
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_tests"
path = "src/lib.rs"
[dev-dependencies]
aria2-rust-pro-cli.workspace = true
aria2-rust-pro-compat.workspace = true
aria2-rust-pro-core.workspace = true
aria2-rust-pro-protocol.workspace = true
aria2-rust-pro-rpc.workspace = true
aria2-rust-pro-storage.workspace = true
criterion = "0.5.1"
[[bench]]
name = "rpc_pressure"
harness = false
[lints]
workspace = true
@@ -0,0 +1,63 @@
//! Criterion pressure benches for shared-runtime RPC and transfer paths.
//!
//! The benchmark suite keeps intentionally explicit arithmetic and indexing so the
//! expected request/throughput math remains easy to audit when performance
//! regressions are investigated.
#![forbid(unsafe_code)]
#![doc(hidden)]
#![expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
clippy::cast_possible_truncation,
clippy::default_trait_access,
clippy::indexing_slicing,
clippy::integer_division,
clippy::too_many_lines,
reason = "pressure benches keep explicit scenario math and fixture setup for auditability"
)]
use aria2_rust_pro_compat as _;
use aria2_rust_pro_storage as _;
use aria2_rust_pro_tests as _;
use criterion::{criterion_group, criterion_main};
#[path = "rpc_pressure/bt_visibility.rs"]
mod bt_visibility;
#[path = "rpc_pressure/live_http_transfer.rs"]
mod live_http_transfer;
#[path = "rpc_pressure/rpc_runtime_pressure.rs"]
mod rpc_runtime_pressure;
#[path = "rpc_pressure/runtime_engine_pressure.rs"]
mod runtime_engine_pressure;
#[path = "rpc_pressure/support.rs"]
mod support;
use bt_visibility::bench_bt_visibility_pressure;
use live_http_transfer::{
bench_live_http_multi_download_contention_pressure,
bench_live_http_shared_runtime_multi_download_pressure,
bench_live_http_transfer_contention_pressure,
};
use rpc_runtime_pressure::{
bench_mixed_rpc_pressure, bench_resource_limit_pressure,
bench_shared_runtime_fairness_pressure, bench_tell_status_pressure,
};
use runtime_engine_pressure::{
bench_runtime_snapshot_pressure, bench_scheduler_backpressure_pressure,
};
criterion_group!(
pressure_benches,
bench_tell_status_pressure,
bench_mixed_rpc_pressure,
bench_runtime_snapshot_pressure,
bench_resource_limit_pressure,
bench_shared_runtime_fairness_pressure,
bench_bt_visibility_pressure,
bench_scheduler_backpressure_pressure,
bench_live_http_transfer_contention_pressure,
bench_live_http_multi_download_contention_pressure,
bench_live_http_shared_runtime_multi_download_pressure
);
criterion_main!(pressure_benches);
@@ -0,0 +1,202 @@
#![expect(
clippy::redundant_pub_crate,
reason = "criterion bench entry points are re-exported only to the private bench root module"
)]
use super::support::{
BT_TORRENT_FIXTURE, BenchmarkId, Criterion, InProcessRpcDispatcher, RpcMethod, RpcValue,
RuntimeConfig, Throughput, TorrentPeerModel, TrackerPeerListModel, TrackerResponseModel,
rpc_request,
};
#[derive(Clone, Copy, Debug)]
struct BtVisibilityPressureScenario {
task_count: usize,
rounds: usize,
}
fn seed_bt_visibility_dispatcher(
scenario: BtVisibilityPressureScenario,
) -> (InProcessRpcDispatcher, Vec<String>) {
let runtime = RuntimeConfig {
allow_jsonrpc: true,
allow_xmlrpc: true,
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
piece_length: 1024,
..RuntimeConfig::default()
};
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
let mut gids = Vec::with_capacity(scenario.task_count);
for index in 0..scenario.task_count {
let add_response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddTorrent,
vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())],
));
let gid = match add_response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result in visibility seed: {other:?}"),
};
dispatcher
.apply_tracker_announce_result(
&gid,
&TrackerResponseModel {
peers: TrackerPeerListModel {
interval_sec: 900,
peers: vec![TorrentPeerModel {
ip: format!("198.51.100.{}", (index % 200) + 1),
port: 51413 + (index as u16 % 32),
peer_id: None,
client_name: None,
interested: false,
choked: false,
}],
min_interval_sec: None,
tracker_id: Some(format!("bench-{index}")),
},
scrape: None,
},
)
.expect("tracker seed should populate visible bt peers");
dispatcher
.apply_bt_runtime_tick(
&gid,
8_192 + (index as u64 % 4) * 2_048,
if index % 3 == 0 { 4_096 } else { 0 },
512 + index as u64,
128 + index as u64,
if index % 3 == 0 { 5 } else { 0 },
if index % 3 == 0 { 5 } else { 0 },
index % 3 == 0,
Some(2 + (index % 6) as u32),
)
.expect("runtime tick should seed visible bt progress");
if index % 3 == 0 {
dispatcher
.set_bt_seeding_state(&gid, true, Some(1_000 + index as u64))
.expect("seeded torrents should enter seeding");
dispatcher
.tick_bt_runtime_clock(&gid, 1_010 + index as u64, true)
.expect("seeded torrents should advance share clocks");
}
gids.push(gid);
}
(dispatcher, gids)
}
fn run_bt_visibility_pressure(
dispatcher: &mut InProcessRpcDispatcher,
gids: &[String],
scenario: BtVisibilityPressureScenario,
) -> usize {
let mut calls = 0_usize;
for round in 0..scenario.rounds {
for (index, gid) in gids.iter().enumerate() {
dispatcher
.apply_bt_runtime_tick(
gid,
if round % 2 == 0 { 512 } else { 0 },
if round % 2 == 1 { 256 } else { 0 },
1_024 + round as u64 + index as u64,
256 + round as u64 + index as u64,
u64::from(index % 3 == 0),
u64::from(index % 3 == 0),
index % 3 == 0,
Some(2 + ((round + index) % 6) as u32),
)
.expect("pressure tick should keep bt runtime visible");
let tell_status = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match tell_status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(payload.contains_key("announceList"));
assert!(payload.contains_key("bitfield"));
assert!(payload.contains_key("shareRatio"));
assert!(payload.contains_key("shareTime"));
assert!(payload.contains_key("files"));
assert!(payload.contains_key("seeder"));
}
other => panic!("unexpected tellStatus payload in bt visibility bench: {other:?}"),
}
calls += 1;
let get_files = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
));
match get_files.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(file)) => {
assert!(file.contains_key("selected"));
assert!(file.contains_key("completedLength"));
assert!(file.contains_key("bitfield"));
}
other => {
panic!("unexpected getFiles payload in bt visibility bench: {other:?}")
}
},
other => panic!("unexpected getFiles result in bt visibility bench: {other:?}"),
}
calls += 1;
let get_peers = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
));
match get_peers.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(peer)) => {
assert!(peer.contains_key("ip"));
assert!(peer.contains_key("port"));
assert!(peer.contains_key("peerChoking"));
}
other => {
panic!("unexpected getPeers payload in bt visibility bench: {other:?}")
}
},
other => panic!("unexpected getPeers result in bt visibility bench: {other:?}"),
}
calls += 1;
}
}
calls
}
pub(super) fn bench_bt_visibility_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("bt_visibility_pressure");
for scenario in [
BtVisibilityPressureScenario {
task_count: 32,
rounds: 4,
},
BtVisibilityPressureScenario {
task_count: 64,
rounds: 4,
},
] {
group.throughput(Throughput::Elements(
(scenario.task_count * scenario.rounds * 3) as u64,
));
group.bench_with_input(
BenchmarkId::new("bt_visibility", scenario.task_count),
&scenario,
|b, &scenario| {
b.iter_batched(
|| seed_bt_visibility_dispatcher(scenario),
|(mut dispatcher, gids)| {
let calls = run_bt_visibility_pressure(&mut dispatcher, &gids, scenario);
assert_eq!(calls, scenario.task_count * scenario.rounds * 3);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
@@ -0,0 +1,602 @@
#![expect(
clippy::redundant_pub_crate,
reason = "criterion bench entry points are re-exported only to the private bench root module"
)]
use super::support::{
Arc, AtomicBool, BTreeMap, BenchmarkId, ConnectorBackedDownloader, Criterion, Duration,
Instant, Invocation, Mutex, Ordering, PathBuf, Read, ReqwestHttpConnector, SocketAddr,
SystemTime, TcpListener, TcpStream, Throughput, UNIX_EPOCH, Write,
execute_runtime_with_downloader, fs, thread,
};
const LIVE_TRANSFER_SPLIT: usize = 4;
const LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER: usize = 4;
#[derive(Clone, Copy, Debug)]
struct LiveTransferScenario {
label: &'static str,
total_length: usize,
piece_length: usize,
overall_download_limit: Option<usize>,
disk_cache_bytes: Option<usize>,
response_delay_ms: u64,
}
#[derive(Clone, Copy, Debug)]
struct LiveTransferContentionScenario {
label: &'static str,
download_count: usize,
total_length: usize,
piece_length: usize,
overall_download_limit: Option<usize>,
disk_cache_bytes: Option<usize>,
response_delay_ms: u64,
}
#[derive(Clone, Debug, Default)]
struct SegmentRequestMetrics {
total_requests: usize,
requests_by_path: BTreeMap<String, usize>,
}
struct TempConfigFile {
path: PathBuf,
}
impl TempConfigFile {
fn new(contents: &str) -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("aria2-rust-pro-{unique}.conf"));
fs::write(&path, contents).expect("benchmark config should write");
Self { path }
}
const fn path(&self) -> &PathBuf {
&self.path
}
}
impl Drop for TempConfigFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
struct LocalHttpSegmentServer {
base_url: String,
metrics: Arc<Mutex<SegmentRequestMetrics>>,
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
listen_address: SocketAddr,
}
impl LocalHttpSegmentServer {
fn spawn(scenario: LiveTransferScenario) -> Self {
let expected_requests_per_download = scenario.total_length.div_ceil(scenario.piece_length);
Self::spawn_many(scenario, 1, expected_requests_per_download)
}
fn spawn_many(
scenario: LiveTransferScenario,
_download_count: usize,
_expected_requests_per_download: usize,
) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("loopback listener should bind");
let addr = listener
.local_addr()
.expect("loopback listener should report local addr");
let piece_length = scenario.piece_length;
let total_length = scenario.total_length;
let response_delay = Duration::from_millis(scenario.response_delay_ms);
let payload_bytes = Arc::<[u8]>::from(vec![b'x'; total_length]);
let metrics = Arc::new(Mutex::new(SegmentRequestMetrics::default()));
let metrics_for_thread = Arc::clone(&metrics);
let stop = Arc::new(AtomicBool::new(false));
let stop_for_thread = Arc::clone(&stop);
let handle = thread::spawn(move || {
loop {
let (stream, _) = listener
.accept()
.unwrap_or_else(|error| panic!("bench client should connect: {error}"));
if stop_for_thread.load(Ordering::Relaxed) {
break;
}
let payload_bytes = Arc::clone(&payload_bytes);
let metrics = Arc::clone(&metrics_for_thread);
thread::spawn(move || {
handle_loopback_segment_request(
stream,
&payload_bytes,
&metrics,
piece_length,
total_length,
response_delay,
);
});
}
});
Self {
base_url: format!("http://{addr}"),
metrics,
stop,
handle: Some(handle),
listen_address: addr,
}
}
fn url_for(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
fn snapshot_metrics(&self) -> SegmentRequestMetrics {
self.metrics
.lock()
.expect("segment request metrics mutex should not poison")
.clone()
}
}
impl Drop for LocalHttpSegmentServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
let _ = TcpStream::connect(self.listen_address);
if let Some(handle) = self.handle.take() {
handle.join().expect("loopback server thread should join");
}
}
}
fn handle_loopback_segment_request(
mut stream: TcpStream,
payload_bytes: &Arc<[u8]>,
metrics: &Arc<Mutex<SegmentRequestMetrics>>,
piece_length: usize,
total_length: usize,
response_delay: Duration,
) {
stream
.set_nonblocking(false)
.expect("accepted bench socket should switch back to blocking mode");
let mut request = [0_u8; 4096];
let read = stream.read(&mut request).expect("request should read");
let request_text = String::from_utf8_lossy(&request[..read]);
let request_path = parse_request_path(&request_text).to_owned();
let (start, end_inclusive) = parse_requested_range(&request_text, piece_length, total_length);
let len = end_inclusive.saturating_sub(start).saturating_add(1);
let body = payload_bytes.get(start..=end_inclusive).unwrap_or(&[]);
{
let mut metrics = metrics
.lock()
.expect("segment request metrics mutex should not poison");
metrics.total_requests += 1;
*metrics.requests_by_path.entry(request_path).or_default() += 1;
}
thread::sleep(response_delay);
let response_head = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Length: {len}\r\nContent-Range: bytes {start}-{end_inclusive}/{total_length}\r\nConnection: close\r\n\r\n"
);
stream
.write_all(response_head.as_bytes())
.expect("response head should write");
stream.write_all(body).expect("response body should write");
}
fn parse_request_path(request_text: &str) -> &str {
request_text
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/payload.bin")
}
fn parse_requested_range(
request_text: &str,
piece_length: usize,
total_length: usize,
) -> (usize, usize) {
let requested = request_text
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if !name.trim().eq_ignore_ascii_case("range") {
return None;
}
value.trim().strip_prefix("bytes=")
})
.and_then(|value| value.split_once('-'))
.map(|(start, end)| {
let start = start.parse::<usize>().expect("range start should parse");
let end = end.parse::<usize>().expect("range end should parse");
(start, end.min(total_length.saturating_sub(1)))
});
requested.unwrap_or_else(|| {
let start = 0usize;
let end = piece_length
.saturating_sub(1)
.min(total_length.saturating_sub(1));
(start, end)
})
}
fn build_live_transfer_config(scenario: LiveTransferScenario) -> TempConfigFile {
let mut lines = vec![
format!("split={LIVE_TRANSFER_SPLIT}"),
format!("max-connection-per-server={LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER}"),
format!("min-split-size={}", scenario.piece_length),
format!("piece-length={}", scenario.piece_length),
];
if let Some(limit) = scenario.overall_download_limit {
lines.push(format!("max-overall-download-limit={limit}"));
}
if let Some(disk_cache_bytes) = scenario.disk_cache_bytes {
lines.push(format!("disk-cache={disk_cache_bytes}"));
}
TempConfigFile::new(&lines.join("\n"))
}
fn min_expected_live_requests_per_download(scenario: LiveTransferContentionScenario) -> usize {
let total_segments = scenario.total_length.div_ceil(scenario.piece_length);
if LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER >= 4 {
let probe_segments = if scenario.piece_length <= 64 * 1024 {
LIVE_TRANSFER_MAX_CONNECTIONS_PER_SERVER.saturating_mul(2)
} else {
2usize
};
if total_segments <= probe_segments {
return 1;
}
let remaining_segments = total_segments.saturating_sub(probe_segments);
let followup_floor = if remaining_segments <= 2 {
1
} else if remaining_segments <= 3 {
2
} else {
remaining_segments.min(LIVE_TRANSFER_SPLIT.saturating_sub(1))
};
return 1usize.saturating_add(followup_floor);
}
let mut segmented_floor = 1usize.saturating_add(
total_segments
.saturating_sub(1)
.min(LIVE_TRANSFER_SPLIT.saturating_sub(1)),
);
if segmented_floor > 3 && total_segments <= LIVE_TRANSFER_SPLIT {
segmented_floor = segmented_floor.saturating_sub(1);
}
total_segments.min(segmented_floor).max(1)
}
fn request_budget_per_download(scenario: LiveTransferContentionScenario) -> usize {
min_expected_live_requests_per_download(scenario).saturating_mul(4)
}
fn run_live_transfer_contention_scenario(scenario: LiveTransferScenario) -> usize {
let config = build_live_transfer_config(scenario);
let server = LocalHttpSegmentServer::spawn(scenario);
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config.path().clone()),
uris: vec![server.url_for("payload.bin")],
},
&downloader,
)
.expect("live transfer benchmark should complete");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(
report.first_completed_length,
Some(scenario.total_length as u64)
);
scenario.total_length
}
fn run_live_transfer_fairness_contention_scenario(
scenario: LiveTransferContentionScenario,
) -> usize {
let single_transfer = LiveTransferScenario {
label: scenario.label,
total_length: scenario.total_length,
piece_length: scenario.piece_length,
overall_download_limit: scenario.overall_download_limit,
disk_cache_bytes: scenario.disk_cache_bytes,
response_delay_ms: scenario.response_delay_ms,
};
let min_requests_per_download = min_expected_live_requests_per_download(scenario);
let request_budget_per_download = request_budget_per_download(scenario);
let server = LocalHttpSegmentServer::spawn_many(
single_transfer,
scenario.download_count,
request_budget_per_download,
);
let worker_handles = (0..scenario.download_count)
.map(|_| build_live_transfer_config(single_transfer))
.enumerate()
.map(|(index, config)| {
let config_path = config.path().clone();
let uri = server.url_for(&format!("payload-{index}.bin"));
thread::spawn(move || {
let _config_guard = config;
let connector =
ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let started = Instant::now();
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![uri],
},
&downloader,
)
.expect("parallel live transfer benchmark should complete");
(started.elapsed(), report)
})
})
.collect::<Vec<_>>();
let mut elapsed = Vec::with_capacity(scenario.download_count);
for worker in worker_handles {
let (duration, report) = worker
.join()
.expect("parallel live transfer worker thread should join");
assert_eq!(report.completed_download_count, 1);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(
report.first_completed_length,
Some(scenario.total_length as u64)
);
elapsed.push(duration);
}
let metrics = server.snapshot_metrics();
assert_eq!(metrics.requests_by_path.len(), scenario.download_count);
assert!(
metrics.total_requests >= min_requests_per_download * scenario.download_count,
"parallel live transfer should keep every download progressing through segmented requests: observed total_requests={} floor_per_download={} download_count={}",
metrics.total_requests,
min_requests_per_download,
scenario.download_count
);
for index in 0..scenario.download_count {
let path = format!("/payload-{index}.bin");
let observed = metrics
.requests_by_path
.get(&path)
.copied()
.unwrap_or_default();
assert!(
observed >= min_requests_per_download,
"each download should exercise the segmented path under contention: path={path} observed={observed} floor={min_requests_per_download}"
);
}
let min_elapsed = elapsed
.iter()
.min()
.copied()
.expect("at least one live contention run should exist");
let max_elapsed = elapsed
.iter()
.max()
.copied()
.expect("at least one live contention run should exist");
assert!(max_elapsed >= min_elapsed);
scenario.total_length * scenario.download_count
}
fn run_live_shared_runtime_multi_download_scenario(
scenario: LiveTransferContentionScenario,
) -> usize {
let single_transfer = LiveTransferScenario {
label: scenario.label,
total_length: scenario.total_length,
piece_length: scenario.piece_length,
overall_download_limit: scenario.overall_download_limit,
disk_cache_bytes: scenario.disk_cache_bytes,
response_delay_ms: scenario.response_delay_ms,
};
let config = build_live_transfer_config(single_transfer);
let min_requests_per_download = min_expected_live_requests_per_download(scenario);
let request_budget_per_download = request_budget_per_download(scenario);
let server = LocalHttpSegmentServer::spawn_many(
single_transfer,
scenario.download_count,
request_budget_per_download,
);
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config.path().clone()),
uris: (0..scenario.download_count)
.map(|index| server.url_for(&format!("shared-runtime-{index}.bin")))
.collect(),
},
&downloader,
)
.expect("shared-runtime live transfer benchmark should complete");
assert_eq!(report.accepted_uri_count, scenario.download_count);
assert_eq!(report.tracked_download_count, scenario.download_count);
assert_eq!(report.completed_download_count, scenario.download_count);
assert_eq!(report.first_status.as_deref(), Some("complete"));
assert_eq!(
report.first_completed_length,
Some(scenario.total_length as u64)
);
let metrics = server.snapshot_metrics();
assert_eq!(metrics.requests_by_path.len(), scenario.download_count);
assert!(
metrics.total_requests >= min_requests_per_download * scenario.download_count,
"shared-runtime live transfer should keep every registered download progressing: observed total_requests={} floor_per_download={} download_count={}",
metrics.total_requests,
min_requests_per_download,
scenario.download_count
);
for index in 0..scenario.download_count {
let path = format!("/shared-runtime-{index}.bin");
let observed = metrics
.requests_by_path
.get(&path)
.copied()
.unwrap_or_default();
assert!(
observed >= min_requests_per_download,
"shared-runtime live transfer should fully progress each registered download: path={path} observed={observed} floor={min_requests_per_download}"
);
}
scenario.total_length * scenario.download_count
}
pub(super) fn bench_live_http_transfer_contention_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("live_http_transfer_contention_pressure");
for scenario in [
LiveTransferScenario {
label: "loose_cap",
total_length: 16 * 1024,
piece_length: 4 * 1024,
overall_download_limit: None,
disk_cache_bytes: None,
response_delay_ms: 8,
},
LiveTransferScenario {
label: "tight_cap",
total_length: 16 * 1024,
piece_length: 4 * 1024,
overall_download_limit: Some(4 * 1024),
disk_cache_bytes: None,
response_delay_ms: 8,
},
] {
group.throughput(Throughput::Bytes(scenario.total_length as u64));
group.bench_with_input(
BenchmarkId::new("live_http_transfer", scenario.label),
&scenario,
|b, &scenario| {
b.iter_batched(
|| scenario,
|scenario| {
let bytes = run_live_transfer_contention_scenario(scenario);
assert_eq!(bytes, scenario.total_length);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
pub(super) fn bench_live_http_multi_download_contention_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("live_http_multi_download_contention_pressure");
for scenario in [
LiveTransferContentionScenario {
label: "loose_cap",
download_count: 3,
total_length: 16 * 1024,
piece_length: 4 * 1024,
overall_download_limit: None,
disk_cache_bytes: None,
response_delay_ms: 8,
},
LiveTransferContentionScenario {
label: "tight_cap",
download_count: 3,
total_length: 16 * 1024,
piece_length: 4 * 1024,
overall_download_limit: Some(4 * 1024),
disk_cache_bytes: None,
response_delay_ms: 8,
},
] {
group.throughput(Throughput::Bytes(
(scenario.total_length * scenario.download_count) as u64,
));
group.bench_with_input(
BenchmarkId::new("multi_live_http_transfer", scenario.label),
&scenario,
|b, &scenario| {
b.iter_batched(
|| scenario,
|scenario| {
let bytes = run_live_transfer_fairness_contention_scenario(scenario);
assert_eq!(bytes, scenario.total_length * scenario.download_count);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
pub(super) fn bench_live_http_shared_runtime_multi_download_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("live_http_shared_runtime_multi_download_pressure");
for scenario in [
LiveTransferContentionScenario {
label: "loose_cap",
download_count: 3,
total_length: 16 * 1024,
piece_length: 4 * 1024,
overall_download_limit: None,
disk_cache_bytes: None,
response_delay_ms: 8,
},
LiveTransferContentionScenario {
label: "tight_cap",
download_count: 3,
total_length: 16 * 1024,
piece_length: 4 * 1024,
overall_download_limit: Some(4 * 1024),
disk_cache_bytes: None,
response_delay_ms: 8,
},
LiveTransferContentionScenario {
label: "tight_cap_6way",
download_count: 6,
total_length: 16 * 1024,
piece_length: 4 * 1024,
overall_download_limit: Some(4 * 1024),
disk_cache_bytes: None,
response_delay_ms: 8,
},
LiveTransferContentionScenario {
label: "cache_pressure_6way_256k",
download_count: 6,
total_length: 256 * 1024,
piece_length: 32 * 1024,
overall_download_limit: Some(64 * 1024),
disk_cache_bytes: Some(64 * 1024),
response_delay_ms: 2,
},
] {
group.throughput(Throughput::Bytes(
(scenario.total_length * scenario.download_count) as u64,
));
group.bench_with_input(
BenchmarkId::new("shared_runtime_live_http_transfer", scenario.label),
&scenario,
|b, &scenario| {
b.iter_batched(
|| scenario,
|scenario| {
let bytes = run_live_shared_runtime_multi_download_scenario(scenario);
assert_eq!(bytes, scenario.total_length * scenario.download_count);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
@@ -0,0 +1,667 @@
#![expect(
clippy::redundant_pub_crate,
reason = "criterion bench entry points are re-exported only to the private bench root module"
)]
use super::support::{
BTreeMap, BenchmarkId, Criterion, InProcessRpcDispatcher, RpcMethod, RpcValue, RuntimeConfig,
Throughput, rpc_request,
};
#[derive(Clone, Copy, Debug)]
struct PressureScenario {
task_count: usize,
rounds: usize,
split: usize,
max_connections_per_server: usize,
}
impl PressureScenario {
fn runtime(self) -> RuntimeConfig {
RuntimeConfig {
split: self.split,
max_connections_per_server: self.max_connections_per_server,
max_connection_per_server: self.max_connections_per_server,
min_split_size: 1024,
piece_length: 1024,
..RuntimeConfig::default()
}
}
}
#[derive(Clone, Copy, Debug)]
struct ResourceLimitScenario {
task_count: usize,
rounds: usize,
global_download_limit: u64,
per_download_limit: u64,
global_upload_limit: u64,
per_upload_limit: u64,
disk_cache_bytes: u64,
}
#[derive(Clone, Copy, Debug)]
struct SharedRuntimeFairnessScenario {
label: &'static str,
task_count: usize,
rounds: usize,
global_download_limit: u64,
default_per_download_limit: u64,
constrained_per_download_limit: u64,
global_upload_limit: u64,
default_per_upload_limit: u64,
constrained_per_upload_limit: u64,
}
fn seed_pressure_dispatcher(scenario: PressureScenario) -> (InProcessRpcDispatcher, Vec<String>) {
let mut dispatcher = InProcessRpcDispatcher::with_runtime(scenario.runtime());
let mut gids = Vec::with_capacity(scenario.task_count);
for i in 0..scenario.task_count {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=bench-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 40_001
);
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet)],
));
match response.result {
Some(RpcValue::String(gid)) => gids.push(gid),
other => panic!("unexpected addUri result in bench setup: {other:?}"),
}
}
for (index, gid) in gids.iter().enumerate() {
dispatcher
.apply_bt_runtime_tick(
gid,
1024 + (index % 4) as u64 * 256,
256 + (index % 3) as u64 * 64,
300 + index as u64 % 80,
120 + index as u64 % 40,
1,
1,
false,
Some(4 + (index % 4) as u32),
)
.expect("bench setup runtime tick should succeed");
}
(dispatcher, gids)
}
fn seed_limited_dispatcher(
scenario: ResourceLimitScenario,
) -> (InProcessRpcDispatcher, Vec<String>) {
let base = PressureScenario {
task_count: scenario.task_count,
rounds: scenario.rounds,
split: 6,
max_connections_per_server: 6,
};
let (mut dispatcher, gids) = seed_pressure_dispatcher(base);
let change_global = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([
(
"max-overall-download-limit".to_owned(),
RpcValue::String(scenario.global_download_limit.to_string()),
),
(
"max-overall-upload-limit".to_owned(),
RpcValue::String(scenario.global_upload_limit.to_string()),
),
(
"disk-cache".to_owned(),
RpcValue::String(scenario.disk_cache_bytes.to_string()),
),
]))],
));
assert!(
change_global.error.is_none(),
"changeGlobalOption should succeed in benchmark setup"
);
for gid in &gids {
let change_option = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([
(
"max-download-limit".to_owned(),
RpcValue::String(scenario.per_download_limit.to_string()),
),
(
"max-upload-limit".to_owned(),
RpcValue::String(scenario.per_upload_limit.to_string()),
),
])),
],
));
assert!(
change_option.error.is_none(),
"changeOption should succeed in benchmark setup"
);
}
(dispatcher, gids)
}
fn seed_shared_runtime_fairness_dispatcher(
scenario: SharedRuntimeFairnessScenario,
) -> (InProcessRpcDispatcher, Vec<String>) {
let base = PressureScenario {
task_count: scenario.task_count,
rounds: scenario.rounds,
split: 6,
max_connections_per_server: 6,
};
let (mut dispatcher, gids) = seed_pressure_dispatcher(base);
let change_global = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([
(
"max-overall-download-limit".to_owned(),
RpcValue::String(scenario.global_download_limit.to_string()),
),
(
"max-overall-upload-limit".to_owned(),
RpcValue::String(scenario.global_upload_limit.to_string()),
),
]))],
));
assert!(
change_global.error.is_none(),
"changeGlobalOption should succeed in shared-runtime fairness setup"
);
for (index, gid) in gids.iter().enumerate() {
let (download_limit, upload_limit) = if index == 0 {
(
scenario.constrained_per_download_limit,
scenario.constrained_per_upload_limit,
)
} else {
(
scenario.default_per_download_limit,
scenario.default_per_upload_limit,
)
};
let change_option = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([
(
"max-download-limit".to_owned(),
RpcValue::String(download_limit.to_string()),
),
(
"max-upload-limit".to_owned(),
RpcValue::String(upload_limit.to_string()),
),
])),
],
));
assert!(
change_option.error.is_none(),
"changeOption should succeed in shared-runtime fairness setup"
);
}
(dispatcher, gids)
}
fn run_tell_status_pressure(
dispatcher: &mut InProcessRpcDispatcher,
gids: &[String],
rounds: usize,
) -> usize {
let mut calls = 0usize;
for round in 0..rounds {
let gid = &gids[round % gids.len()];
dispatcher
.apply_bt_runtime_tick(
gid,
0,
0,
400 + round as u64 * 10,
160 + round as u64 * 5,
0,
0,
false,
Some(8),
)
.expect("bench tellStatus churn should succeed");
for gid in gids {
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match response.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
calls += 1;
}
other => panic!("unexpected tellStatus payload in bench: {other:?}"),
}
}
}
calls
}
fn run_resource_limit_pressure(
dispatcher: &mut InProcessRpcDispatcher,
gids: &[String],
scenario: ResourceLimitScenario,
) -> usize {
let mut calls = 0usize;
let expected_download_speed = (scenario.global_download_limit / scenario.task_count as u64)
.min(scenario.per_download_limit)
.max(1);
let expected_upload_speed = (scenario.global_upload_limit / scenario.task_count as u64)
.min(scenario.per_upload_limit)
.max(1);
for round in 0..scenario.rounds {
for gid in gids {
dispatcher
.apply_bt_runtime_tick(gid, 64, 32, 5_000, 2_000, 1, 1, false, Some(6))
.expect("bench limited runtime tick should succeed");
}
let gid = &gids[round % gids.len()];
let tell_status = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match tell_status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("downloadSpeed"),
Some(&RpcValue::String(expected_download_speed.to_string()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String(expected_upload_speed.to_string()))
);
calls += 1;
}
other => panic!("unexpected tellStatus payload under resource caps: {other:?}"),
}
let tell_global =
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new()));
match tell_global.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("downloadSpeed"),
Some(&RpcValue::String(
expected_download_speed
.saturating_mul(scenario.task_count as u64)
.to_string(),
))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String(
expected_upload_speed
.saturating_mul(scenario.task_count as u64)
.to_string(),
))
);
calls += 1;
}
other => panic!("unexpected tellGlobalStat payload under resource caps: {other:?}"),
}
}
calls
}
fn run_mixed_rpc_pressure(
dispatcher: &mut InProcessRpcDispatcher,
gids: &[String],
rounds: usize,
) -> usize {
let mut calls = 0usize;
for round in 0..rounds {
let gid = &gids[round % gids.len()];
dispatcher
.apply_bt_runtime_tick(
gid,
128,
64,
500 + round as u64 * 15,
180 + round as u64 * 6,
1,
1,
round + 1 >= rounds,
Some(6 + (round % 4) as u32),
)
.expect("bench mixed churn should succeed");
let tell_status = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
assert!(matches!(tell_status.result, Some(RpcValue::Object(_))));
calls += 1;
let tell_active =
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellActive, Vec::new()));
assert!(matches!(tell_active.result, Some(RpcValue::Array(_))));
calls += 1;
let tell_global =
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new()));
assert!(matches!(tell_global.result, Some(RpcValue::Object(_))));
calls += 1;
}
calls
}
fn run_shared_runtime_fairness_pressure(
dispatcher: &mut InProcessRpcDispatcher,
gids: &[String],
scenario: SharedRuntimeFairnessScenario,
) -> usize {
let mut calls = 0usize;
let initial_shared_download_speed =
(scenario.global_download_limit / scenario.task_count as u64).max(1);
let initial_shared_upload_speed =
(scenario.global_upload_limit / scenario.task_count as u64).max(1);
let constrained_initial_download_speed = initial_shared_download_speed
.min(scenario.constrained_per_download_limit)
.max(1);
let constrained_initial_upload_speed = initial_shared_upload_speed
.min(scenario.constrained_per_upload_limit)
.max(1);
let rebalanced_active_count = scenario.task_count.saturating_sub(1).max(1);
let rebalanced_download_speed = (scenario.global_download_limit
/ rebalanced_active_count as u64)
.min(scenario.default_per_download_limit)
.max(1);
let rebalanced_upload_speed = (scenario.global_upload_limit / rebalanced_active_count as u64)
.min(scenario.default_per_upload_limit)
.max(1);
for round in 0..scenario.rounds {
let active_slice = if round == 0 { gids } else { &gids[1..] };
for gid in active_slice {
dispatcher
.apply_bt_runtime_tick(gid, 128, 64, 5_000, 2_000, 0, 0, false, Some(6))
.expect("shared-runtime fairness tick should succeed");
}
for (index, gid) in gids.iter().enumerate() {
let tell_status = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
));
match tell_status.result {
Some(RpcValue::Object(payload)) => {
if round > 0 && index == 0 {
assert_eq!(
payload.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
} else {
let expected_download_speed = if index == 0 {
constrained_initial_download_speed
} else if round == 0 {
initial_shared_download_speed
.min(scenario.default_per_download_limit)
.max(1)
} else {
rebalanced_download_speed
};
let expected_upload_speed = if index == 0 {
constrained_initial_upload_speed
} else if round == 0 {
initial_shared_upload_speed
.min(scenario.default_per_upload_limit)
.max(1)
} else {
rebalanced_upload_speed
};
assert_eq!(
payload.get("downloadSpeed"),
Some(&RpcValue::String(expected_download_speed.to_string()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String(expected_upload_speed.to_string()))
);
}
calls += 1;
}
other => panic!(
"unexpected tellStatus payload in shared-runtime fairness bench: {other:?}"
),
}
}
let tell_global =
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new()));
match tell_global.result {
Some(RpcValue::Object(payload)) => {
let (expected_download_speed, expected_upload_speed) = if round == 0 {
(
constrained_initial_download_speed.saturating_add(
initial_shared_download_speed
.min(scenario.default_per_download_limit)
.max(1)
.saturating_mul(rebalanced_active_count as u64),
),
constrained_initial_upload_speed.saturating_add(
initial_shared_upload_speed
.min(scenario.default_per_upload_limit)
.max(1)
.saturating_mul(rebalanced_active_count as u64),
),
)
} else {
(
constrained_initial_download_speed
.saturating_add(
rebalanced_download_speed
.saturating_mul(rebalanced_active_count as u64),
)
.min(scenario.global_download_limit),
constrained_initial_upload_speed
.saturating_add(
rebalanced_upload_speed
.saturating_mul(rebalanced_active_count as u64),
)
.min(scenario.global_upload_limit),
)
};
assert_eq!(
payload.get("downloadSpeed"),
Some(&RpcValue::String(expected_download_speed.to_string()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String(expected_upload_speed.to_string()))
);
calls += 1;
}
other => panic!(
"unexpected tellGlobalStat payload in shared-runtime fairness bench: {other:?}"
),
}
if round == 0 {
dispatcher
.mark_complete(&gids[0])
.expect("shared-runtime fairness benchmark should complete one gid");
}
}
calls
}
pub(super) fn bench_tell_status_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("rpc_tell_status_pressure");
for scenario in [
PressureScenario {
task_count: 64,
rounds: 4,
split: 4,
max_connections_per_server: 4,
},
PressureScenario {
task_count: 128,
rounds: 4,
split: 8,
max_connections_per_server: 8,
},
] {
group.throughput(Throughput::Elements(
(scenario.task_count * scenario.rounds) as u64,
));
group.bench_with_input(
BenchmarkId::new("tell_status", scenario.task_count),
&scenario,
|b, &scenario| {
b.iter_batched(
|| seed_pressure_dispatcher(scenario),
|(mut dispatcher, gids)| {
let calls =
run_tell_status_pressure(&mut dispatcher, &gids, scenario.rounds);
assert_eq!(calls, scenario.task_count * scenario.rounds);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
pub(super) fn bench_mixed_rpc_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("rpc_mixed_pressure");
for scenario in [
PressureScenario {
task_count: 96,
rounds: 6,
split: 6,
max_connections_per_server: 6,
},
PressureScenario {
task_count: 192,
rounds: 6,
split: 8,
max_connections_per_server: 8,
},
] {
group.throughput(Throughput::Elements((scenario.rounds * 3) as u64));
group.bench_with_input(
BenchmarkId::new("mixed_rpc", scenario.task_count),
&scenario,
|b, &scenario| {
b.iter_batched(
|| seed_pressure_dispatcher(scenario),
|(mut dispatcher, gids)| {
let calls = run_mixed_rpc_pressure(&mut dispatcher, &gids, scenario.rounds);
assert_eq!(calls, scenario.rounds * 3);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
pub(super) fn bench_resource_limit_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("rpc_speed_limit_pressure");
for scenario in [
ResourceLimitScenario {
task_count: 32,
rounds: 6,
global_download_limit: 2_560,
per_download_limit: 160,
global_upload_limit: 1_280,
per_upload_limit: 80,
disk_cache_bytes: 8 * 1024 * 1024,
},
ResourceLimitScenario {
task_count: 64,
rounds: 6,
global_download_limit: 5_120,
per_download_limit: 120,
global_upload_limit: 2_560,
per_upload_limit: 60,
disk_cache_bytes: 16 * 1024 * 1024,
},
] {
group.throughput(Throughput::Elements((scenario.rounds * 2) as u64));
group.bench_with_input(
BenchmarkId::new("speed_limit", scenario.task_count),
&scenario,
|b, &scenario| {
b.iter_batched(
|| seed_limited_dispatcher(scenario),
|(mut dispatcher, gids)| {
let calls = run_resource_limit_pressure(&mut dispatcher, &gids, scenario);
assert_eq!(calls, scenario.rounds * 2);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
pub(super) fn bench_shared_runtime_fairness_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("rpc_shared_runtime_fairness_pressure");
for scenario in [
SharedRuntimeFairnessScenario {
label: "three_way_rebalance",
task_count: 3,
rounds: 4,
global_download_limit: 1_200,
default_per_download_limit: 900,
constrained_per_download_limit: 250,
global_upload_limit: 600,
default_per_upload_limit: 500,
constrained_per_upload_limit: 120,
},
SharedRuntimeFairnessScenario {
label: "four_way_rebalance",
task_count: 4,
rounds: 4,
global_download_limit: 1_600,
default_per_download_limit: 900,
constrained_per_download_limit: 220,
global_upload_limit: 800,
default_per_upload_limit: 500,
constrained_per_upload_limit: 100,
},
] {
group.throughput(Throughput::Elements(
(scenario.task_count * scenario.rounds) as u64,
));
group.bench_with_input(
BenchmarkId::new("shared_runtime_fairness", scenario.label),
&scenario,
|b, &scenario| {
b.iter_batched(
|| seed_shared_runtime_fairness_dispatcher(scenario),
|(mut dispatcher, gids)| {
let calls =
run_shared_runtime_fairness_pressure(&mut dispatcher, &gids, scenario);
assert_eq!(calls, scenario.rounds * (scenario.task_count + 1));
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
@@ -0,0 +1,206 @@
#![expect(
clippy::redundant_pub_crate,
reason = "criterion bench entry points are re-exported only to the private bench root module"
)]
use super::support::{
BenchmarkId, Criterion, DownloadEngine, DownloadStatus, PieceId, PieceState, RuntimeConfig,
Throughput,
};
#[derive(Clone, Copy, Debug)]
struct BackpressureScenario {
task_count: usize,
rounds: usize,
disk_cache_bytes: u64,
split: usize,
max_connections_per_server: usize,
}
fn seed_instrumented_engine(task_count: usize) -> DownloadEngine {
let runtime = RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
piece_length: 1024,
..RuntimeConfig::default()
};
let mut engine = DownloadEngine::with_runtime(runtime);
for i in 0..task_count {
let gid = engine
.add_uri(format!("magnet:?xt=urn:btih:{:040x}", 90_001 + i))
.gid();
let group = engine
.handle_mut(gid)
.expect("newly inserted benchmark group should exist");
group.set_status(if i % 3 == 0 {
DownloadStatus::Waiting
} else {
DownloadStatus::Active
});
group.set_total_length(8 * 1024);
group.set_completed_length((i % 4) as u64 * 1024);
group.set_piece_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);
}
for _ in 0..3 {
let _ = engine.schedule_once();
}
engine
}
fn seed_backpressure_engine(scenario: BackpressureScenario) -> DownloadEngine {
let runtime = RuntimeConfig {
split: scenario.split,
max_connections_per_server: scenario.max_connections_per_server,
max_connection_per_server: scenario.max_connections_per_server,
min_split_size: 1024,
piece_length: 1024,
disk_cache_bytes: scenario.disk_cache_bytes,
..RuntimeConfig::default()
};
let mut engine = DownloadEngine::with_runtime(runtime);
for i in 0..scenario.task_count {
let gid = engine
.add_uri(format!("https://example.org/backpressure-{i}.bin"))
.gid();
let group = engine
.handle_mut(gid)
.expect("newly inserted backpressure group should exist");
group.set_status(match i % 4 {
0 => DownloadStatus::Waiting,
2 => DownloadStatus::Error,
_ => DownloadStatus::Active,
});
group.set_total_length(16 * 1024);
group.set_completed_length((i % 8) as u64 * 1024);
group.set_piece_length(1024);
group.set_download_speed(2_500 + i as u64 * 11);
group.set_upload_speed(900 + i as u64 * 5);
group.set_num_connections((scenario.max_connections_per_server.min(8)) as u32);
group.set_retry_count((i % 3) as u32);
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);
}
for _ in 0..4 {
let _ = engine.schedule_once();
}
engine
}
fn run_runtime_snapshot_pressure(engine: &mut DownloadEngine, rounds: usize) -> usize {
let gids = engine.registry().handles().collect::<Vec<_>>();
for round in 0..rounds {
let gid = gids[round % gids.len()].gid();
let group = engine
.handle_mut(gid)
.expect("benchmark group should still exist");
group.set_download_speed(600 + round as u64 * 10);
group.set_upload_speed(200 + round as u64 * 5);
let _ = engine.schedule_once();
let runtime = engine.runtime_instrumentation_snapshot();
assert!(runtime.download_count >= gids.len());
assert!(runtime.scheduler_counters.schedule_run_count >= 1);
}
rounds
}
fn run_backpressure_runtime_pressure(
engine: &mut DownloadEngine,
scenario: BackpressureScenario,
) -> usize {
let gids = engine.registry().handles().collect::<Vec<_>>();
for round in 0..scenario.rounds {
let gid = gids[round % gids.len()].gid();
let group = engine
.handle_mut(gid)
.expect("backpressure benchmark group should still exist");
group.set_retry_count((round % 5) as u32);
group.set_status(if round % 3 == 0 {
DownloadStatus::Waiting
} else {
DownloadStatus::Active
});
group.set_download_speed(4_000 + round as u64 * 40);
group.set_upload_speed(1_500 + round as u64 * 25);
let _ = engine.schedule_once();
let runtime = engine.runtime_instrumentation_snapshot();
assert_eq!(
runtime.configured_disk_cache_bytes,
scenario.disk_cache_bytes
);
assert!(runtime.total_active_segments >= 1);
assert!(runtime.scheduler_counters.schedule_run_count >= 1);
}
scenario.rounds
}
pub(super) fn bench_runtime_snapshot_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("runtime_snapshot_pressure");
for task_count in [64usize, 128, 256] {
group.throughput(Throughput::Elements(task_count as u64));
group.bench_with_input(
BenchmarkId::new("runtime_snapshot", task_count),
&task_count,
|b, &task_count| {
b.iter_batched(
|| seed_instrumented_engine(task_count),
|mut engine| {
let observations = run_runtime_snapshot_pressure(&mut engine, 8);
assert_eq!(observations, 8);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
pub(super) fn bench_scheduler_backpressure_pressure(c: &mut Criterion) {
let mut group = c.benchmark_group("scheduler_backpressure_pressure");
for scenario in [
BackpressureScenario {
task_count: 64,
rounds: 10,
disk_cache_bytes: 4 * 1024 * 1024,
split: 4,
max_connections_per_server: 4,
},
BackpressureScenario {
task_count: 128,
rounds: 10,
disk_cache_bytes: 32 * 1024 * 1024,
split: 8,
max_connections_per_server: 8,
},
] {
group.throughput(Throughput::Elements(scenario.task_count as u64));
group.bench_with_input(
BenchmarkId::new("backpressure", scenario.task_count),
&scenario,
|b, &scenario| {
b.iter_batched(
|| seed_backpressure_engine(scenario),
|mut engine| {
let observations = run_backpressure_runtime_pressure(&mut engine, scenario);
assert_eq!(observations, scenario.rounds);
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
@@ -0,0 +1,41 @@
#![expect(
clippy::redundant_pub_crate,
reason = "private criterion bench modules share fixtures through pub(super) support exports"
)]
pub(super) use std::{
collections::BTreeMap,
fs,
io::{Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::PathBuf,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
pub(super) use aria2_rust_pro_cli::{Invocation, execute_runtime_with_downloader};
pub(super) use aria2_rust_pro_core::{
DownloadEngine, DownloadStatus, PieceId, PieceState, RuntimeConfig,
};
pub(super) use aria2_rust_pro_protocol::{
ReqwestHttpConnector, TorrentPeerModel, TrackerPeerListModel, TrackerResponseModel,
downloader::ConnectorBackedDownloader,
};
pub(super) use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMethod, RpcValue};
pub(super) use criterion::{BenchmarkId, Criterion, Throughput};
pub(super) const BT_TORRENT_FIXTURE: &str = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
pub(super) fn rpc_request(method: RpcMethod, params: Vec<RpcValue>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method.as_str().to_owned(),
params,
meta: Default::default(),
}
}
+36
View File
@@ -0,0 +1,36 @@
#![forbid(unsafe_code)]
#![doc = "Workspace integration and benchmark tests for aria2-rust-pro."]
#[cfg(test)]
use aria2_rust_pro_cli as _;
#[cfg(test)]
use criterion as _;
#[cfg(test)]
#[expect(
clippy::arithmetic_side_effects,
clippy::cognitive_complexity,
clippy::default_trait_access,
clippy::indexing_slicing,
clippy::integer_division,
clippy::too_many_lines,
reason = "integration tests keep protocol/RPC fixtures explicit so regressions remain auditable"
)]
mod tests {
use support::*;
#[path = "bt_status_and_selection.rs"]
mod bt_status_and_selection;
#[path = "dht_and_peer_wire.rs"]
mod dht_and_peer_wire;
#[path = "foundations_and_protocol.rs"]
mod foundations_and_protocol;
#[path = "rpc_parity.rs"]
mod rpc_parity;
#[path = "rpc_pressure_and_runtime.rs"]
mod rpc_pressure_and_runtime;
#[path = "support.rs"]
mod support;
#[path = "tracker_and_surface_regression.rs"]
mod tracker_and_surface_regression;
}
@@ -0,0 +1,701 @@
use super::*;
#[test]
fn add_torrent_registers_runtime_backed_bt_metadata_surfaces() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(false)));
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
assert!(matches!(
payload.get("magnetUri"),
Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:")
));
}
other => panic!("unexpected tellStatus after addTorrent: {other:?}"),
}
let files = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetFiles.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match files.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("path"),
Some(&RpcValue::String("ubuntu.iso".to_owned()))
);
assert_eq!(
file.get("length"),
Some(&RpcValue::String("32768".to_owned()))
);
}
other => panic!("unexpected getFiles entry after addTorrent: {other:?}"),
},
other => panic!("unexpected getFiles result after addTorrent: {other:?}"),
}
let servers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetServers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
#[test]
fn add_uri_magnet_registers_bt_runtime_status_surfaces() {
let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-magnet.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true)));
assert!(matches!(
payload.get("magnetUri"),
Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:")
));
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
}
other => panic!("unexpected tellStatus after addUri magnet: {other:?}"),
}
}
#[test]
fn tracker_announce_ingestion_populates_peers_and_preserves_bt_servers() {
fn bencode_int(value: i64) -> Vec<u8> {
format!("i{value}e").into_bytes()
}
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
let mut out = format!("{}:", value.len()).into_bytes();
out.extend_from_slice(value);
out
}
fn bencode_list(values: Vec<Vec<u8>>) -> Vec<u8> {
let mut out = vec![b'l'];
for value in values {
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
fn bencode_dict(entries: Vec<(&str, Vec<u8>)>) -> Vec<u8> {
let mut out = vec![b'd'];
for (key, value) in entries {
out.extend_from_slice(format!("{}:{key}", key.len()).as_bytes());
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
let magnet = "magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=bt-peers.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let announce_bytes = bencode_dict(vec![
("interval", bencode_int(1800)),
("tracker id", bencode_bytes(b"tracker-session-1")),
(
"peers",
bencode_list(vec![
bencode_dict(vec![
("ip", bencode_bytes(b"203.0.113.10")),
("port", bencode_int(51413)),
("peer id", bencode_bytes(b"-AZ2060-123456789012")),
("client", bencode_bytes(b"Azureus 2.0.6.0")),
("choked", bencode_int(0)),
("interested", bencode_int(1)),
]),
bencode_dict(vec![
("ip", bencode_bytes(b"203.0.113.11")),
("port", bencode_int(51414)),
("choked", bencode_int(1)),
("interested", bencode_int(0)),
]),
]),
),
]);
let announce = TrackerResponseModel::from_announce_bytes(&announce_bytes)
.expect("announce response should parse");
dispatcher
.apply_tracker_announce_result(&gid, &announce)
.expect("tracker announce should ingest");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(entries)) => {
assert!(!entries.is_empty(), "expected concrete peer entries");
match entries.first() {
Some(RpcValue::Object(peer)) => {
assert!(matches!(peer.get("ip"), Some(RpcValue::String(_))));
assert!(matches!(peer.get("port"), Some(RpcValue::String(_))));
}
other => panic!("unexpected getPeers entry: {other:?}"),
}
}
other => panic!("unexpected getPeers result: {other:?}"),
}
let servers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetServers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
#[test]
fn live_http_tracker_announce_round_trips_into_dispatcher_peer_visibility() {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let payload = {
let mut payload = b"d8:intervali900e5:peers6:".to_vec();
payload.extend_from_slice(&[127, 0, 0, 1, 0x1A, 0xE1]);
payload.extend_from_slice(b"e");
payload
};
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("tracker client should connect");
let mut request = [0_u8; 2048];
let read = stream.read(&mut request).expect("request should read");
let request_text = String::from_utf8_lossy(&request[..read]);
assert!(request_text.starts_with("GET /announce?"));
assert!(request_text.contains("compact=1"));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n",
payload.len()
);
stream
.write_all(response.as_bytes())
.expect("headers should write");
stream.write_all(&payload).expect("payload should write");
});
let transport = ReqwestTrackerTransport::new().expect("reqwest tracker transport should build");
let announce = transport
.announce(&TrackerRequestModel {
announce_url: format!("http://{addr}/announce"),
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(),
port: 6881,
uploaded: 0,
downloaded: 0,
left: 2048,
event: Some("started".to_owned()),
compact: true,
numwant: Some(10),
})
.expect("live announce should succeed");
let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-live-tracker.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
dispatcher
.apply_tracker_announce_result(&gid, &announce)
.expect("tracker announce should ingest");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(items)) => match items.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("127.0.0.1".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned())));
}
other => panic!("unexpected peer payload after live tracker announce: {other:?}"),
},
other => panic!("unexpected getPeers result after live tracker announce: {other:?}"),
}
handle.join().expect("tracker server thread should join");
}
#[test]
fn bt_change_option_select_file_round_trips_through_get_files_selected_flags() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let change = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String(String::new()),
)])),
],
meta: Default::default(),
});
assert!(
change.error.is_some(),
"empty select-file value should be rejected to protect BT file selection contract"
);
let change = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1".to_owned()),
)])),
],
meta: Default::default(),
});
assert_eq!(change.result, Some(RpcValue::String("OK".to_owned())));
let files = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetFiles.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match files.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(entries.len(), 1, "fixture torrent currently has one file");
match entries.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("selected"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected getFiles entry: {other:?}"),
}
}
other => panic!("unexpected getFiles response: {other:?}"),
}
}
#[test]
fn bt_pause_and_selected_state_are_persisted_in_saved_session_file() {
let session_root = std::env::temp_dir().join(format!(
"aria2-rust-pro-tests-bt-session-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&session_root);
let session_path = session_root.join("session.txt");
let mut dispatcher = InProcessRpcDispatcher::with_runtime(
RuntimeConfig::default().with_session_path(session_path.clone()),
);
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let _ = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1".to_owned()),
)])),
],
meta: Default::default(),
});
let pause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Pause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert_eq!(pause.result, Some(RpcValue::String(gid.clone())));
let save = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2SaveSession.as_str().to_owned(),
params: vec![],
meta: Default::default(),
});
assert_eq!(save.result, Some(RpcValue::String("OK".to_owned())));
let loaded = load_session_file(&session_path).expect("saved session file should load");
let persisted = loaded
.entries
.iter()
.find(|entry| entry.gid == gid)
.expect("saved session should include paused BT gid");
let metadata = persisted
.metadata
.as_ref()
.expect("session entry should include metadata");
assert_eq!(metadata.get("status"), Some(&"paused".to_owned()));
assert_eq!(
metadata.get("bt.file.0.selected"),
Some(&"true".to_owned()),
"session metadata should preserve BT selected-file flag for reload path"
);
let _ = std::fs::remove_file(session_path);
let _ = std::fs::remove_dir_all(session_root);
}
#[test]
fn bt_metadata_only_must_not_report_false_completion_lengths() {
let magnet = "magnet:?xt=urn:btih:fedcba98765432100123456789abcdef01234567&dn=bt-metadata-only.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true)));
assert_ne!(
payload.get("status"),
Some(&RpcValue::String("complete".to_owned())),
"metadata-only BT should not look fully complete before payload download"
);
assert_eq!(
payload.get("completedLength"),
Some(&RpcValue::String("0".to_owned())),
"metadata-only BT should not fake payload completed length"
);
}
other => panic!("unexpected tellStatus payload: {other:?}"),
}
}
#[test]
fn bt_select_file_pause_resume_and_save_session_contract_is_stable() {
let session_root = std::env::temp_dir().join(format!(
"aria2-rust-pro-tests-bt-select-pause-resume-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&session_root);
let session_path = session_root.join("session.txt");
let mut dispatcher = InProcessRpcDispatcher::with_runtime(
RuntimeConfig::default().with_session_path(session_path.clone()),
);
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let change = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2ChangeOption.as_str().to_owned(),
params: vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1".to_owned()),
)])),
],
meta: Default::default(),
});
assert_eq!(change.result, Some(RpcValue::String("OK".to_owned())));
let pause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Pause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert!(pause.error.is_none());
let unpause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Unpause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert!(unpause.error.is_none());
let repause = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2Pause.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
assert!(repause.error.is_none());
let files = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetFiles.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match files.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(file)) => assert_eq!(
file.get("selected"),
Some(&RpcValue::String("true".to_owned()))
),
other => panic!("unexpected getFiles entry after pause/resume: {other:?}"),
},
other => panic!("unexpected getFiles payload after pause/resume: {other:?}"),
}
let save = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2SaveSession.as_str().to_owned(),
params: vec![],
meta: Default::default(),
});
assert_eq!(save.result, Some(RpcValue::String("OK".to_owned())));
let loaded = load_session_file(&session_path).expect("saved session file should load");
let entry = loaded
.entries
.iter()
.find(|entry| entry.gid == gid)
.expect("saved session should include BT gid");
let metadata = entry
.metadata
.as_ref()
.expect("saved session entry should include metadata");
assert_eq!(metadata.get("status"), Some(&"paused".to_owned()));
assert_eq!(metadata.get("bt.file.0.selected"), Some(&"true".to_owned()));
let _ = std::fs::remove_file(session_path);
let _ = std::fs::remove_dir_all(session_root);
}
#[test]
fn bt_seeding_and_share_visibility_fields_exist_and_are_type_stable() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(
matches!(payload.get("status"), Some(RpcValue::String(_)),),
"status field should remain visible for BT"
);
assert!(
payload.contains_key("seeder"),
"BT status should expose seeder visibility field"
);
assert!(
payload.contains_key("seeders"),
"BT status should expose seeders visibility field"
);
assert!(
payload.contains_key("numSeeders"),
"BT status should expose numSeeders visibility field"
);
assert!(
payload.contains_key("shareRatio"),
"BT status should expose shareRatio visibility field"
);
assert!(
payload.contains_key("shareRatioProgress"),
"BT status should expose shareRatioProgress visibility field"
);
assert!(
payload.contains_key("shareRatioRemaining"),
"BT status should expose shareRatioRemaining visibility field"
);
assert!(
payload.contains_key("shareTime"),
"BT status should expose shareTime visibility field"
);
if let Some(value) = payload.get("shareRatio") {
assert!(
matches!(value, RpcValue::String(_) | RpcValue::Number(_)),
"shareRatio should stay scalar"
);
}
if let Some(value) = payload.get("shareTime") {
assert!(
matches!(value, RpcValue::String(_) | RpcValue::Number(_)),
"shareTime should stay scalar"
);
}
}
other => panic!("unexpected tellStatus payload for BT share visibility: {other:?}"),
}
}
@@ -0,0 +1,682 @@
use super::*;
#[test]
fn peer_wire_exchange_keeps_bt_peer_surface_visible_after_tracker_ingest() {
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let bootstrap_status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let info_hash = match bootstrap_status.result {
Some(RpcValue::Object(payload)) => match payload.get("magnetUri") {
Some(RpcValue::String(uri)) => info_hash_bytes(uri),
other => panic!("unexpected magnetUri payload after addTorrent: {other:?}"),
},
other => panic!("unexpected tellStatus bootstrap payload: {other:?}"),
};
dispatcher
.apply_tracker_announce_result(
&gid,
&TrackerResponseModel {
peers: TrackerPeerListModel {
interval_sec: 900,
peers: vec![TorrentPeerModel {
peer_id: None,
ip: "198.51.100.20".to_owned(),
port: 51413,
client_name: None,
interested: false,
choked: true,
}],
min_interval_sec: None,
tracker_id: Some("tracker-session-bt".to_owned()),
},
scrape: None,
},
)
.expect("tracker announce should seed a BT peer");
let connector = FixedPeerWireConnector {
response_payload: peer_wire_payload(
info_hash,
*b"-TR3000-RUNTIME-PEER",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[
true, true,
])),
PeerWireMessageKind::Have(1),
],
),
};
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange should succeed");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("peerId"),
Some(&RpcValue::String(
"2d5452333030302d52554e54494d452d50454552".to_owned()
))
);
assert_eq!(
peer.get("peerChoking"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
peer.get("seeder"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected getPeers row after peer-wire exchange: {other:?}"),
},
other => panic!("unexpected getPeers result after peer-wire exchange: {other:?}"),
}
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
payload.get("numSeeders"),
Some(&RpcValue::String("1".to_owned()))
);
}
other => panic!("unexpected tellStatus payload after peer-wire exchange: {other:?}"),
}
}
#[test]
fn execute_dht_get_peers_refreshes_peer_list_and_connection_count() {
let magnet = "magnet:?xt=urn:btih:fedcba98765432100123456789abcdef01234567&dn=bt-dht-exec.iso";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let transport = FixedDhtTransport {
response: DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x22; 20],
Some(b"dht-get-peers-token".to_vec()),
Some(compact_node(0x44, [203, 0, 113, 20], 6885)),
vec![
compact_peer([203, 0, 113, 10], 51413),
compact_peer([203, 0, 113, 11], 51414),
],
),
};
dispatcher
.execute_dht_get_peers(&gid, &transport)
.expect("dht get_peers should succeed");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(entries)) => {
assert_eq!(
entries.len(),
2,
"dht peers should replace the visible peer list"
);
let ports = entries
.into_iter()
.filter_map(|entry| match entry {
RpcValue::Object(peer) => peer.get("port").cloned(),
_ => None,
})
.collect::<Vec<_>>();
assert!(ports.contains(&RpcValue::String("51413".to_owned())));
assert!(ports.contains(&RpcValue::String("51414".to_owned())));
}
other => panic!("unexpected getPeers result after dht execution: {other:?}"),
}
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(
payload.get("connections"),
Some(&RpcValue::String("2".to_owned()))
);
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
}
other => panic!("unexpected tellStatus payload after dht execution: {other:?}"),
}
}
#[test]
fn dht_peer_wire_and_rpc_views_stay_in_sync_for_piece_progress_regression() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash"));
let dht_source = DhtNodeModel {
node_id: String::new(),
address: "203.0.113.200".to_owned(),
port: 6881,
};
let dht_response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x91; 20],
Some(b"dht-piece-token".to_vec()),
Some(compact_node(0x62, [203, 0, 113, 201], 6882)),
vec![compact_peer([198, 51, 100, 44], 51413)],
);
dispatcher
.apply_dht_get_peers_result(&gid, &dht_source, &dht_response)
.expect("dht peer discovery should seed a peer-wire target");
let connector = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
*b"-RTK0001-12345678901",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[true, false])),
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 0,
block_offset: 0,
block: vec![0xAB; 16_384],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange should succeed after dht discovery");
let seen = connector.seen();
assert_eq!(seen.len(), 1, "peer-wire transport should see one exchange");
assert_eq!(seen[0].endpoint.address, "198.51.100.44:51413");
let (handshake, _consumed) = PeerWireHandshakeModel::parse_prefix(&seen[0].payload)
.expect("outbound peer-wire payload should begin with a handshake");
assert_eq!(handshake.info_hash, info_hash);
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
status.get("completedLength"),
Some(&RpcValue::String("16384".to_owned()))
);
assert_eq!(
status.get("completedPieces"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
status.get("bitfield"),
Some(&RpcValue::String("20".to_owned()))
);
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
let files = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
)));
match files.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("completedLength"),
Some(&RpcValue::String("16384".to_owned()))
);
assert_eq!(
file.get("bitfield"),
Some(&RpcValue::String("20".to_owned()))
);
}
other => {
panic!("unexpected getFiles payload after peer-wire piece exchange: {other:?}")
}
}
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid)],
)));
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("198.51.100.44".to_owned()))
);
assert_eq!(
peer.get("peerChoking"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
peer.get("downloadSpeed"),
Some(&RpcValue::String("16384".to_owned()))
);
}
other => {
panic!("unexpected getPeers payload after peer-wire piece exchange: {other:?}")
}
}
}
#[test]
fn tracker_scrape_then_dht_refresh_preserves_seed_counts_and_server_rows() {
let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-handoff.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = match dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet.to_owned())],
))
.result
{
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let tracker_announce = TrackerResponseModel {
peers: TrackerPeerListModel {
interval_sec: 900,
peers: vec![TorrentPeerModel {
peer_id: None,
ip: "198.51.100.60".to_owned(),
port: 51413,
client_name: Some("tracker-peer".to_owned()),
interested: true,
choked: false,
}],
min_interval_sec: None,
tracker_id: Some("bt-tracker".to_owned()),
},
scrape: None,
};
dispatcher
.apply_tracker_announce_result(&gid, &tracker_announce)
.expect("tracker announce should populate peer and tracker views");
dispatcher
.apply_tracker_scrape_result(
&gid,
None,
&TrackerScrapeModel {
complete: Some(9),
downloaded: Some(12),
incomplete: Some(4),
files: vec![TrackerScrapeFileModel {
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
complete: Some(9),
downloaded: Some(12),
incomplete: Some(4),
}],
},
)
.expect("tracker scrape should populate seeding counts");
let dht_response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x33; 20],
Some(b"handoff".to_vec()),
Some(compact_node(0x71, [203, 0, 113, 61], 6889)),
vec![compact_peer([203, 0, 113, 62], 6001)],
);
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.60".to_owned(),
port: 6881,
},
&dht_response,
)
.expect("dht refresh should replace the visible peer view");
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
status.get("numSeeders"),
Some(&RpcValue::String("9".to_owned()))
);
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
assert!(matches!(
status.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(
peers.len(),
1,
"dht snapshot should replace tracker peer rows"
);
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("203.0.113.62".to_owned()))
);
assert_eq!(peer.get("port"), Some(&RpcValue::String("6001".to_owned())));
}
other => panic!("unexpected getPeers payload after tracker+dht handoff: {other:?}"),
}
let servers = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetServers,
vec![RpcValue::String(gid.clone())],
));
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
#[test]
fn peer_wire_exchange_follows_dht_peer_refresh_under_swarm_churn() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash"));
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.70".to_owned(),
port: 6881,
},
&DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x31; 20],
Some(b"churn-a".to_vec()),
None,
vec![compact_peer([198, 51, 100, 70], 51413)],
),
)
.expect("first dht refresh should seed peer A");
let connector_a = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
*b"-PC0001-CHURN-PEERA1",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 0,
block_offset: 0,
block: vec![0xAA; 16_384],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector_a)
.expect("first peer-wire exchange should succeed");
let seen_a = connector_a.seen();
assert_eq!(seen_a.len(), 1, "peer A should receive one exchange");
assert_eq!(seen_a[0].endpoint.address, "198.51.100.70:51413");
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.71".to_owned(),
port: 6882,
},
&DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x32; 20],
Some(b"churn-b".to_vec()),
None,
vec![compact_peer([198, 51, 100, 71], 51414)],
),
)
.expect("second dht refresh should replace visible peer with peer B");
let connector_b = FakePeerWireConnector::new(peer_wire_handshake_and_frames(
info_hash,
*b"-PC0001-CHURN-PEERB1",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 1,
block_offset: 0,
block: vec![0xBB; 16_384],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector_b)
.expect("second peer-wire exchange should follow refreshed peer");
let seen_b = connector_b.seen();
assert_eq!(seen_b.len(), 1, "peer B should receive one exchange");
assert_eq!(seen_b[0].endpoint.address, "198.51.100.71:51414");
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(
status.get("completedLength"),
Some(&RpcValue::String("32768".to_owned()))
);
assert_eq!(
status.get("completedPieces"),
Some(&RpcValue::String("2".to_owned()))
);
assert_eq!(
status.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
assert_eq!(
status.get("seeder"),
Some(&RpcValue::String("false".to_owned()))
);
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid)],
)));
assert_eq!(
peers.len(),
1,
"refreshed DHT peer view should replace stale peer A"
);
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("198.51.100.71".to_owned()))
);
assert_eq!(
peer.get("port"),
Some(&RpcValue::String("51414".to_owned()))
);
}
other => panic!("unexpected peer payload after swarm churn exchange: {other:?}"),
}
}
#[test]
fn dht_find_node_and_announce_peer_runtime_handoff_stays_coherent() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
let info_hash = decode_hex_20(&rpc_string_field(&bootstrap, "infoHash"));
let find_node_transport = RecordingDhtTransport::new(DhtMessageModel::find_node_response(
b"fn".to_vec(),
vec![0x41; 20],
vec![aria2_rust_pro_protocol::torrent::DhtCompactNodeModel {
node_id: [0x77; 20],
address: [203, 0, 113, 99],
port: 6891,
}],
));
dispatcher
.execute_dht_find_node(&gid, &find_node_transport)
.expect("dht find_node should succeed");
let find_node_seen = find_node_transport.seen();
assert_eq!(
find_node_seen.len(),
1,
"find_node should send exactly one query"
);
match &find_node_seen[0].1.body {
DhtMessageBody::Query(DhtQueryModel::FindNode(query)) => {
assert_eq!(query.target, info_hash);
}
other => panic!("unexpected find_node query payload: {other:?}"),
}
dispatcher
.apply_dht_get_peers_result(
&gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.99".to_owned(),
port: 6891,
},
&DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x55; 20],
Some(b"dht-announce-token".to_vec()),
None,
vec![compact_peer([198, 51, 100, 9], 51413)],
),
)
.expect("get_peers handoff should cache announce token");
let announce_transport = RecordingDhtTransport::new(DhtMessageModel::ping_response(
b"ap".to_vec(),
vec![0x66; 20],
));
dispatcher
.execute_dht_announce_peer(&gid, &announce_transport)
.expect("dht announce_peer should succeed after token handoff");
let announce_seen = announce_transport.seen();
assert_eq!(
announce_seen.len(),
1,
"announce_peer should send exactly one query"
);
match &announce_seen[0].1.body {
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(query)) => {
assert_eq!(query.info_hash, info_hash);
assert_eq!(query.token, b"dht-announce-token".to_vec());
assert_eq!(query.port, 6881);
assert!(!query.implied_port);
}
other => panic!("unexpected announce_peer query payload: {other:?}"),
}
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert_eq!(
status.get("connections"),
Some(&RpcValue::String("1".to_owned()))
);
let peers = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
)));
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("ip"),
Some(&RpcValue::String("198.51.100.9".to_owned()))
);
assert_eq!(
peer.get("port"),
Some(&RpcValue::String("51413".to_owned()))
);
}
other => panic!("unexpected peer payload after announce handoff: {other:?}"),
}
}
@@ -0,0 +1,284 @@
use super::*;
#[test]
fn phase_zero_workspace_tracks_goal_contract() {
assert_eq!(BASELINE_COMMIT, "1f1323128cae942f5440c035cb5f42788b3de33f");
assert!(is_required_pro_option("retry-on-403"));
assert!(is_required_protocol("xml-rpc"));
assert!(is_required_rpc_method("aria2.addMetalink"));
assert_eq!(Protocol::Https.as_str(), "https");
assert_eq!(ControlFileVersion::CURRENT.major(), 1);
assert_eq!(
GoalProgress::new("Phase 0 - Foundation").phase_name(),
"Phase 0 - Foundation"
);
}
#[test]
fn streamed_execution_truth_surfaces_align_across_storage_and_protocol() {
let payload = b"stream-truth";
let mut sink = ObservedByteSink::with_unbounded_retention();
sink.write(payload)
.expect("observed sink write is infallible");
let mut checksum = ChecksumSpec {
algorithm: "md5".to_owned(),
expected_hex: String::new(),
actual_hex: None,
};
checksum.expected_hex = checksum
.compute_actual_hex(sink.retained())
.expect("md5 digest should be computable");
let response = HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Streamed {
expected_len: None,
observed_len: Some(sink.observed_len()),
observed_digest: checksum.compute_actual_hex(sink.retained()),
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: Some(checksum),
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(response.completed_length(), sink.observed_len());
assert_eq!(completion.completed_length, sink.observed_len());
assert!(completion.checksum_seen);
assert!(completion.checksum_verified);
assert_eq!(completion.state, HttpCompletionState::Verified);
}
#[test]
fn add_metalink_prefers_protocol_selected_resource_over_first_resource() {
let metalink_xml = r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="metalink-priority.bin">
<url priority="10">http://fallback.example.org/metalink-priority.bin</url>
<url priority="1" location="CN" type="https">https://preferred.example.org/metalink-priority.bin</url>
</file>
</metalink>"#;
let parsed = parse_metalink_document(metalink_xml).expect("fixture metalink should parse");
let expected_uri = aria2_rust_pro_protocol::preferred_download_candidate(&parsed)
.map(|(_, resource)| resource.url.clone())
.expect("fixture should expose a preferred resource");
let mut dispatcher = InProcessRpcDispatcher::new();
let add_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddMetalink.as_str().to_owned(),
params: vec![RpcValue::String(metalink_xml.to_owned())],
meta: Default::default(),
});
let gid = match add_response.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected addMetalink gid entry: {other:?}"),
},
other => panic!("unexpected addMetalink result: {other:?}"),
};
let uris = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetUris.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
let selected_uri = match uris.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(entry)) => match entry.get("uri") {
Some(RpcValue::String(uri)) => uri.clone(),
other => panic!("unexpected uri field payload: {other:?}"),
},
other => panic!("unexpected getUris first entry: {other:?}"),
},
other => panic!("unexpected getUris response: {other:?}"),
};
assert_eq!(
selected_uri, expected_uri,
"dispatcher should honor protocol-layer preferred resource selection"
);
}
#[test]
fn xmlrpc_add_metalink_roundtrip_and_dispatch_preserve_preferred_resource_semantics() {
let metalink_xml = r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="metalink-xmlrpc.bin">
<url priority="20">http://fallback.example.org/metalink-xmlrpc.bin</url>
<url priority="1" type="https">https://preferred.example.org/metalink-xmlrpc.bin</url>
</file>
</metalink>"#;
let expected_uri = aria2_rust_pro_protocol::preferred_download_candidate(
&parse_metalink_document(metalink_xml).expect("fixture metalink should parse"),
)
.map(|(_, resource)| resource.url.clone())
.expect("fixture should expose a preferred resource");
let call_xml = format!(
"<methodCall><methodName>aria2.addMetalink</methodName><params><param><value><string>{}</string></value></param></params></methodCall>",
metalink_xml
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
);
let parsed_call = xmlrpc_method_call_from_xml(&call_xml).expect("xmlrpc methodCall parse");
let canonical_xml = xmlrpc_method_call_to_xml(&parsed_call);
let reparsed_call =
xmlrpc_method_call_from_xml(&canonical_xml).expect("canonical xmlrpc should parse");
assert!(
canonical_xml.contains("aria2.addMetalink")
&& canonical_xml.contains("metalink-xmlrpc.bin")
&& reparsed_call.method_name == "aria2.addMetalink",
"xmlrpc methodCall should roundtrip cleanly for raw transport"
);
let mut dispatcher = InProcessRpcDispatcher::new();
let version_response = dispatcher.dispatch_xml(
xmlrpc_method_call_from_xml(
"<methodCall><methodName>aria2.getVersion</methodName><params></params></methodCall>",
)
.expect("xmlrpc getVersion call should parse"),
);
let response_xml = xmlrpc_method_response_to_xml(&version_response);
assert!(
response_xml.contains("<methodResponse>")
&& response_xml.contains(aria2_rust_pro_compat::VERSION),
"xmlrpc methodResponse should be renderable for raw transport"
);
let add_response = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddMetalink.as_str().to_owned(),
params: vec![RpcValue::String(metalink_xml.to_owned())],
meta: Default::default(),
});
let gid = match add_response.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::String(gid)) => gid.clone(),
other => panic!("unexpected addMetalink gid entry: {other:?}"),
},
other => panic!("unexpected addMetalink result: {other:?}"),
};
let uris = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetUris.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
let selected_uri = match uris.result {
Some(RpcValue::Array(entries)) => match entries.first() {
Some(RpcValue::Object(entry)) => match entry.get("uri") {
Some(RpcValue::String(uri)) => uri.clone(),
other => panic!("unexpected uri field payload: {other:?}"),
},
other => panic!("unexpected getUris first entry: {other:?}"),
},
other => panic!("unexpected getUris response: {other:?}"),
};
assert_eq!(
selected_uri, expected_uri,
"protocol-layer preferred-resource selection should stay consistent across XML-RPC parse/render and JSON-RPC dispatch"
);
}
#[test]
fn xmlrpc_and_jsonrpc_add_metalink_expand_same_actionable_file_count() {
let metalink_xml = r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="alpha.bin">
<url priority="1">https://example.org/alpha.bin</url>
</file>
<file name="ignored.bin">
<url priority="1"></url>
</file>
<file name="beta.bin">
<url priority="1">https://example.org/beta.bin</url>
</file>
</metalink>"#;
let mut json_dispatcher = InProcessRpcDispatcher::new();
let json_result = rpc_result(json_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddMetalink,
vec![RpcValue::String(metalink_xml.to_owned())],
)));
let mut xml_dispatcher = InProcessRpcDispatcher::new();
let xml_result = rpc_result_from_xml(xml_dispatcher.dispatch_xml(xmlrpc_request(
"aria2.addMetalink",
vec![RpcValue::String(metalink_xml.to_owned())],
)));
let json_count = match json_result {
RpcValue::Array(items) => items.len(),
other => panic!("unexpected json addMetalink payload: {other:?}"),
};
let xml_count = match xml_result {
RpcValue::Array(items) => items.len(),
other => panic!("unexpected xml addMetalink payload: {other:?}"),
};
assert_eq!(json_count, 2);
assert_eq!(xml_count, json_count);
}
#[test]
fn jsonrpc_save_session_writes_storage_compatible_session_file() {
let session_root = std::env::temp_dir().join(format!(
"aria2-rust-pro-tests-session-root-{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&session_root);
let session_path = session_root.join("session.txt");
let mut dispatcher = InProcessRpcDispatcher::with_runtime(
RuntimeConfig::default().with_session_path(session_path.clone()),
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(
"https://session.example.org/session-download.bin".to_owned(),
)],
meta: Default::default(),
});
let added_gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let save = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2SaveSession.as_str().to_owned(),
params: vec![],
meta: Default::default(),
});
assert_eq!(
save.result,
Some(RpcValue::String("OK".to_owned())),
"saveSession should return OK for writable target"
);
let loaded = load_session_file(&session_path).expect("saved session file should load");
assert!(
loaded.entries.iter().any(|entry| entry.gid == added_gid
&& entry
.uris
.iter()
.any(|uri| uri.contains("session-download.bin"))),
"saved session should be readable by storage crate and contain addUri payload"
);
let _ = std::fs::remove_file(session_path);
let _ = std::fs::remove_dir_all(session_root);
}
@@ -0,0 +1,306 @@
use super::*;
#[test]
fn xmlrpc_bt_status_roundtrip_preserves_new_bt_runtime_fields() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let status_value = dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid)],
))
.result
.expect("tellStatus should produce a bt status payload");
let response_xml = xmlrpc_method_response_to_xml(&XmlRpcMethodResponse {
value: Some(rpc_value_to_xmlrpc(status_value)),
fault: None,
meta: Default::default(),
});
assert!(response_xml.contains("<name>isBt</name>"));
assert!(response_xml.contains("<name>announceList</name>"));
assert!(response_xml.contains("<name>bitfield</name>"));
assert!(response_xml.contains("<name>shareRatio</name>"));
assert!(response_xml.contains("<name>magnetUri</name>"));
let reparsed = xmlrpc_method_response_from_xml(&response_xml)
.expect("rendered xmlrpc response should roundtrip");
let rerendered = xmlrpc_method_response_to_xml(&reparsed);
assert!(rerendered.contains("<name>isBt</name>"));
assert!(rerendered.contains("<name>seeder</name>"));
assert!(rerendered.contains("<name>numSeeders</name>"));
}
#[test]
fn parsed_jsonrpc_change_option_request_shape_matches_manual_dispatch_state() {
let mut parsed_dispatcher = InProcessRpcDispatcher::new();
let mut manual_dispatcher = InProcessRpcDispatcher::new();
let parsed_gid = match parsed_dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(
"https://example.org/shape-parsed.iso".to_owned(),
)],
))
.result
{
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected parsed addUri result: {other:?}"),
};
let manual_gid = match manual_dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(
"https://example.org/shape-manual.iso".to_owned(),
)],
))
.result
{
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected manual addUri result: {other:?}"),
};
let request_json = format!(
r#"{{"jsonrpc":"2.0","id":"lane-f-shape","method":"aria2.changeOption","params":["{parsed_gid}",{{"split":8,"out":"shape.bin"}}]}}"#
);
let parsed_request =
jsonrpc_request_from_json(&request_json).expect("request JSON should parse");
let _ = parsed_dispatcher.dispatch_json(parsed_request);
let _ = manual_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(manual_gid.clone()),
RpcValue::Object(BTreeMap::from([
("out".to_owned(), RpcValue::String("shape.bin".to_owned())),
("split".to_owned(), RpcValue::Number(8)),
])),
],
));
let parsed_option_payload = rpc_object_result(parsed_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(parsed_gid)],
)));
let manual_option_payload = rpc_object_result(manual_dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(manual_gid)],
)));
assert_eq!(
parsed_option_payload, manual_option_payload,
"raw JSON-RPC request parsing should preserve the same downstream option state as a manually constructed request"
);
}
#[test]
fn xmlrpc_and_jsonrpc_get_global_option_payloads_match_exactly() {
let mut dispatcher = InProcessRpcDispatcher::new();
let _ = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([
(
"max-connection-per-server".to_owned(),
RpcValue::String("32".to_owned()),
),
("retry-on-403".to_owned(), RpcValue::Bool(true)),
(
"all-proxy-user".to_owned(),
RpcValue::String("proxy-user".to_owned()),
),
("ftp-pasv".to_owned(), RpcValue::Bool(false)),
]))],
));
let json_payload = rpc_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2GetGlobalOption, Vec::new())),
);
let xml_payload = rpc_result_from_xml(
dispatcher.dispatch_xml(xmlrpc_request("aria2.getGlobalOption", Vec::new())),
);
assert_eq!(
xml_payload, json_payload,
"XML-RPC should expose the same global option object as JSON-RPC"
);
}
#[test]
fn xmlrpc_and_jsonrpc_get_option_payloads_match_for_proxy_and_ftp_surface() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = match dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::Array(vec![RpcValue::String(
"https://example.org/file.iso".to_owned(),
)])],
)) {
JsonRpcResponse {
result: Some(RpcValue::String(gid)),
..
} => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let _ = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeGlobalOption,
vec![RpcValue::Object(BTreeMap::from([(
"all-proxy-user".to_owned(),
RpcValue::String("global-proxy-user".to_owned()),
)]))],
));
let _ = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([
(
"ftp-proxy-user".to_owned(),
RpcValue::String("ftp-user".to_owned()),
),
("ftp-pasv".to_owned(), RpcValue::Bool(false)),
])),
],
));
let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
)));
let xml_payload = rpc_result_from_xml(dispatcher.dispatch_xml(xmlrpc_request(
"aria2.getOption",
vec![RpcValue::String(gid)],
)));
assert_eq!(
xml_payload, json_payload,
"XML-RPC should expose the same per-download option object as JSON-RPC for proxy/FTP options"
);
}
#[test]
fn xmlrpc_and_jsonrpc_tell_status_filtered_bt_payloads_match_after_runtime_updates() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
dispatcher
.apply_bt_runtime_tick(&gid, 32_768, 0, 512, 0, 0, 0, false, Some(6))
.expect("download tick should complete the torrent payload");
dispatcher
.set_bt_seeding_state(&gid, true, Some(1_000))
.expect("completed torrent should enter seeding");
dispatcher
.tick_bt_runtime_clock(&gid, 1_045, true)
.expect("share clock should advance");
dispatcher
.apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(9))
.expect("upload tick should enrich bt runtime fields");
let selected_keys = vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
RpcValue::String("completedLength".to_owned()),
RpcValue::String("uploadLength".to_owned()),
RpcValue::String("connections".to_owned()),
RpcValue::String("files".to_owned()),
RpcValue::String("bitfield".to_owned()),
RpcValue::String("isBt".to_owned()),
RpcValue::String("metadataOnly".to_owned()),
RpcValue::String("magnetUri".to_owned()),
RpcValue::String("announceList".to_owned()),
RpcValue::String("shareRatio".to_owned()),
RpcValue::String("shareTime".to_owned()),
RpcValue::String("seeder".to_owned()),
RpcValue::String("numSeeders".to_owned()),
RpcValue::String("completedPieces".to_owned()),
];
let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![
RpcValue::String(gid.clone()),
RpcValue::Array(selected_keys.clone()),
],
)));
let xml_payload = rpc_result_from_xml(dispatcher.dispatch_xml(xmlrpc_request(
"aria2.tellStatus",
vec![RpcValue::String(gid), RpcValue::Array(selected_keys)],
)));
assert_eq!(
xml_payload, json_payload,
"filtered BT tellStatus payloads should stay semantically identical across XML-RPC and JSON-RPC"
);
}
#[test]
fn xmlrpc_and_jsonrpc_multicall_nested_payloads_match_semantically() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let method_specs = RpcValue::Array(vec![
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("aria2.getVersion".to_owned()),
),
("params".to_owned(), RpcValue::Array(Vec::new())),
])),
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("aria2.getSessionInfo".to_owned()),
),
("params".to_owned(), RpcValue::Array(Vec::new())),
])),
RpcValue::Object(BTreeMap::from([
(
"methodName".to_owned(),
RpcValue::String("aria2.tellStatus".to_owned()),
),
(
"params".to_owned(),
RpcValue::Array(vec![
RpcValue::String(gid),
RpcValue::Array(vec![
RpcValue::String("gid".to_owned()),
RpcValue::String("status".to_owned()),
RpcValue::String("isBt".to_owned()),
RpcValue::String("magnetUri".to_owned()),
]),
]),
),
])),
]);
let json_payload = rpc_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::SystemMulticall,
vec![method_specs.clone()],
)));
let xml_payload = rpc_result_from_xml(
dispatcher.dispatch_xml(xmlrpc_request("system.multicall", vec![method_specs])),
);
assert_eq!(
xml_payload, json_payload,
"nested multicall results should preserve the same payload structure across RPC front doors"
);
}
#[test]
fn xmlrpc_and_jsonrpc_invalid_gid_errors_share_underlying_rpc_error() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = "bad-option-gid".to_owned();
let json_error = dispatcher
.dispatch_json(rpc_request(
RpcMethod::Aria2GetOption,
vec![RpcValue::String(gid.clone())],
))
.error
.expect("invalid gid should return a JSON-RPC error");
let xml_fault = dispatcher
.dispatch_xml(xmlrpc_request(
"aria2.getOption",
vec![RpcValue::String(gid)],
))
.fault
.expect("invalid gid should return an XML-RPC fault");
assert_eq!(xml_fault.code, 1);
assert_eq!(xml_fault.message, json_error.message);
assert_eq!(xml_fault.error, Some(json_error));
}
@@ -0,0 +1,617 @@
use super::*;
#[test]
fn rpc_bt_status_pressure_smoke_keeps_responses_healthy() {
let mut dispatcher = InProcessRpcDispatcher::new();
let mut gids = Vec::new();
for i in 0..32 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=bt-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 1
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result in pressure smoke: {other:?}"),
};
gids.push(gid);
}
let mut ok_responses = 0usize;
for _round in 0..4 {
for gid in &gids {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
ok_responses += 1;
}
other => panic!("unexpected tellStatus payload in pressure smoke: {other:?}"),
}
}
}
assert_eq!(ok_responses, gids.len() * 4);
}
#[test]
fn rpc_bt_pressure_smoke_keeps_true_seeding_fields_consistent() {
let mut dispatcher = InProcessRpcDispatcher::new();
let torrent_add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())],
meta: Default::default(),
});
let torrent_gid = match torrent_add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result in pressure smoke: {other:?}"),
};
let mut background_gids = Vec::new();
for i in 0..24 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=bt-live-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 101
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
match add.result {
Some(RpcValue::String(gid)) => background_gids.push(gid),
other => panic!("unexpected addUri result in live pressure smoke: {other:?}"),
}
}
let mut observed_share_times = Vec::new();
let mut torrent_seed_states = Vec::new();
for round in 0..4 {
for gid in std::iter::once(&torrent_gid).chain(background_gids.iter()) {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let payload = match status.result {
Some(RpcValue::Object(payload)) => payload,
other => {
panic!("unexpected tellStatus payload in live pressure smoke: {other:?}")
}
};
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
if gid == &torrent_gid {
let seeder = match payload.get("seeder") {
Some(RpcValue::String(value)) => value == "true",
other => {
panic!("unexpected seeder field in live pressure smoke: {other:?}")
}
};
torrent_seed_states.push(seeder);
let share_time = match payload.get("shareTime") {
Some(RpcValue::String(value)) => {
value.parse::<u64>().expect("shareTime should parse")
}
other => {
panic!("unexpected shareTime field in live pressure smoke: {other:?}")
}
};
observed_share_times.push(share_time);
if round >= 2 {
assert!(
seeder,
"torrent should be a local seeder once payload is complete"
);
assert_eq!(
payload.get("shareRatio"),
Some(&RpcValue::String("0.500".to_owned()))
);
assert_eq!(
payload.get("uploadSpeed"),
Some(&RpcValue::String("180".to_owned()))
);
} else if round == 0 {
assert!(
!seeder,
"torrent should not claim local seeding before any seeding runtime has started"
);
}
}
}
match round {
0 => {
dispatcher
.set_bt_seeding_state(&torrent_gid, true, Some(1_000))
.expect("should start seeding runtime state before completion");
dispatcher
.tick_bt_runtime_clock(&torrent_gid, 1_020, true)
.expect("should advance runtime clock before completion");
}
1 => {
dispatcher
.apply_bt_runtime_tick(
&torrent_gid,
32_768,
16_384,
90,
180,
5,
5,
true,
Some(16),
)
.expect("should complete payload and expose true seeding");
}
2 => {
dispatcher
.tick_bt_runtime_clock(&torrent_gid, 1_040, true)
.expect("should keep advancing share clock after completion");
}
_ => {}
}
}
assert_eq!(observed_share_times[0], 0);
assert!(torrent_seed_states[0..1].iter().all(|state| !state));
assert!(torrent_seed_states[2..].iter().all(|state| *state));
assert!(
observed_share_times
.windows(2)
.all(|window| window[1] >= window[0]),
"shareTime should be monotonic after seeding becomes visible: {observed_share_times:?}"
);
assert_eq!(observed_share_times[1], 20);
assert_eq!(observed_share_times[2], 25);
assert_eq!(observed_share_times[3], 45);
}
#[test]
fn rpc_bt_pressure_guard_keeps_status_active_and_global_stat_responsive() {
let mut dispatcher = InProcessRpcDispatcher::new();
let mut gids = Vec::new();
for i in 0..64 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 10_001
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
match add.result {
Some(RpcValue::String(gid)) => gids.push(gid),
other => panic!("unexpected addUri result in BT pressure guard: {other:?}"),
}
}
let mut tell_status_calls = 0usize;
let mut tell_status_millis = 0_u128;
let mut tell_active_millis = 0_u128;
let mut tell_global_stat_millis = 0_u128;
for round in 0..4 {
if let Some(first_gid) = gids.first() {
dispatcher
.apply_bt_runtime_tick(
first_gid,
0,
0,
200 + round * 10,
100 + round * 10,
0,
0,
false,
Some(8),
)
.expect("pressure guard runtime tick should succeed");
}
let tell_status_start = Instant::now();
for gid in &gids {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
tell_status_calls += 1;
}
other => {
panic!("unexpected tellStatus payload in BT pressure guard: {other:?}")
}
}
}
tell_status_millis += tell_status_start.elapsed().as_millis();
let tell_active_start = Instant::now();
let active = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellActive.as_str().to_owned(),
params: Vec::new(),
meta: Default::default(),
});
match active.result {
Some(RpcValue::Array(items)) => {
assert!(
items.iter().all(|item| matches!(item, RpcValue::Object(_))),
"tellActive should keep returning object rows under BT-like pressure"
);
}
other => panic!("unexpected tellActive payload in BT pressure guard: {other:?}"),
}
tell_active_millis += tell_active_start.elapsed().as_millis();
let tell_global_stat_start = Instant::now();
let global = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellGlobalStat.as_str().to_owned(),
params: Vec::new(),
meta: Default::default(),
});
match global.result {
Some(RpcValue::Object(payload)) => {
assert!(payload.contains_key("numActive"));
assert!(payload.contains_key("downloadSpeed"));
}
other => {
panic!("unexpected tellGlobalStat payload in BT pressure guard: {other:?}")
}
}
tell_global_stat_millis += tell_global_stat_start.elapsed().as_millis();
}
assert_eq!(tell_status_calls, gids.len() * 4);
assert!(
tell_status_millis <= 2_000,
"synthetic tellStatus pressure guard regressed badly: {tell_status_millis}ms for {tell_status_calls} calls"
);
assert!(
tell_active_millis <= 500,
"synthetic tellActive pressure guard regressed badly: {tell_active_millis}ms"
);
assert!(
tell_global_stat_millis <= 500,
"synthetic tellGlobalStat pressure guard regressed badly: {tell_global_stat_millis}ms"
);
}
#[test]
fn rpc_bt_mixed_pressure_guard_covers_churned_status_files_and_global_views() {
const TASK_COUNT: usize = 96;
const ROUNDS: usize = 6;
const FILE_SAMPLE_STRIDE: usize = 8;
let mut dispatcher = InProcessRpcDispatcher::new();
let mut gids = Vec::new();
for i in 0..TASK_COUNT {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=mixed-pressure-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 30_001
);
let add = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet)],
));
match add.result {
Some(RpcValue::String(gid)) => gids.push(gid),
other => {
panic!("unexpected addUri result in mixed BT pressure guard: {other:?}")
}
}
}
let mut tell_status_calls = 0usize;
let mut get_files_calls = 0usize;
let mut tell_active_calls = 0usize;
let mut tell_global_stat_calls = 0usize;
let mut mixed_millis = 0_u128;
for round in 0..ROUNDS {
let round_start = Instant::now();
for (index, gid) in gids.iter().enumerate() {
dispatcher
.apply_bt_runtime_tick(
gid,
u64::try_from(index + round).unwrap_or(u64::MAX),
u64::from(index % 3 == 0),
128 + u64::try_from(round).unwrap_or_default(),
64 + u64::try_from(index % 17).unwrap_or_default(),
u64::from(index % 5 == 0),
u64::from(index % 7 == 0),
index % 11 == 0,
Some(2 + u32::try_from(index % 9).unwrap_or_default()),
)
.expect("mixed pressure guard runtime tick should succeed");
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(status.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(status.contains_key("status"));
assert!(status.contains_key("completedLength"));
assert!(status.contains_key("connections"));
tell_status_calls += 1;
}
for gid in gids
.iter()
.skip(round % FILE_SAMPLE_STRIDE)
.step_by(FILE_SAMPLE_STRIDE)
{
let files = rpc_array_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
)));
match files.first() {
Some(RpcValue::Object(file)) => {
assert!(file.contains_key("selected"));
assert!(file.contains_key("completedLength"));
assert!(file.contains_key("bitfield"));
}
other => {
panic!("unexpected getFiles payload in mixed pressure guard: {other:?}")
}
}
get_files_calls += 1;
}
let active = rpc_array_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellActive, Vec::new())),
);
assert!(
active
.iter()
.all(|item| matches!(item, RpcValue::Object(_))),
"tellActive should keep object rows under mixed BT pressure"
);
tell_active_calls += 1;
let global = rpc_object_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())),
);
assert!(global.contains_key("numActive"));
assert!(global.contains_key("downloadSpeed"));
assert!(global.contains_key("uploadSpeed"));
tell_global_stat_calls += 1;
mixed_millis += round_start.elapsed().as_millis();
}
assert_eq!(tell_status_calls, TASK_COUNT * ROUNDS);
assert_eq!(get_files_calls, (TASK_COUNT / FILE_SAMPLE_STRIDE) * ROUNDS);
assert_eq!(tell_active_calls, ROUNDS);
assert_eq!(tell_global_stat_calls, ROUNDS);
assert!(
mixed_millis <= 4_000,
"synthetic mixed BT RPC pressure guard regressed badly: {mixed_millis}ms for {tell_status_calls} tellStatus, {get_files_calls} getFiles, {tell_active_calls} tellActive, and {tell_global_stat_calls} tellGlobalStat calls"
);
}
#[test]
fn shared_runtime_speed_caps_rebalance_after_one_download_completes() {
let mut dispatcher = InProcessRpcDispatcher::with_runtime(RuntimeConfig {
max_overall_download_limit: Some(1_200),
max_overall_upload_limit: Some(600),
..RuntimeConfig::default()
});
let gids = vec![
add_magnet(&mut dispatcher, 70_001),
add_magnet(&mut dispatcher, 70_002),
add_magnet(&mut dispatcher, 70_003),
];
let change_first = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gids[0].clone()),
RpcValue::Object(BTreeMap::from([
(
"max-download-limit".to_owned(),
RpcValue::String("250".to_owned()),
),
(
"max-upload-limit".to_owned(),
RpcValue::String("120".to_owned()),
),
])),
],
));
assert!(
change_first.error.is_none(),
"changeOption should succeed for the constrained fairness gid"
);
for gid in &gids {
dispatcher
.apply_bt_runtime_tick(gid, 128, 64, 5_000, 2_000, 0, 0, false, Some(6))
.expect("initial fairness runtime tick should succeed");
}
let initial_expected = [(250_u64, 120_u64), (400, 200), (400, 200)];
for (gid, (expected_download_speed, expected_upload_speed)) in gids.iter().zip(initial_expected)
{
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(
rpc_u64_field(&status, "downloadSpeed"),
expected_download_speed
);
assert_eq!(rpc_u64_field(&status, "uploadSpeed"), expected_upload_speed);
assert_eq!(rpc_u64_field(&status, "completedLength"), 128);
}
let initial_global = rpc_object_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())),
);
assert_eq!(rpc_u64_field(&initial_global, "downloadSpeed"), 1_050);
assert_eq!(rpc_u64_field(&initial_global, "uploadSpeed"), 520);
dispatcher
.mark_complete(&gids[0])
.expect("completing the constrained gid should succeed");
for gid in &gids[1..] {
dispatcher
.apply_bt_runtime_tick(gid, 256, 96, 5_000, 2_000, 0, 0, false, Some(6))
.expect("post-completion fairness runtime tick should succeed");
}
let completed_status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gids[0].clone())],
)));
assert_eq!(
completed_status.get("status"),
Some(&RpcValue::String("complete".to_owned()))
);
for gid in &gids[1..] {
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(rpc_u64_field(&status, "downloadSpeed"), 600);
assert_eq!(rpc_u64_field(&status, "uploadSpeed"), 300);
assert_eq!(rpc_u64_field(&status, "completedLength"), 384);
}
let rebalanced_global = rpc_object_result(
dispatcher.dispatch_json(rpc_request(RpcMethod::Aria2TellGlobalStat, Vec::new())),
);
assert_eq!(rpc_u64_field(&rebalanced_global, "downloadSpeed"), 1_200);
assert_eq!(rpc_u64_field(&rebalanced_global, "uploadSpeed"), 600);
}
#[test]
fn bt_live_pressure_status_regression_tracks_true_seeding_runtime_over_repeated_rpc_probes() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = add_torrent(&mut dispatcher);
let bootstrap = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(bootstrap.completed_length, 0);
assert_eq!(bootstrap.share_time, 0);
assert_eq!(bootstrap.share_ratio, "0.000");
assert!(!bootstrap.seeder);
dispatcher
.apply_bt_runtime_tick(&gid, 8_192, 0, 256, 0, 0, 0, false, Some(2))
.expect("first runtime tick should advance partial payload progress");
let partial_a = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(partial_a.completed_length, 8_192);
assert_eq!(partial_a.share_time, 0);
assert_eq!(partial_a.share_ratio, "0.000");
assert!(!partial_a.seeder);
assert_eq!(partial_a.connections, 2);
dispatcher
.apply_bt_runtime_tick(&gid, 8_192, 0, 256, 0, 0, 0, false, Some(3))
.expect("second runtime tick should continue partial payload progress");
let partial_b = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(partial_b.completed_length, 16_384);
assert_eq!(partial_b.share_time, 0);
assert_eq!(partial_b.share_ratio, "0.000");
assert!(!partial_b.seeder);
assert_eq!(partial_b.connections, 3);
dispatcher
.apply_bt_runtime_tick(&gid, 16_384, 0, 512, 0, 0, 0, false, Some(4))
.expect("final download tick should complete payload without forcing seeding");
let completed = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(completed.completed_length, 32_768);
assert_eq!(completed.share_time, 0);
assert_eq!(completed.share_ratio, "0.000");
assert!(
!completed.seeder,
"seeder must stay false until the payload is complete and seeding state flips"
);
assert_eq!(completed.connections, 4);
dispatcher
.set_bt_seeding_state(&gid, true, Some(1_000))
.expect("payload-complete bt group should enter seeding");
let seeding_started = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(seeding_started.completed_length, 32_768);
assert_eq!(seeding_started.share_time, 0);
assert_eq!(seeding_started.share_ratio, "0.000");
assert!(
seeding_started.seeder,
"seeder should only flip after payload completion once seeding begins"
);
dispatcher
.tick_bt_runtime_clock(&gid, 1_040, true)
.expect("share clock should advance under repeated rpc probing");
let seeded_40 = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(seeded_40.completed_length, 32_768);
assert_eq!(
seeded_40.share_time, 40,
"shareTime should reflect the live seeding runtime after 40 seconds"
);
assert_eq!(seeded_40.share_ratio, "0.000");
assert!(seeded_40.seeder);
dispatcher
.apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(16))
.expect("upload tick should populate live share ratio after true payload completion");
let seeded_45 = bt_status_probe(&mut dispatcher, &gid);
assert_eq!(seeded_45.completed_length, 32_768);
assert_eq!(
seeded_45.share_time, 45,
"shareTime should remain monotonic as runtime ticks continue"
);
assert_eq!(
seeded_45.share_ratio, "0.500",
"shareRatio should become meaningful after true payload completion and upload runtime"
);
assert!(seeded_45.seeder);
assert_eq!(seeded_45.connections, 16);
let observed = [
bootstrap.share_time,
partial_a.share_time,
partial_b.share_time,
completed.share_time,
seeding_started.share_time,
seeded_40.share_time,
seeded_45.share_time,
];
assert!(
observed.windows(2).all(|window| window[0] <= window[1]),
"shareTime should stay monotonic across repeated rpc probes: {observed:?}"
);
}
@@ -0,0 +1,340 @@
pub(super) use std::{collections::BTreeMap, sync::Mutex, time::Instant};
pub(super) use aria2_rust_pro_compat::{
BASELINE_COMMIT, is_required_pro_option, is_required_protocol,
};
pub(super) use aria2_rust_pro_core::{GoalProgress, RuntimeConfig};
pub(super) use aria2_rust_pro_protocol::{
ChecksumSpec, DhtMessageModel, DhtNodeModel, DhtTransport, HttpCompletionState,
HttpResponseHeaders, HttpResponseModel, HttpVersion, Protocol, ReqwestTrackerTransport,
ResponseBody, TorrentPeerModel, TrackerPeerListModel, TrackerRequestModel,
TrackerResponseModel, TrackerScrapeFileModel, TrackerScrapeModel, TrackerTransport,
parse_metalink_document,
torrent::{
DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel, PeerWireHandshakeModel,
PeerWireMessageKind, PeerWirePieceBlockModel, TorrentMessageModel,
},
transport::{
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
TransportEndpoint, TransportError, TransportScheme,
},
};
pub(super) use aria2_rust_pro_rpc::{
InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse, RpcMethod, RpcValue, XmlRpcMethodCall,
XmlRpcMethodResponse, XmlRpcParam, is_required_rpc_method, jsonrpc_request_from_json,
rpc_value_to_xmlrpc, xmlrpc_method_call_from_xml, xmlrpc_method_call_to_xml,
xmlrpc_method_response_from_xml, xmlrpc_method_response_to_xml, xmlrpc_value_to_rpc,
};
pub(super) use aria2_rust_pro_storage::{
ByteSink, ControlFileVersion, ObservedByteSink, load_session_file,
};
pub(super) const BT_TORRENT_FIXTURE: &str = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
pub(super) fn rpc_request(method: RpcMethod, params: Vec<RpcValue>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method.as_str().to_owned(),
params,
meta: Default::default(),
}
}
pub(super) fn xmlrpc_request(method_name: &str, params: Vec<RpcValue>) -> XmlRpcMethodCall {
XmlRpcMethodCall {
method_name: method_name.to_owned(),
params: params
.into_iter()
.map(|value| XmlRpcParam {
value: rpc_value_to_xmlrpc(value),
})
.collect(),
meta: Default::default(),
}
}
pub(super) fn rpc_result(response: JsonRpcResponse) -> RpcValue {
match response.result {
Some(result) => result,
None => panic!("unexpected rpc result envelope without result: {response:?}"),
}
}
pub(super) fn rpc_result_from_xml(response: XmlRpcMethodResponse) -> RpcValue {
match response {
XmlRpcMethodResponse {
value: Some(value),
fault: None,
..
} => xmlrpc_value_to_rpc(value),
other => panic!("unexpected xmlrpc success envelope: {other:?}"),
}
}
pub(super) fn add_torrent(dispatcher: &mut InProcessRpcDispatcher) -> String {
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddTorrent,
vec![RpcValue::String(BT_TORRENT_FIXTURE.to_owned())],
));
match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
}
}
pub(super) fn rpc_object_result(response: JsonRpcResponse) -> BTreeMap<String, RpcValue> {
match response.result {
Some(RpcValue::Object(payload)) => payload,
other => panic!("unexpected rpc object result: {other:?}"),
}
}
pub(super) fn rpc_array_result(response: JsonRpcResponse) -> Vec<RpcValue> {
match response.result {
Some(RpcValue::Array(entries)) => entries,
other => panic!("unexpected rpc array result: {other:?}"),
}
}
pub(super) fn rpc_string_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> String {
match payload.get(field) {
Some(RpcValue::String(value)) => value.clone(),
other => panic!("unexpected string field {field}: {other:?}"),
}
}
pub(super) fn rpc_u64_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> u64 {
match payload.get(field) {
Some(RpcValue::String(value)) => value
.parse()
.unwrap_or_else(|error| panic!("unexpected u64 string field {field}: {error}")),
Some(RpcValue::Number(value)) => (*value)
.try_into()
.unwrap_or_else(|_| panic!("unexpected negative number field {field}: {value}")),
other => panic!("unexpected u64 field {field}: {other:?}"),
}
}
pub(super) fn rpc_bool_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> bool {
match payload.get(field) {
Some(RpcValue::Bool(value)) => *value,
Some(RpcValue::String(value)) => value
.parse()
.unwrap_or_else(|error| panic!("unexpected bool string field {field}: {error}")),
other => panic!("unexpected bool field {field}: {other:?}"),
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(super) struct BtStatusProbe {
pub(super) completed_length: u64,
pub(super) share_time: u64,
pub(super) share_ratio: String,
pub(super) seeder: bool,
pub(super) connections: u64,
}
pub(super) fn bt_status_probe(dispatcher: &mut InProcessRpcDispatcher, gid: &str) -> BtStatusProbe {
let status = rpc_object_result(dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.to_owned())],
)));
BtStatusProbe {
completed_length: rpc_u64_field(&status, "completedLength"),
share_time: rpc_u64_field(&status, "shareTime"),
share_ratio: rpc_string_field(&status, "shareRatio"),
seeder: rpc_bool_field(&status, "seeder"),
connections: rpc_u64_field(&status, "connections"),
}
}
pub(super) fn add_magnet(dispatcher: &mut InProcessRpcDispatcher, suffix: u64) -> String {
let magnet = format!(
"magnet:?xt=urn:btih:{suffix:040x}&dn=fairness-{suffix}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"
);
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet)],
));
match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result in fairness setup: {other:?}"),
}
}
pub(super) fn decode_hex_20(raw: &str) -> [u8; 20] {
let bytes = (0..raw.len())
.step_by(2)
.map(|offset| u8::from_str_radix(&raw[offset..offset + 2], 16))
.collect::<Result<Vec<_>, _>>()
.expect("info hash should be valid hex");
bytes.try_into().expect("info hash should be 20 bytes")
}
pub(super) fn peer_wire_handshake_and_frames(
info_hash: [u8; 20],
peer_id: [u8; 20],
frames: &[PeerWireMessageKind],
) -> Vec<u8> {
let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize();
for frame in frames {
bytes.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(frame.clone())
.serialize_peer_wire_frame()
.expect("peer-wire frame should serialize"),
);
}
bytes
}
#[derive(Debug)]
pub(super) struct FakePeerWireConnector {
pub(super) response_payload: Vec<u8>,
pub(super) seen: Mutex<Vec<PeerWireTransportRequest>>,
}
impl FakePeerWireConnector {
pub(super) fn new(response_payload: Vec<u8>) -> Self {
Self {
response_payload,
seen: Mutex::new(Vec::new()),
}
}
pub(super) fn seen(&self) -> Vec<PeerWireTransportRequest> {
self.seen
.lock()
.expect("peer-wire seen requests mutex should not be poisoned")
.clone()
}
}
impl PeerWireTransportConnector for FakePeerWireConnector {
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
self.seen
.lock()
.expect("peer-wire seen requests mutex should not be poisoned")
.push(request.clone());
Ok(PeerWireTransportResponse {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: request.endpoint.address.clone(),
},
payload: self.response_payload.clone(),
})
}
}
#[derive(Debug)]
pub(super) struct FixedPeerWireConnector {
pub(super) response_payload: Vec<u8>,
}
impl PeerWireTransportConnector for FixedPeerWireConnector {
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
Ok(PeerWireTransportResponse {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: request.endpoint.address.clone(),
},
payload: self.response_payload.clone(),
})
}
}
pub(super) struct FixedDhtTransport {
pub(super) response: DhtMessageModel,
}
impl DhtTransport for FixedDhtTransport {
fn send_message(
&self,
_node: &DhtNodeModel,
_message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
Ok(self.response.clone())
}
}
#[derive(Debug)]
pub(super) struct RecordingDhtTransport {
pub(super) response: DhtMessageModel,
pub(super) seen: Mutex<Vec<(String, DhtMessageModel)>>,
}
impl RecordingDhtTransport {
pub(super) fn new(response: DhtMessageModel) -> Self {
Self {
response,
seen: Mutex::new(Vec::new()),
}
}
pub(super) fn seen(&self) -> Vec<(String, DhtMessageModel)> {
self.seen.lock().expect("dht seen mutex").clone()
}
}
impl DhtTransport for RecordingDhtTransport {
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
self.seen
.lock()
.expect("dht seen mutex")
.push((format!("{}:{}", node.address, node.port), message.clone()));
Ok(self.response.clone())
}
}
pub(super) fn peer_wire_payload(
info_hash: [u8; 20],
peer_id: [u8; 20],
frames: &[PeerWireMessageKind],
) -> Vec<u8> {
let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize();
for frame in frames {
bytes.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(frame.clone())
.serialize_peer_wire_frame()
.expect("peer-wire frame should serialize"),
);
}
bytes
}
pub(super) fn info_hash_bytes(input: &str) -> [u8; 20] {
let encoded = input
.split("xt=urn:btih:")
.nth(1)
.and_then(|rest| rest.split('&').next())
.unwrap_or(input);
let mut out = [0_u8; 20];
for (index, chunk) in encoded.as_bytes().chunks_exact(2).enumerate() {
let hex = std::str::from_utf8(chunk).expect("info hash should stay utf8 hex");
out[index] = u8::from_str_radix(hex, 16).expect("info hash should decode from hex");
}
out
}
pub(super) fn compact_peer(ip: [u8; 4], port: u16) -> Vec<u8> {
let mut out = ip.to_vec();
out.extend_from_slice(&port.to_be_bytes());
out
}
pub(super) fn compact_node(node_tag: u8, ip: [u8; 4], port: u16) -> Vec<u8> {
let mut out = vec![node_tag; 20];
out.extend_from_slice(&ip);
out.extend_from_slice(&port.to_be_bytes());
out
}
@@ -0,0 +1,236 @@
use super::*;
#[test]
fn magnet_with_tracker_tiers_and_dht_hint_preserves_bt_status_shape() {
let magnet = "magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=bt-dht-tiered.iso&tr=http%3A%2F%2Ftracker-a.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker-b.example.org%3A6969&x.pe=198.51.100.9%3A51413";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
};
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(matches!(
payload.get("announceList"),
Some(RpcValue::Array(tiers)) if !tiers.is_empty()
));
assert!(matches!(
payload.get("magnetUri"),
Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:")
));
assert!(
payload.contains_key("numSeeders"),
"compat bt status should expose numSeeders key"
);
}
other => panic!("unexpected tellStatus payload for tiered magnet: {other:?}"),
}
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
assert!(
matches!(peers.result, Some(RpcValue::Array(_))),
"getPeers should remain type-stable for dht/tiered magnet surface"
);
}
#[test]
fn tracker_announce_reingest_replaces_peer_view_with_latest_snapshot() {
fn bencode_int(value: i64) -> Vec<u8> {
format!("i{value}e").into_bytes()
}
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
let mut out = format!("{}:", value.len()).into_bytes();
out.extend_from_slice(value);
out
}
fn bencode_list(values: Vec<Vec<u8>>) -> Vec<u8> {
let mut out = vec![b'l'];
for value in values {
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
fn bencode_dict(entries: Vec<(&str, Vec<u8>)>) -> Vec<u8> {
let mut out = vec![b'd'];
for (key, value) in entries {
out.extend_from_slice(format!("{}:{key}", key.len()).as_bytes());
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
let magnet = "magnet:?xt=urn:btih:8899aabbccddeeff00112233445566778899aabb&dn=bt-reannounce.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce";
let mut dispatcher = InProcessRpcDispatcher::new();
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet.to_owned())],
meta: Default::default(),
});
let gid = match add.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri result: {other:?}"),
};
let announce_a = TrackerResponseModel::from_announce_bytes(&bencode_dict(vec![
("interval", bencode_int(1200)),
(
"peers",
bencode_list(vec![bencode_dict(vec![
("ip", bencode_bytes(b"198.51.100.41")),
("port", bencode_int(6001)),
])]),
),
]))
.expect("announce-a should parse");
dispatcher
.apply_tracker_announce_result(&gid, &announce_a)
.expect("announce-a should ingest");
let announce_b = TrackerResponseModel::from_announce_bytes(&bencode_dict(vec![
("interval", bencode_int(900)),
(
"peers",
bencode_list(vec![
bencode_dict(vec![
("ip", bencode_bytes(b"198.51.100.42")),
("port", bencode_int(6002)),
]),
bencode_dict(vec![
("ip", bencode_bytes(b"198.51.100.43")),
("port", bencode_int(6003)),
]),
]),
),
]))
.expect("announce-b should parse");
dispatcher
.apply_tracker_announce_result(&gid, &announce_b)
.expect("announce-b should ingest");
let peers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetPeers.as_str().to_owned(),
params: vec![RpcValue::String(gid)],
meta: Default::default(),
});
match peers.result {
Some(RpcValue::Array(items)) => {
assert_eq!(
items.len(),
2,
"second announce snapshot should be reflected"
);
let ports = items
.into_iter()
.filter_map(|item| match item {
RpcValue::Object(peer) => peer.get("port").cloned(),
_ => None,
})
.collect::<Vec<_>>();
assert!(ports.contains(&RpcValue::String("6002".to_owned())));
assert!(ports.contains(&RpcValue::String("6003".to_owned())));
}
other => panic!("unexpected getPeers payload after reannounce: {other:?}"),
}
}
#[test]
fn bt_mixed_rpc_surface_smoke_preserves_peer_and_server_type_contracts() {
let mut dispatcher = InProcessRpcDispatcher::new();
let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
let add_torrent = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
params: vec![RpcValue::String(torrent_payload.to_owned())],
meta: Default::default(),
});
let torrent_gid = match add_torrent.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
};
let mut magnet_gids = Vec::new();
for i in 0..20 {
let magnet = format!(
"magnet:?xt=urn:btih:{:040x}&dn=bt-mixed-{i}&tr=http%3A%2F%2Ftracker.example.org%2Fannounce",
i + 4000
);
let add = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
params: vec![RpcValue::String(magnet)],
meta: Default::default(),
});
match add.result {
Some(RpcValue::String(gid)) => magnet_gids.push(gid),
other => panic!("unexpected addUri result in mixed smoke: {other:?}"),
}
}
let mut bt_status_objects = 0usize;
for gid in magnet_gids.iter().chain(std::iter::once(&torrent_gid)) {
let status = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
match status.result {
Some(RpcValue::Object(payload)) => {
assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true)));
assert!(payload.contains_key("status"));
assert!(payload.contains_key("completedLength"));
bt_status_objects += 1;
}
other => panic!("unexpected tellStatus payload in mixed smoke: {other:?}"),
}
let servers = dispatcher.dispatch_json(JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: RpcMethod::Aria2GetServers.as_str().to_owned(),
params: vec![RpcValue::String(gid.clone())],
meta: Default::default(),
});
let error = servers
.error
.expect("getServers should reject non-active BT downloads");
assert!(
error
.message
.contains(&format!("No active download for GID#{gid}"))
);
}
assert_eq!(bt_status_objects, magnet_gids.len() + 1);
}
@@ -0,0 +1,300 @@
#![doc(hidden)]
#![expect(
dead_code,
unreachable_pub,
reason = "shared integration-test support intentionally exposes a superset of helpers because each integration suite only consumes part of it"
)]
use aria2_rust_pro_cli as _;
use aria2_rust_pro_compat as _;
use aria2_rust_pro_core as _;
use aria2_rust_pro_storage as _;
use aria2_rust_pro_tests as _;
use criterion as _;
use std::{
collections::BTreeMap,
io::{Read, Write},
net::TcpListener,
path::Path,
sync::Mutex,
thread,
};
use aria2_rust_pro_protocol::{
DhtMessageModel, DhtNodeModel, DhtTransport, PeerWireTransportConnector,
PeerWireTransportRequest, PeerWireTransportResponse, ReqwestTrackerTransport,
TorrentMessageModel,
torrent::{PeerWireHandshakeModel, PeerWireMessageKind},
transport::{TransportEndpoint, TransportError, TransportScheme},
};
use aria2_rust_pro_rpc::{
InProcessRpcDispatcher, JsonRpcRequest, JsonRpcResponse, RpcMeta, RpcMethod, RpcValue,
};
pub const BT_TORRENT_FIXTURE_BASE64: &str = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl";
pub const BT_TORRENT_FIXTURE_BYTES: &[u8] = b"d8:announce35:http://tracker.example.org/announce4:infod4:name10:ubuntu.iso12:piece lengthi16384e6:lengthi32768e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee";
pub fn write_torrent_fixture(path: &Path) {
std::fs::write(path, BT_TORRENT_FIXTURE_BYTES).expect("torrent fixture should write");
}
pub fn rpc_request(method: RpcMethod, params: Vec<RpcValue>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: Some("2.0".to_owned()),
id: None,
method: method.as_str().to_owned(),
params,
meta: RpcMeta::default(),
}
}
pub fn rpc_result_object(response: JsonRpcResponse) -> BTreeMap<String, RpcValue> {
match response.result {
Some(RpcValue::Object(payload)) => payload,
other => panic!("unexpected object rpc result: {other:?}"),
}
}
pub fn rpc_result_array(response: JsonRpcResponse) -> Vec<RpcValue> {
match response.result {
Some(RpcValue::Array(payload)) => payload,
other => panic!("unexpected array rpc result: {other:?}"),
}
}
pub fn rpc_string_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> String {
match payload.get(field) {
Some(RpcValue::String(value)) => value.clone(),
other => panic!("unexpected string field {field}: {other:?}"),
}
}
pub fn rpc_u64_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> u64 {
match payload.get(field) {
Some(RpcValue::String(value)) => value
.parse()
.unwrap_or_else(|error| panic!("unexpected u64 string field {field}: {error}")),
Some(RpcValue::Number(value)) => (*value)
.try_into()
.unwrap_or_else(|_| panic!("unexpected negative number field {field}: {value}")),
other => panic!("unexpected u64 field {field}: {other:?}"),
}
}
pub fn rpc_bool_field(payload: &BTreeMap<String, RpcValue>, field: &str) -> bool {
match payload.get(field) {
Some(RpcValue::Bool(value)) => *value,
Some(RpcValue::String(value)) => value
.parse()
.unwrap_or_else(|error| panic!("unexpected bool string field {field}: {error}")),
other => panic!("unexpected bool field {field}: {other:?}"),
}
}
pub fn add_torrent(dispatcher: &mut InProcessRpcDispatcher) -> String {
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddTorrent,
vec![RpcValue::String(BT_TORRENT_FIXTURE_BASE64.to_owned())],
));
match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addTorrent result: {other:?}"),
}
}
pub fn add_magnet(dispatcher: &mut InProcessRpcDispatcher, magnet: &str) -> String {
let response = dispatcher.dispatch_json(rpc_request(
RpcMethod::Aria2AddUri,
vec![RpcValue::String(magnet.to_owned())],
));
match response.result {
Some(RpcValue::String(gid)) => gid,
other => panic!("unexpected addUri magnet result: {other:?}"),
}
}
pub fn decode_hex_20(raw: &str) -> [u8; 20] {
let mut chunks = raw.as_bytes().chunks_exact(2);
let bytes = chunks
.by_ref()
.map(|chunk| {
let pair = std::str::from_utf8(chunk).expect("hex field should stay ascii");
u8::from_str_radix(pair, 16)
})
.collect::<Result<Vec<_>, _>>()
.expect("hex field should parse");
assert!(
chunks.remainder().is_empty(),
"hex field should contain an even number of digits"
);
bytes.try_into().expect("hex field should be 20 bytes")
}
pub fn peer_wire_handshake_and_frames(
info_hash: [u8; 20],
peer_id: [u8; 20],
frames: &[PeerWireMessageKind],
) -> Vec<u8> {
let mut bytes = PeerWireHandshakeModel::new(info_hash, peer_id).serialize();
for frame in frames {
bytes.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(frame.clone())
.serialize_peer_wire_frame()
.expect("peer-wire frame should serialize"),
);
}
bytes
}
pub fn compact_peer(address: [u8; 4], port: u16) -> Vec<u8> {
let mut bytes = address.to_vec();
bytes.extend_from_slice(&port.to_be_bytes());
bytes
}
pub fn compact_node(node_id_byte: u8, address: [u8; 4], port: u16) -> Vec<u8> {
let mut bytes = vec![node_id_byte; 20];
bytes.extend_from_slice(&address);
bytes.extend_from_slice(&port.to_be_bytes());
bytes
}
#[derive(Debug)]
pub struct RecordingDhtTransport {
response: DhtMessageModel,
seen: Mutex<Vec<(DhtNodeModel, DhtMessageModel)>>,
}
impl RecordingDhtTransport {
pub const fn new(response: DhtMessageModel) -> Self {
Self {
response,
seen: Mutex::new(Vec::new()),
}
}
pub fn seen(&self) -> Vec<(DhtNodeModel, DhtMessageModel)> {
self.seen
.lock()
.expect("dht seen mutex should not be poisoned")
.clone()
}
}
impl DhtTransport for RecordingDhtTransport {
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
self.seen
.lock()
.expect("dht seen mutex should not be poisoned")
.push((node.clone(), message.clone()));
Ok(self.response.clone())
}
}
#[derive(Debug)]
pub struct FakePeerWireConnector {
response_payload: Vec<u8>,
seen: Mutex<Vec<PeerWireTransportRequest>>,
}
impl FakePeerWireConnector {
pub const fn new(response_payload: Vec<u8>) -> Self {
Self {
response_payload,
seen: Mutex::new(Vec::new()),
}
}
pub fn seen(&self) -> Vec<PeerWireTransportRequest> {
self.seen
.lock()
.expect("peer-wire seen mutex should not be poisoned")
.clone()
}
}
impl PeerWireTransportConnector for FakePeerWireConnector {
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
self.seen
.lock()
.expect("peer-wire seen mutex should not be poisoned")
.push(request.clone());
Ok(PeerWireTransportResponse {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: request.endpoint.address.clone(),
},
payload: self.response_payload.clone(),
})
}
}
#[derive(Debug)]
pub struct LocalTrackerServer {
announce_url: String,
handle: Option<thread::JoinHandle<()>>,
}
impl LocalTrackerServer {
pub fn spawn(compact_peers: Vec<u8>, interval_secs: u64) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("tracker listener should bind");
let addr = listener
.local_addr()
.expect("tracker listener should report local addr");
let announce_url = format!("http://{addr}/announce");
let handle = thread::spawn(move || {
let mut payload = format!(
"d8:intervali{interval_secs}e5:peers{}:",
compact_peers.len()
)
.into_bytes();
payload.extend_from_slice(&compact_peers);
payload.extend_from_slice(b"e");
let (mut stream, _) = listener.accept().expect("tracker client should connect");
let mut request = [0_u8; 2048];
let _ = stream
.read(&mut request)
.expect("tracker request should read");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
stream
.write_all(response.as_bytes())
.expect("tracker response head should write");
stream
.write_all(&payload)
.expect("tracker response body should write");
});
Self {
announce_url,
handle: Some(handle),
}
}
pub fn announce_url(&self) -> &str {
&self.announce_url
}
}
impl Drop for LocalTrackerServer {
fn drop(&mut self) {
if let Some(handle) = self.handle.take() {
handle.join().expect("tracker server thread should join");
}
}
}
pub fn tracker_transport() -> ReqwestTrackerTransport {
ReqwestTrackerTransport::new().expect("reqwest tracker transport should build")
}
@@ -0,0 +1,170 @@
#![expect(
missing_docs,
reason = "integration test scenarios are documented by case names rather than item-level rustdoc"
)]
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use aria2_rust_pro_cli::{
BtStatusReport, Invocation, TransferSelection, execute_runtime, execute_runtime_with_downloader,
};
use aria2_rust_pro_compat as _;
use aria2_rust_pro_core as _;
use aria2_rust_pro_protocol::FixtureHttpDownloader;
use aria2_rust_pro_storage as _;
use aria2_rust_pro_tests as _;
use criterion as _;
#[path = "../test_support/support.rs"]
mod support;
fn unique_temp_path(stem: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("aria2-rust-pro-tests-{stem}-{unique}"))
}
fn write_config(path: &Path, download_dir: &Path) {
fs::write(path, format!("dir={}", download_dir.display())).expect("config should write");
}
fn assert_bt_snapshot(
bt: &BtStatusReport,
expected_metadata_only: bool,
expected_total_length: Option<u64>,
) {
assert_eq!(bt.is_bt, Some(true));
assert_eq!(bt.metadata_only, Some(expected_metadata_only));
assert_eq!(bt.share_time, Some(0));
assert_eq!(bt.share_ratio.as_deref(), Some("0.000"));
assert_eq!(bt.share_ratio_progress.as_deref(), Some("0.000"));
assert_eq!(bt.share_ratio_remaining.as_deref(), Some("0.000"));
assert!(
bt.announce_list_tier_count.unwrap_or_default() >= 1,
"bt snapshot should retain at least one announce tier"
);
if !expected_metadata_only {
assert!(
bt.magnet_uri
.as_deref()
.is_some_and(|uri| uri.starts_with("magnet:?xt=urn:btih:"))
);
assert_eq!(expected_total_length, Some(32_768));
}
}
#[test]
fn foreground_magnet_runtime_report_keeps_bt_snapshot_visible() {
let report = execute_runtime(Invocation::Run {
config_path: None,
uris: vec![String::from(
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=dht-metadata.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker.example.org%3A6969",
)],
})
.expect("runtime should execute for a bt magnet");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.completed_download_count, 0);
assert_eq!(report.transfer_kinds, vec![TransferSelection::Magnet]);
assert_eq!(report.recognized_schemes, vec![String::from("magnet")]);
assert_eq!(report.first_total_length, Some(0));
assert_eq!(report.first_completed_length, Some(0));
let bt = report
.first_bt_status
.as_ref()
.expect("magnet foreground execution should surface a bt snapshot");
assert_bt_snapshot(bt, true, report.first_total_length);
assert_eq!(bt.num_seeders, Some(0));
}
#[test]
fn foreground_local_torrent_file_runs_as_bt_session_not_plain_uri() {
let temp_root = unique_temp_path("local-torrent");
let download_dir = temp_root.join("downloads");
let torrent_path = temp_root.join("fixture.torrent");
let config_path = temp_root.join("aria2.conf");
fs::create_dir_all(&download_dir).expect("download directory should create");
support::write_torrent_fixture(&torrent_path);
write_config(&config_path, &download_dir);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![torrent_path.to_string_lossy().into_owned()],
},
&FixtureHttpDownloader::new(),
)
.expect("runtime should accept a local torrent input");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.completed_download_count, 0);
assert_eq!(report.transfer_kinds, vec![TransferSelection::Torrent]);
assert_eq!(report.first_total_length, Some(32_768));
assert_eq!(report.first_completed_length, Some(0));
let bt = report
.first_bt_status
.as_ref()
.expect("local torrent foreground execution should expose bt status");
assert_bt_snapshot(bt, false, report.first_total_length);
assert!(
!download_dir.join("fixture.torrent").exists(),
"torrent metainfo should bootstrap a bt session instead of being persisted as the final download artifact"
);
let _ = fs::remove_dir_all(&temp_root);
}
#[test]
fn foreground_remote_torrent_url_bootstraps_bt_session_instead_of_saving_metainfo_payload() {
let temp_root = unique_temp_path("remote-torrent");
let download_dir = temp_root.join("downloads");
let config_path = temp_root.join("aria2.conf");
fs::create_dir_all(&download_dir).expect("download directory should create");
write_config(&config_path, &download_dir);
let torrent_url = "https://tracker.example.org/files/ubuntu.torrent";
let mut downloader = FixtureHttpDownloader::new();
downloader.register(torrent_url, support::BT_TORRENT_FIXTURE_BYTES);
let report = execute_runtime_with_downloader(
Invocation::Run {
config_path: Some(config_path),
uris: vec![torrent_url.to_owned()],
},
&downloader,
)
.expect("runtime should accept a remote torrent url");
assert_eq!(report.accepted_uri_count, 1);
assert_eq!(report.tracked_download_count, 1);
assert_eq!(report.completed_download_count, 0);
assert_eq!(report.transfer_kinds, vec![TransferSelection::Torrent]);
assert_eq!(report.recognized_schemes, vec![String::from("https")]);
assert_eq!(report.first_total_length, Some(32_768));
assert_eq!(report.first_completed_length, Some(0));
let bt = report
.first_bt_status
.as_ref()
.expect("remote torrent bootstrap should expose bt status");
assert_bt_snapshot(bt, false, report.first_total_length);
assert!(
!download_dir.join("ubuntu.torrent").exists(),
"remote torrent bootstrap should not leave the .torrent payload behind as the downloaded artifact"
);
let _ = fs::remove_dir_all(&temp_root);
}
@@ -0,0 +1,168 @@
#![expect(
missing_docs,
reason = "integration test scenarios are documented by case names rather than item-level rustdoc"
)]
use std::collections::BTreeMap;
use aria2_rust_pro_cli as _;
use aria2_rust_pro_compat as _;
use aria2_rust_pro_core as _;
use aria2_rust_pro_protocol::{
DhtMessageModel, DhtNodeModel,
torrent::{PeerWireExtensionHandshakeModel, PeerWireMessageKind, PeerWireMetadataMessageModel},
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, RpcMethod, RpcValue};
use aria2_rust_pro_storage as _;
use aria2_rust_pro_tests as _;
use criterion as _;
#[path = "../test_support/support.rs"]
mod support;
fn tell_status(dispatcher: &mut InProcessRpcDispatcher, gid: &str) -> BTreeMap<String, RpcValue> {
support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.to_owned())],
)))
}
fn get_files(dispatcher: &mut InProcessRpcDispatcher, gid: &str) -> Vec<RpcValue> {
support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.to_owned())],
)))
}
fn first_file<'a>(files: &'a [RpcValue], context: &str) -> &'a BTreeMap<String, RpcValue> {
match files.first() {
Some(RpcValue::Object(file)) => file,
other => panic!("unexpected first file payload for {context}: {other:?}"),
}
}
fn seed_peer_and_promote_metadata(
dispatcher: &mut InProcessRpcDispatcher,
gid: &str,
info_hash_hex: &str,
) {
dispatcher
.apply_dht_get_peers_result(
gid,
&DhtNodeModel {
node_id: String::new(),
address: "203.0.113.77".to_owned(),
port: 51413,
},
&DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x55; 20],
Some(b"dht-token".to_vec()),
None,
vec![support::compact_peer([198, 51, 100, 42], 51415)],
),
)
.expect("dht get_peers should seed a connectable peer");
let connector = support::FakePeerWireConnector::new(support::peer_wire_handshake_and_frames(
support::decode_hex_20(info_hash_hex),
*b"-AZ2060-META-PROMO01",
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Extension(
PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]),
client_name: Some("libtorrent/2.0.11".to_owned()),
metadata_size: Some(
u32::try_from(support::BT_TORRENT_FIXTURE_BYTES.len())
.expect("fixture metadata length should fit u32"),
),
request_queue: Some(64),
}
.to_peer_wire_message(),
),
PeerWireMessageKind::Extension(
PeerWireMetadataMessageModel::data(
0,
u32::try_from(support::BT_TORRENT_FIXTURE_BYTES.len())
.expect("fixture metadata length should fit u32"),
support::BT_TORRENT_FIXTURE_BYTES.to_vec(),
)
.to_peer_wire_message(3),
),
],
));
dispatcher
.execute_peer_wire_exchange(gid, &connector)
.expect("peer-wire metadata exchange should promote the magnet session");
}
#[test]
fn magnet_piece_observation_clears_metadata_only_and_pending_snapshot_flags() {
let mut dispatcher = InProcessRpcDispatcher::new();
let reference_gid = support::add_torrent(&mut dispatcher);
let reference_status = tell_status(&mut dispatcher, &reference_gid);
let magnet_uri = support::rpc_string_field(&reference_status, "magnetUri");
let info_hash = support::rpc_string_field(&reference_status, "infoHash");
let gid = support::add_magnet(&mut dispatcher, &magnet_uri);
let before = tell_status(&mut dispatcher, &gid);
assert!(support::rpc_bool_field(&before, "metadataOnly"));
assert_eq!(support::rpc_string_field(&before, "magnetUri"), magnet_uri);
let before_snapshot = dispatcher
.bt_runtime_coordinator_snapshot(&gid)
.expect("snapshot should inspect metadata-only magnet state");
assert!(before_snapshot.metadata_only);
assert!(before_snapshot.metadata_exchange_pending);
seed_peer_and_promote_metadata(&mut dispatcher, &gid, &info_hash);
let after = tell_status(&mut dispatcher, &gid);
assert!(!support::rpc_bool_field(&after, "metadataOnly"));
assert_eq!(support::rpc_string_field(&after, "magnetUri"), magnet_uri);
let after_snapshot = dispatcher
.bt_runtime_coordinator_snapshot(&gid)
.expect("snapshot should inspect promoted magnet state");
assert!(!after_snapshot.metadata_only);
assert!(!after_snapshot.metadata_exchange_pending);
}
#[test]
fn promoted_magnet_session_should_match_reference_torrent_file_surface() {
let mut dispatcher = InProcessRpcDispatcher::new();
let reference_gid = support::add_torrent(&mut dispatcher);
let reference_status = tell_status(&mut dispatcher, &reference_gid);
let reference_files = get_files(&mut dispatcher, &reference_gid);
let reference_file = first_file(&reference_files, "reference torrent");
let magnet_uri = support::rpc_string_field(&reference_status, "magnetUri");
let info_hash = support::rpc_string_field(&reference_status, "infoHash");
let gid = support::add_magnet(&mut dispatcher, &magnet_uri);
seed_peer_and_promote_metadata(&mut dispatcher, &gid, &info_hash);
let promoted_status = tell_status(&mut dispatcher, &gid);
assert!(
!support::rpc_bool_field(&promoted_status, "metadataOnly"),
"metadata promotion should leave metadata-only mode before comparing the public torrent surface"
);
assert_eq!(
support::rpc_u64_field(&promoted_status, "totalLength"),
support::rpc_u64_field(&reference_status, "totalLength"),
"promoted magnet sessions should hydrate the same totalLength visible on the equivalent .torrent bootstrap"
);
let promoted_files = get_files(&mut dispatcher, &gid);
let promoted_file = first_file(&promoted_files, "promoted magnet");
assert_eq!(
promoted_file.get("path"),
reference_file.get("path"),
"promoted magnet sessions should expose the real torrent file path instead of a synthetic placeholder row"
);
assert_eq!(
promoted_file.get("length"),
reference_file.get("length"),
"promoted magnet sessions should expose the same per-file length as the equivalent .torrent bootstrap"
);
}
@@ -0,0 +1,317 @@
#![expect(
missing_docs,
reason = "integration test scenarios are documented by case names rather than item-level rustdoc"
)]
use std::collections::BTreeMap;
use aria2_rust_pro_cli as _;
use aria2_rust_pro_compat as _;
use aria2_rust_pro_core as _;
use aria2_rust_pro_protocol::{
DhtMessageModel, TrackerRequestModel, TrackerTransport,
torrent::{
DhtMessageBody, DhtQueryModel, PeerWireBitfieldModel, PeerWireMessageKind,
PeerWirePieceBlockModel,
},
};
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, RpcMethod, RpcValue};
use aria2_rust_pro_storage as _;
use aria2_rust_pro_tests as _;
use criterion as _;
#[path = "../test_support/support.rs"]
mod support;
#[test]
#[expect(
clippy::too_many_lines,
reason = "integration scenario keeps one end-to-end bt surface in a single readable flow"
)]
fn tracker_dht_peer_wire_and_seeding_updates_remain_rpc_visible_across_public_dispatcher_api() {
let tracker_server =
support::LocalTrackerServer::spawn(support::compact_peer([198, 51, 100, 9], 51413), 900);
let tracker = support::tracker_transport();
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = support::add_torrent(&mut dispatcher);
let bootstrap_status =
support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
let info_hash = support::rpc_string_field(&bootstrap_status, "infoHash");
let magnet_uri = support::rpc_string_field(&bootstrap_status, "magnetUri");
let announce = tracker
.announce(&TrackerRequestModel {
announce_url: tracker_server.announce_url().to_owned(),
info_hash: info_hash.clone(),
peer_id: "0123456789abcdef0123456789abcdef01234567".to_owned(),
port: 6881,
uploaded: 0,
downloaded: 0,
left: 32_768,
event: Some("started".to_owned()),
compact: true,
numwant: Some(10),
})
.expect("live tracker announce should succeed");
dispatcher
.apply_tracker_announce_result(&gid, &announce)
.expect("tracker announce should update the bt view");
let dht_get_peers = support::RecordingDhtTransport::new(DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x55; 20],
Some(b"dht-token".to_vec()),
Some(support::compact_node(0x44, [203, 0, 113, 99], 51414)),
vec![support::compact_peer([198, 51, 100, 10], 51415)],
));
dispatcher
.execute_dht_get_peers(&gid, &dht_get_peers)
.expect("dht get_peers should succeed");
let dht_find_node = support::RecordingDhtTransport::new(DhtMessageModel::find_node_response(
b"fn".to_vec(),
vec![0x66; 20],
vec![aria2_rust_pro_protocol::torrent::DhtCompactNodeModel {
node_id: [0x77; 20],
address: [203, 0, 113, 100],
port: 51416,
}],
));
dispatcher
.execute_dht_find_node(&gid, &dht_find_node)
.expect("dht find_node should succeed");
let dht_announce = support::RecordingDhtTransport::new(DhtMessageModel::ping_response(
b"ap".to_vec(),
vec![0x88; 20],
));
dispatcher
.execute_dht_announce_peer(&gid, &dht_announce)
.expect("dht announce_peer should succeed after token handoff");
let info_hash_bytes = support::decode_hex_20(&info_hash);
let remote_peer_id = *b"-AZ2060-MODERN-PEER!";
let connector = support::FakePeerWireConnector::new(support::peer_wire_handshake_and_frames(
info_hash_bytes,
remote_peer_id,
&[
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Bitfield(PeerWireBitfieldModel::from_piece_flags(&[true, true])),
PeerWireMessageKind::Piece(PeerWirePieceBlockModel {
piece_index: 0,
block_offset: 0,
block: vec![0x5a; 16_384],
}),
],
));
dispatcher
.execute_peer_wire_exchange(&gid, &connector)
.expect("peer-wire exchange should succeed");
dispatcher
.apply_bt_runtime_tick(&gid, 16_384, 0, 512, 0, 0, 0, false, Some(8))
.expect("runtime tick should finish the remaining payload");
dispatcher
.set_bt_seeding_state(&gid, true, Some(1_000))
.expect("completed torrent should enter seeding");
dispatcher
.tick_bt_runtime_clock(&gid, 1_030, true)
.expect("share clock should advance");
dispatcher
.apply_bt_runtime_tick(&gid, 0, 16_384, 90, 180, 5, 5, true, Some(8))
.expect("upload tick should populate visible share fields");
let _ = dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2ChangeOption,
vec![
RpcValue::String(gid.clone()),
RpcValue::Object(BTreeMap::from([(
"select-file".to_owned(),
RpcValue::String("1".to_owned()),
)])),
],
));
let status = support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2TellStatus,
vec![RpcValue::String(gid.clone())],
)));
assert!(support::rpc_bool_field(&status, "isBt"));
assert!(!support::rpc_bool_field(&status, "metadataOnly"));
assert_eq!(support::rpc_string_field(&status, "magnetUri"), magnet_uri);
assert_eq!(support::rpc_u64_field(&status, "completedLength"), 32_768);
assert_eq!(support::rpc_u64_field(&status, "connections"), 8);
assert_eq!(support::rpc_string_field(&status, "shareRatio"), "0.500");
assert_eq!(support::rpc_u64_field(&status, "shareTime"), 35);
assert!(support::rpc_bool_field(&status, "seeder"));
assert_eq!(support::rpc_u64_field(&status, "numSeeders"), 1);
let files = support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
)));
match files.first() {
Some(RpcValue::Object(file)) => {
assert_eq!(
file.get("selected"),
Some(&RpcValue::String("true".to_owned()))
);
assert_eq!(
file.get("path"),
Some(&RpcValue::String("ubuntu.iso".to_owned()))
);
}
other => panic!("unexpected file payload after bt orchestration: {other:?}"),
}
let peers = support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2GetPeers,
vec![RpcValue::String(gid.clone())],
)));
match peers.first() {
Some(RpcValue::Object(peer)) => {
assert_eq!(
peer.get("peerId"),
Some(&RpcValue::String(
"2d415a323036302d4d4f4445524e2d5045455221".to_owned()
))
);
assert_eq!(
peer.get("peerChoking"),
Some(&RpcValue::String("false".to_owned()))
);
assert_eq!(
peer.get("seeder"),
Some(&RpcValue::String("true".to_owned()))
);
}
other => panic!("unexpected peer payload after bt orchestration: {other:?}"),
}
let seen_announce = dht_announce.seen();
assert_eq!(
seen_announce.len(),
1,
"announce_peer should send exactly one query"
);
match &seen_announce
.first()
.expect("announce_peer should record one query")
.1
.body
{
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(query)) => {
assert_eq!(query.info_hash, info_hash_bytes);
assert_eq!(query.token, b"dht-token".to_vec());
assert_eq!(query.port, 6881);
}
other => panic!("unexpected announce_peer query payload: {other:?}"),
}
let seen_peer_wire = connector.seen();
assert_eq!(
seen_peer_wire.len(),
1,
"peer-wire transport should see one request"
);
assert!(
seen_peer_wire
.first()
.expect("peer-wire transport should record one request")
.payload
.len()
> 68,
"peer-wire request should include the handshake plus follow-up frames"
);
}
#[test]
fn bt_status_surfaces_remain_monotonic_under_public_runtime_tick_pressure() {
let mut dispatcher = InProcessRpcDispatcher::new();
let gid = support::add_torrent(&mut dispatcher);
let mut last_completed = 0_u64;
let mut last_share_time = 0_u64;
for round in 0..48_u64 {
let downloaded_delta = if round < 16 { 2_048 } else { 0 };
let uploaded_delta = if round >= 16 { 512 } else { 0 };
let seeding = round >= 16;
dispatcher
.apply_bt_runtime_tick(
&gid,
downloaded_delta,
uploaded_delta,
256 + round,
128 + round,
u64::from(seeding),
u64::from(seeding),
seeding,
Some(2 + u32::try_from(round % 5).expect("round modulo 5 should fit u32")),
)
.expect("runtime tick should succeed under repeated probing");
if round == 16 {
dispatcher
.set_bt_seeding_state(&gid, true, Some(2_000))
.expect("completed torrent should enter seeding");
}
if seeding {
dispatcher
.tick_bt_runtime_clock(&gid, 2_000 + round, true)
.expect("runtime clock should advance while seeding");
}
let status = support::rpc_result_object(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2TellStatus,
vec![
RpcValue::String(gid.clone()),
RpcValue::Array(vec![
RpcValue::String("status".to_owned()),
RpcValue::String("completedLength".to_owned()),
RpcValue::String("connections".to_owned()),
RpcValue::String("files".to_owned()),
RpcValue::String("bitfield".to_owned()),
RpcValue::String("isBt".to_owned()),
RpcValue::String("shareRatio".to_owned()),
RpcValue::String("shareTime".to_owned()),
RpcValue::String("seeder".to_owned()),
]),
],
)));
let completed = support::rpc_u64_field(&status, "completedLength");
let share_time = support::rpc_u64_field(&status, "shareTime");
assert!(support::rpc_bool_field(&status, "isBt"));
assert!(
completed >= last_completed,
"completedLength should stay monotonic under repeated probes"
);
assert!(
share_time >= last_share_time,
"shareTime should stay monotonic under repeated probes"
);
assert!(
status.contains_key("bitfield"),
"bt tellStatus should retain bitfield visibility under pressure"
);
assert!(
status.contains_key("files"),
"bt tellStatus should retain files visibility under pressure"
);
last_completed = completed;
last_share_time = share_time;
let files = support::rpc_result_array(dispatcher.dispatch_json(support::rpc_request(
RpcMethod::Aria2GetFiles,
vec![RpcValue::String(gid.clone())],
)));
assert_eq!(files.len(), 1, "torrent fixture should expose one file");
match files.first() {
Some(RpcValue::Object(file)) => {
assert!(file.contains_key("selected"));
assert!(file.contains_key("completedLength"));
}
other => panic!("unexpected getFiles payload under pressure: {other:?}"),
}
}
}