mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 01:44:01 +00:00
1439 lines
51 KiB
Rust
1439 lines
51 KiB
Rust
use async_trait::async_trait;
|
|
use serde_json::{json, Map, Value};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
const STOP_POLL_ATTEMPTS: usize = 30;
|
|
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
|
const CANCELLATION_CLEANUP_ATTEMPTS: usize = 3;
|
|
const CANCELLATION_CLEANUP_INTERVAL: Duration = Duration::from_millis(250);
|
|
|
|
#[async_trait]
|
|
pub(crate) trait RpcClient: Send + Sync {
|
|
async fn call(&self, method: &str, params: Value) -> Result<Value, String>;
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(crate) enum ProbeFailure {
|
|
Metadata(String),
|
|
Cleanup(String),
|
|
}
|
|
|
|
pub(crate) async fn run_metadata_probe<C: RpcClient + 'static>(
|
|
client: Arc<C>,
|
|
source: &str,
|
|
mut options: Map<String, Value>,
|
|
metadata_path: &Path,
|
|
timeout: Duration,
|
|
poll_interval: Duration,
|
|
) -> Result<Vec<u8>, ProbeFailure> {
|
|
// This probe only resolves magnet metadata. It must never allow Aria2 to
|
|
// interpret a downloaded metadata file as another child download because
|
|
// the probe cleanup guard owns exactly one GID.
|
|
options.insert("follow-torrent".to_string(), json!("false"));
|
|
options.insert("follow-metalink".to_string(), json!("false"));
|
|
let mut cleanup_guard = ProbeCleanupGuard::new(Arc::clone(&client), metadata_path);
|
|
let result = match client
|
|
.call("aria2.addUri", json!([[source], options]))
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(error) => {
|
|
cleanup_guard.disarm();
|
|
return Err(ProbeFailure::Metadata(format!(
|
|
"Aria2 could not start magnet metadata resolution: {}",
|
|
crate::redact_sensitive_text(&error)
|
|
)));
|
|
}
|
|
};
|
|
let gid = match result.as_str().filter(|value| !value.is_empty()) {
|
|
Some(gid) => gid.to_string(),
|
|
None => {
|
|
cleanup_guard.disarm();
|
|
return Err(ProbeFailure::Metadata(
|
|
"Aria2 returned an empty metadata probe GID".to_string(),
|
|
));
|
|
}
|
|
};
|
|
cleanup_guard.set_gid(gid.clone());
|
|
|
|
let metadata_result = async {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
let status = match client
|
|
.call(
|
|
"aria2.tellStatus",
|
|
json!([&gid, ["status", "errorCode", "errorMessage"]]),
|
|
)
|
|
.await
|
|
{
|
|
Ok(status) => status,
|
|
Err(error) if crate::aria2_gid_not_found(&error) => {
|
|
return Err(ProbeFailure::Metadata(
|
|
"Aria2 removed the magnet metadata probe before metadata was saved"
|
|
.to_string(),
|
|
));
|
|
}
|
|
Err(error) if crate::retry::is_transient_network_error(&error) => {
|
|
if Instant::now() >= deadline {
|
|
return Err(ProbeFailure::Metadata(format!(
|
|
"Aria2 metadata resolution status failed: {}",
|
|
crate::redact_sensitive_text(&error)
|
|
)));
|
|
}
|
|
tokio::time::sleep(poll_interval).await;
|
|
continue;
|
|
}
|
|
Err(error) => {
|
|
return Err(ProbeFailure::Metadata(format!(
|
|
"Aria2 metadata resolution status failed: {}",
|
|
crate::redact_sensitive_text(&error)
|
|
)));
|
|
}
|
|
};
|
|
match status.get("status").and_then(Value::as_str) {
|
|
Some("complete") => break,
|
|
Some("error") | Some("removed") => {
|
|
let error_code = status
|
|
.get("errorCode")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.is_empty());
|
|
let error_message = status
|
|
.get("errorMessage")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or("metadata probe ended without a torrent file");
|
|
let detail = match error_code {
|
|
Some(code) => format!("aria2 error code {code}: {error_message}"),
|
|
None => error_message.to_string(),
|
|
};
|
|
return Err(ProbeFailure::Metadata(format!(
|
|
"Aria2 could not resolve magnet metadata: {}",
|
|
crate::redact_sensitive_text(&detail)
|
|
)));
|
|
}
|
|
Some("active") | Some("waiting") | Some("paused") => {}
|
|
None => {
|
|
return Err(ProbeFailure::Metadata(
|
|
"Aria2 returned an invalid metadata probe status".to_string(),
|
|
));
|
|
}
|
|
Some(status) => {
|
|
return Err(ProbeFailure::Metadata(format!(
|
|
"Aria2 returned an unsupported metadata probe status: {status}"
|
|
)));
|
|
}
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Err(ProbeFailure::Metadata(
|
|
"Aria2 magnet metadata resolution timed out".to_string(),
|
|
));
|
|
}
|
|
tokio::time::sleep(poll_interval).await;
|
|
}
|
|
|
|
tokio::fs::read(metadata_path).await.map_err(|error| {
|
|
ProbeFailure::Metadata(format!(
|
|
"Aria2 did not save magnet metadata ({:?})",
|
|
error.kind()
|
|
))
|
|
})
|
|
}
|
|
.await;
|
|
|
|
let cleanup_result = cleanup_metadata_probe(client.as_ref(), &gid).await;
|
|
if let Err(error) = cleanup_result {
|
|
return Err(ProbeFailure::Cleanup(error));
|
|
}
|
|
cleanup_guard.disarm();
|
|
metadata_result
|
|
}
|
|
|
|
struct ProbeCleanupGuard<C: RpcClient + 'static> {
|
|
client: Arc<C>,
|
|
gid: Option<String>,
|
|
probe_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl<C: RpcClient + 'static> ProbeCleanupGuard<C> {
|
|
fn new(client: Arc<C>, metadata_path: &Path) -> Self {
|
|
Self {
|
|
client,
|
|
gid: None,
|
|
probe_dir: metadata_path
|
|
.parent()
|
|
.filter(|path| !path.as_os_str().is_empty())
|
|
.map(Path::to_path_buf),
|
|
}
|
|
}
|
|
|
|
fn set_gid(&mut self, gid: String) {
|
|
self.gid = Some(gid);
|
|
}
|
|
|
|
fn disarm(&mut self) {
|
|
self.gid = None;
|
|
self.probe_dir = None;
|
|
}
|
|
}
|
|
|
|
impl<C: RpcClient + 'static> Drop for ProbeCleanupGuard<C> {
|
|
fn drop(&mut self) {
|
|
let gid = self.gid.take();
|
|
let probe_dir = self.probe_dir.take();
|
|
if gid.is_none() && probe_dir.is_none() {
|
|
return;
|
|
}
|
|
|
|
let Some(runtime) = tokio::runtime::Handle::try_current().ok() else {
|
|
log::warn!("magnet metadata probe was canceled without an active Tokio runtime");
|
|
return;
|
|
};
|
|
let client = Arc::clone(&self.client);
|
|
runtime.spawn(async move {
|
|
let cleanup_result = if let Some(gid) = gid {
|
|
let mut last_error = None;
|
|
for attempt in 0..CANCELLATION_CLEANUP_ATTEMPTS {
|
|
match cleanup_metadata_probe(client.as_ref(), &gid).await {
|
|
Ok(()) => {
|
|
last_error = None;
|
|
break;
|
|
}
|
|
Err(error) => {
|
|
last_error = Some(error);
|
|
if attempt + 1 < CANCELLATION_CLEANUP_ATTEMPTS {
|
|
tokio::time::sleep(CANCELLATION_CLEANUP_INTERVAL).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
last_error
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(error) = cleanup_result {
|
|
log::warn!(
|
|
"canceled magnet metadata probe cleanup failed: {}",
|
|
crate::redact_sensitive_text(&error)
|
|
);
|
|
// Do not delete a directory while Aria2 may still own the
|
|
// GID. The startup reaper removes this orphan before the
|
|
// next daemon launch.
|
|
return;
|
|
}
|
|
if let Some(probe_dir) = probe_dir {
|
|
if let Err(error) = tokio::fs::remove_dir_all(&probe_dir).await {
|
|
if error.kind() != std::io::ErrorKind::NotFound {
|
|
log::warn!(
|
|
"canceled magnet metadata probe directory cleanup failed ({:?})",
|
|
error.kind()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn cleanup_metadata_probe<C: RpcClient>(client: &C, gid: &str) -> Result<(), String> {
|
|
match client.call("aria2.forceRemove", json!([gid])).await {
|
|
Ok(result) => {
|
|
crate::ensure_aria2_gid_result("forceRemove", gid, &result)?;
|
|
wait_for_stopped(client, gid).await
|
|
}
|
|
Err(error) if crate::aria2_gid_not_found(&error) => Ok(()),
|
|
Err(error) => match aria2_status(client, gid).await {
|
|
Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => Ok(()),
|
|
Err(status_error) if crate::aria2_gid_not_found(&status_error) => Ok(()),
|
|
_ => Err(format!(
|
|
"failed to remove aria2 gid {gid}: {}",
|
|
crate::redact_sensitive_text(&error)
|
|
)),
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn aria2_status<C: RpcClient>(client: &C, gid: &str) -> Result<String, String> {
|
|
let result = client
|
|
.call("aria2.tellStatus", json!([gid, ["status"]]))
|
|
.await
|
|
.map_err(|error| {
|
|
format!(
|
|
"failed to query aria2 gid {gid}: {}",
|
|
crate::redact_sensitive_text(&error)
|
|
)
|
|
})?;
|
|
result
|
|
.get("status")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
.ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}"))
|
|
}
|
|
|
|
async fn wait_for_stopped<C: RpcClient>(client: &C, gid: &str) -> Result<(), String> {
|
|
let mut last_transient_error = None;
|
|
for _ in 0..STOP_POLL_ATTEMPTS {
|
|
match aria2_status(client, gid).await {
|
|
Ok(status)
|
|
if matches!(status.as_str(), "paused" | "complete" | "error" | "removed") =>
|
|
{
|
|
return Ok(())
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) if crate::aria2_gid_not_found(&error) => return Ok(()),
|
|
Err(error) if crate::retry::is_transient_network_error(&error) => {
|
|
last_transient_error = Some(error);
|
|
}
|
|
Err(error) => return Err(error),
|
|
}
|
|
tokio::time::sleep(STOP_POLL_INTERVAL).await;
|
|
}
|
|
match last_transient_error {
|
|
Some(error) => Err(format!(
|
|
"aria2 gid {gid} did not stop within 3 seconds after forceRemove: {}",
|
|
crate::redact_sensitive_text(&error)
|
|
)),
|
|
None => Err(format!(
|
|
"aria2 gid {gid} did not stop within 3 seconds after forceRemove"
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::{
|
|
body::Body,
|
|
extract::State,
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
routing::post,
|
|
Json, Router,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::collections::VecDeque;
|
|
use std::net::SocketAddr;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Mutex;
|
|
use tokio::sync::{oneshot, watch, Notify};
|
|
|
|
enum ScriptedReply {
|
|
Result(Value),
|
|
RpcError(String),
|
|
HttpError(StatusCode, String),
|
|
Malformed(String),
|
|
Delay(Duration, Box<ScriptedReply>),
|
|
Hang,
|
|
}
|
|
|
|
struct RecordedCall {
|
|
method: String,
|
|
params: Value,
|
|
}
|
|
|
|
struct ScriptedRpcState {
|
|
secret: String,
|
|
scripts: Mutex<HashMap<String, VecDeque<ScriptedReply>>>,
|
|
calls: Mutex<Vec<RecordedCall>>,
|
|
call_notification: Notify,
|
|
termination: watch::Sender<bool>,
|
|
}
|
|
|
|
struct ScriptedRpcServer {
|
|
address: SocketAddr,
|
|
state: Arc<ScriptedRpcState>,
|
|
shutdown: Option<oneshot::Sender<()>>,
|
|
task: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
impl ScriptedRpcServer {
|
|
async fn start(scripts: impl IntoIterator<Item = (String, Vec<ScriptedReply>)>) -> Self {
|
|
let secret = "torrent-probe-test-secret".to_string();
|
|
let (termination, _) = watch::channel(false);
|
|
let state = Arc::new(ScriptedRpcState {
|
|
secret: secret.clone(),
|
|
scripts: Mutex::new(
|
|
scripts
|
|
.into_iter()
|
|
.map(|(method, replies)| (method, replies.into_iter().collect()))
|
|
.collect(),
|
|
),
|
|
calls: Mutex::new(Vec::new()),
|
|
call_notification: Notify::new(),
|
|
termination,
|
|
});
|
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
|
.await
|
|
.expect("scripted RPC listener should bind");
|
|
let address = listener
|
|
.local_addr()
|
|
.expect("scripted RPC listener should have an address");
|
|
let app = Router::new()
|
|
.route("/jsonrpc", post(scripted_rpc_handler))
|
|
.with_state(Arc::clone(&state));
|
|
let (shutdown, shutdown_signal) = oneshot::channel();
|
|
let task = tokio::spawn(async move {
|
|
let _ = axum::serve(listener, app)
|
|
.with_graceful_shutdown(async {
|
|
let _ = shutdown_signal.await;
|
|
})
|
|
.await;
|
|
});
|
|
Self {
|
|
address,
|
|
state,
|
|
shutdown: Some(shutdown),
|
|
task: Some(task),
|
|
}
|
|
}
|
|
|
|
fn client(&self) -> Arc<crate::Aria2RpcClient> {
|
|
Arc::new(crate::Aria2RpcClient {
|
|
port: self.address.port(),
|
|
secret: self.state.secret.clone(),
|
|
})
|
|
}
|
|
|
|
fn calls(&self) -> Vec<(String, Value)> {
|
|
self.state
|
|
.calls
|
|
.lock()
|
|
.expect("scripted RPC call log lock should work")
|
|
.iter()
|
|
.map(|call| (call.method.clone(), call.params.clone()))
|
|
.collect()
|
|
}
|
|
|
|
async fn wait_for_method(&self, method: &str, occurrence: usize) {
|
|
loop {
|
|
let count = self
|
|
.state
|
|
.calls
|
|
.lock()
|
|
.expect("scripted RPC call log lock should work")
|
|
.iter()
|
|
.filter(|call| call.method == method)
|
|
.count();
|
|
if count >= occurrence {
|
|
return;
|
|
}
|
|
self.state.call_notification.notified().await;
|
|
}
|
|
}
|
|
|
|
async fn shutdown(mut self) {
|
|
self.state.termination.send_replace(true);
|
|
if let Some(shutdown) = self.shutdown.take() {
|
|
let _ = shutdown.send(());
|
|
}
|
|
if let Some(task) = self.task.take() {
|
|
task.await.expect("scripted RPC server should stop cleanly");
|
|
}
|
|
}
|
|
|
|
async fn terminate(mut self) {
|
|
self.state.termination.send_replace(true);
|
|
if let Some(shutdown) = self.shutdown.take() {
|
|
let _ = shutdown.send(());
|
|
}
|
|
if let Some(mut task) = self.task.take() {
|
|
match tokio::time::timeout(Duration::from_secs(1), &mut task).await {
|
|
Ok(result) => {
|
|
let _ = result;
|
|
}
|
|
Err(_) => {
|
|
task.abort();
|
|
let _ = task.await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ScriptedRpcServer {
|
|
fn drop(&mut self) {
|
|
self.state.termination.send_replace(true);
|
|
if let Some(task) = self.task.take() {
|
|
task.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn scripted_rpc_handler(
|
|
State(state): State<Arc<ScriptedRpcState>>,
|
|
Json(request): Json<Value>,
|
|
) -> Response {
|
|
let id = request.get("id").cloned().unwrap_or(Value::Null);
|
|
let method = request
|
|
.get("method")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("<missing-method>")
|
|
.to_string();
|
|
let params = request.get("params").cloned().unwrap_or(Value::Null);
|
|
state
|
|
.calls
|
|
.lock()
|
|
.expect("scripted RPC call log lock should work")
|
|
.push(RecordedCall {
|
|
method: method.clone(),
|
|
params: params.clone(),
|
|
});
|
|
state.call_notification.notify_waiters();
|
|
|
|
let token = params
|
|
.as_array()
|
|
.and_then(|params| params.first())
|
|
.and_then(Value::as_str);
|
|
let expected_token = format!("token:{}", state.secret);
|
|
if token != Some(expected_token.as_str()) {
|
|
return rpc_error_response(
|
|
id,
|
|
StatusCode::UNAUTHORIZED,
|
|
"invalid RPC token".to_string(),
|
|
);
|
|
}
|
|
|
|
let reply = state
|
|
.scripts
|
|
.lock()
|
|
.expect("scripted RPC scripts lock should work")
|
|
.get_mut(&method)
|
|
.and_then(VecDeque::pop_front)
|
|
.unwrap_or_else(|| {
|
|
ScriptedReply::RpcError(format!("unexpected scripted RPC method {method}"))
|
|
});
|
|
scripted_reply_response(id, reply, state.termination.subscribe()).await
|
|
}
|
|
|
|
async fn scripted_reply_response(
|
|
id: Value,
|
|
mut reply: ScriptedReply,
|
|
mut termination: watch::Receiver<bool>,
|
|
) -> Response {
|
|
loop {
|
|
if *termination.borrow() {
|
|
return terminated_response();
|
|
}
|
|
match reply {
|
|
ScriptedReply::Delay(delay, next) => {
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(delay) => reply = *next,
|
|
_ = termination.changed() => return terminated_response(),
|
|
}
|
|
}
|
|
ScriptedReply::Hang => {
|
|
let _ = termination.changed().await;
|
|
return terminated_response();
|
|
}
|
|
ScriptedReply::Result(result) => {
|
|
return Json(json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"result": result,
|
|
}))
|
|
.into_response();
|
|
}
|
|
ScriptedReply::RpcError(message) => {
|
|
return rpc_error_response(id, StatusCode::OK, message);
|
|
}
|
|
ScriptedReply::HttpError(status, message) => {
|
|
return rpc_error_response(id, status, message);
|
|
}
|
|
ScriptedReply::Malformed(body) => {
|
|
return (StatusCode::OK, Body::from(body)).into_response();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn terminated_response() -> Response {
|
|
(StatusCode::OK, Body::empty()).into_response()
|
|
}
|
|
|
|
fn rpc_error_response(id: Value, status: StatusCode, message: String) -> Response {
|
|
(
|
|
status,
|
|
Json(json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"error": { "code": 1, "message": message },
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn scripts(
|
|
entries: impl IntoIterator<Item = (&'static str, Vec<ScriptedReply>)>,
|
|
) -> Vec<(String, Vec<ScriptedReply>)> {
|
|
entries
|
|
.into_iter()
|
|
.map(|(method, replies)| (method.to_string(), replies))
|
|
.collect()
|
|
}
|
|
|
|
async fn probe_fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
|
let temporary = tempfile::tempdir().expect("temporary probe storage should exist");
|
|
let probe_dir = temporary.path().join("probe");
|
|
tokio::fs::create_dir(&probe_dir)
|
|
.await
|
|
.expect("probe directory should be created");
|
|
let metadata_path = probe_dir.join("metadata.torrent");
|
|
tokio::fs::write(&metadata_path, b"torrent metadata")
|
|
.await
|
|
.expect("metadata fixture should be writable");
|
|
(temporary, probe_dir, metadata_path)
|
|
}
|
|
|
|
async fn wait_for_path(path: &Path, should_exist: bool) {
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
while Instant::now() < deadline {
|
|
if path.exists() == should_exist {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
assert_eq!(
|
|
path.exists(),
|
|
should_exist,
|
|
"path state did not become {}: {}",
|
|
should_exist,
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
struct FakeRpc {
|
|
statuses: Mutex<VecDeque<Result<Value, String>>>,
|
|
force_remove: Mutex<Result<Value, String>>,
|
|
removed: AtomicBool,
|
|
calls: Mutex<Vec<String>>,
|
|
call_notification: tokio::sync::Notify,
|
|
}
|
|
|
|
impl FakeRpc {
|
|
fn new(
|
|
statuses: impl IntoIterator<Item = Result<Value, String>>,
|
|
force_remove: Result<Value, String>,
|
|
) -> Self {
|
|
Self {
|
|
statuses: Mutex::new(statuses.into_iter().collect()),
|
|
force_remove: Mutex::new(force_remove),
|
|
removed: AtomicBool::new(false),
|
|
calls: Mutex::new(Vec::new()),
|
|
call_notification: tokio::sync::Notify::new(),
|
|
}
|
|
}
|
|
|
|
fn status(name: &str) -> Result<Value, String> {
|
|
Ok(json!({ "status": name }))
|
|
}
|
|
|
|
fn call_names(&self) -> Vec<String> {
|
|
self.calls
|
|
.lock()
|
|
.expect("call log lock should work")
|
|
.clone()
|
|
}
|
|
|
|
async fn wait_for_call(&self, method: &str) {
|
|
loop {
|
|
if self.call_names().iter().any(|call| call == method) {
|
|
return;
|
|
}
|
|
self.call_notification.notified().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RpcClient for FakeRpc {
|
|
async fn call(&self, method: &str, _params: Value) -> Result<Value, String> {
|
|
self.calls
|
|
.lock()
|
|
.expect("call log lock should work")
|
|
.push(method.to_string());
|
|
self.call_notification.notify_one();
|
|
match method {
|
|
"aria2.addUri" => Ok(json!("gid-1")),
|
|
"aria2.tellStatus" => {
|
|
if let Some(status) = self
|
|
.statuses
|
|
.lock()
|
|
.expect("status queue lock should work")
|
|
.pop_front()
|
|
{
|
|
status
|
|
} else if self.removed.load(Ordering::Acquire) {
|
|
Self::status("removed")
|
|
} else {
|
|
Self::status("active")
|
|
}
|
|
}
|
|
"aria2.forceRemove" => {
|
|
let result = self
|
|
.force_remove
|
|
.lock()
|
|
.expect("forceRemove lock should work");
|
|
if result.is_ok() {
|
|
self.removed.store(true, Ordering::Release);
|
|
}
|
|
match &*result {
|
|
Ok(value) => Ok(value.clone()),
|
|
Err(error) => Err(error.clone()),
|
|
}
|
|
}
|
|
_ => Err(format!("unexpected RPC method {method}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_fake_probe(
|
|
rpc: Arc<FakeRpc>,
|
|
timeout: Duration,
|
|
poll_interval: Duration,
|
|
) -> Result<Vec<u8>, ProbeFailure> {
|
|
let temporary = tempfile::tempdir().expect("temporary probe storage should exist");
|
|
let metadata_path = temporary.path().join("metadata.torrent");
|
|
tokio::fs::write(&metadata_path, b"torrent metadata")
|
|
.await
|
|
.expect("metadata fixture should be writable");
|
|
run_metadata_probe(
|
|
rpc,
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
timeout,
|
|
poll_interval,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn resolves_active_waiting_complete_and_cleans_the_probe() {
|
|
let rpc = Arc::new(FakeRpc::new(
|
|
[
|
|
FakeRpc::status("active"),
|
|
FakeRpc::status("waiting"),
|
|
FakeRpc::status("complete"),
|
|
FakeRpc::status("removed"),
|
|
],
|
|
Ok(json!("gid-1")),
|
|
));
|
|
let bytes = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
|
.await
|
|
.expect("complete probe should return metadata");
|
|
assert_eq!(bytes, b"torrent metadata");
|
|
assert_eq!(
|
|
rpc.call_names(),
|
|
vec![
|
|
"aria2.addUri",
|
|
"aria2.tellStatus",
|
|
"aria2.tellStatus",
|
|
"aria2.tellStatus",
|
|
"aria2.forceRemove",
|
|
"aria2.tellStatus",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn returns_metadata_error_for_failed_status_and_cleans_the_probe() {
|
|
let rpc = Arc::new(FakeRpc::new(
|
|
[FakeRpc::status("error"), FakeRpc::status("removed")],
|
|
Ok(json!("gid-1")),
|
|
));
|
|
let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
|
.await
|
|
.expect_err("error status should fail the probe");
|
|
assert!(
|
|
matches!(error, ProbeFailure::Metadata(message) if message.contains("could not resolve"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn retries_transient_status_errors_during_metadata_polling() {
|
|
let rpc = Arc::new(FakeRpc::new(
|
|
[
|
|
Err("connection reset by peer".to_string()),
|
|
FakeRpc::status("complete"),
|
|
FakeRpc::status("removed"),
|
|
],
|
|
Ok(json!("gid-1")),
|
|
));
|
|
let bytes = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
|
.await
|
|
.expect("a transient status error should be retried");
|
|
assert_eq!(bytes, b"torrent metadata");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn rejects_a_malformed_status_response_instead_of_waiting_for_timeout() {
|
|
let rpc = Arc::new(FakeRpc::new([Ok(json!({}))], Ok(json!("gid-1"))));
|
|
let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
|
.await
|
|
.expect_err("a missing status field should fail immediately");
|
|
assert!(matches!(
|
|
error,
|
|
ProbeFailure::Metadata(message)
|
|
if message.contains("invalid metadata probe status")
|
|
));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn does_not_expose_metadata_path_when_reading_fails() {
|
|
let temporary = tempfile::tempdir().expect("temporary probe storage should exist");
|
|
let metadata_path = temporary.path().join("missing-metadata.torrent");
|
|
let rpc = Arc::new(FakeRpc::new(
|
|
[FakeRpc::status("complete"), FakeRpc::status("removed")],
|
|
Ok(json!("gid-1")),
|
|
));
|
|
let error = run_metadata_probe(
|
|
Arc::clone(&rpc),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect_err("missing metadata should fail");
|
|
let ProbeFailure::Metadata(message) = error else {
|
|
panic!("metadata read failure should remain a metadata error");
|
|
};
|
|
assert!(!message.contains(&*temporary.path().to_string_lossy()));
|
|
assert!(message.contains("NotFound"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn redacts_status_rpc_errors_during_cleanup() {
|
|
let rpc = Arc::new(FakeRpc::new(
|
|
[
|
|
FakeRpc::status("complete"),
|
|
Err("token=super-secret-value".to_string()),
|
|
],
|
|
Ok(json!("gid-1")),
|
|
));
|
|
let error = run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
|
.await
|
|
.expect_err("cleanup status failure should be reported");
|
|
let ProbeFailure::Cleanup(message) = error else {
|
|
panic!("cleanup status failure should remain a cleanup error");
|
|
};
|
|
assert!(!message.contains("super-secret-value"));
|
|
assert!(message.contains("[redacted]"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn retries_transient_status_errors_after_force_remove() {
|
|
let rpc = Arc::new(FakeRpc::new(
|
|
[
|
|
FakeRpc::status("complete"),
|
|
Err("connection reset by peer".to_string()),
|
|
FakeRpc::status("removed"),
|
|
],
|
|
Ok(json!("gid-1")),
|
|
));
|
|
run_fake_probe(Arc::clone(&rpc), Duration::from_secs(1), Duration::ZERO)
|
|
.await
|
|
.expect("a transient cleanup status error should be retried");
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn handles_missing_gid_and_timeout_without_leaking_cleanup() {
|
|
let missing = Arc::new(FakeRpc::new(
|
|
[
|
|
Err("aria2 gid gid-1 not found".to_string()),
|
|
Err("aria2 gid gid-1 not found".to_string()),
|
|
],
|
|
Err("temporary forceRemove transport failure".to_string()),
|
|
));
|
|
let missing_error =
|
|
run_fake_probe(Arc::clone(&missing), Duration::from_secs(1), Duration::ZERO)
|
|
.await
|
|
.expect_err("missing gid should fail the probe");
|
|
assert!(
|
|
matches!(missing_error, ProbeFailure::Metadata(message) if message.contains("removed"))
|
|
);
|
|
|
|
let timeout = Arc::new(FakeRpc::new(Vec::new(), Ok(json!("gid-1"))));
|
|
let timeout_error = run_fake_probe(
|
|
Arc::clone(&timeout),
|
|
Duration::from_millis(5),
|
|
Duration::from_millis(1),
|
|
)
|
|
.await
|
|
.expect_err("active probe should time out");
|
|
assert!(
|
|
matches!(timeout_error, ProbeFailure::Metadata(message) if message.contains("timed out"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn accepts_force_remove_race_only_after_terminal_status() {
|
|
let terminal_race = Arc::new(FakeRpc::new(
|
|
[FakeRpc::status("error"), FakeRpc::status("complete")],
|
|
Err("temporary forceRemove transport failure".to_string()),
|
|
));
|
|
let error = run_fake_probe(
|
|
Arc::clone(&terminal_race),
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect_err("metadata error should still be returned");
|
|
assert!(
|
|
matches!(error, ProbeFailure::Metadata(message) if message.contains("could not resolve"))
|
|
);
|
|
|
|
let active_race = Arc::new(FakeRpc::new(
|
|
[FakeRpc::status("error"), FakeRpc::status("active")],
|
|
Err("temporary forceRemove transport failure".to_string()),
|
|
));
|
|
let cleanup_error = run_fake_probe(
|
|
Arc::clone(&active_race),
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect_err("active race must report cleanup failure");
|
|
assert!(
|
|
matches!(cleanup_error, ProbeFailure::Cleanup(message) if message.contains("failed to remove"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn cancellation_still_removes_the_remote_gid_and_probe_directory() {
|
|
let temporary = tempfile::tempdir().expect("temporary probe storage should exist");
|
|
let metadata_path = temporary.path().join("metadata.torrent");
|
|
tokio::fs::write(&metadata_path, b"torrent metadata")
|
|
.await
|
|
.expect("metadata fixture should be writable");
|
|
let rpc = Arc::new(FakeRpc::new(Vec::new(), Ok(json!("gid-1"))));
|
|
let probe = run_metadata_probe(
|
|
Arc::clone(&rpc),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(60),
|
|
Duration::from_secs(60),
|
|
);
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(20), probe)
|
|
.await
|
|
.is_err(),
|
|
"the active probe should be canceled while polling"
|
|
);
|
|
|
|
tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
rpc.wait_for_call("aria2.forceRemove"),
|
|
)
|
|
.await
|
|
.expect("cancellation cleanup should force-remove the GID");
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while temporary.path().exists() {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("cancellation cleanup should remove the probe directory");
|
|
}
|
|
|
|
fn status_reply(status: &str) -> ScriptedReply {
|
|
ScriptedReply::Result(json!({ "status": status }))
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn http_rpc_harness_exercises_production_client_and_status_order() {
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![
|
|
status_reply("active"),
|
|
status_reply("waiting"),
|
|
status_reply("paused"),
|
|
status_reply("complete"),
|
|
status_reply("removed"),
|
|
],
|
|
),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![ScriptedReply::Result(json!("gid-1"))],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
let mut options = Map::new();
|
|
options.insert("bt-metadata-only".to_string(), json!("true"));
|
|
options.insert("bt-save-metadata".to_string(), json!("true"));
|
|
let bytes = run_metadata_probe(
|
|
server.client(),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
options,
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect("HTTP-backed metadata probe should resolve");
|
|
|
|
assert_eq!(bytes, b"torrent metadata");
|
|
let calls = server.calls();
|
|
assert_eq!(
|
|
calls
|
|
.iter()
|
|
.map(|(method, _)| method.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"aria2.addUri",
|
|
"aria2.tellStatus",
|
|
"aria2.tellStatus",
|
|
"aria2.tellStatus",
|
|
"aria2.tellStatus",
|
|
"aria2.forceRemove",
|
|
"aria2.tellStatus",
|
|
]
|
|
);
|
|
assert!(calls.iter().all(|(_, params)| {
|
|
params
|
|
.as_array()
|
|
.and_then(|params| params.first())
|
|
.and_then(Value::as_str)
|
|
== Some("token:torrent-probe-test-secret")
|
|
}));
|
|
let add_params = calls[0]
|
|
.1
|
|
.as_array()
|
|
.expect("addUri params should be an array");
|
|
assert_eq!(
|
|
add_params[1],
|
|
json!(["magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"])
|
|
);
|
|
let options = add_params[2]
|
|
.as_object()
|
|
.expect("addUri options should be an object");
|
|
assert_eq!(
|
|
options.get("bt-metadata-only"),
|
|
Some(&json!("true")),
|
|
"recorded addUri params: {add_params:?}"
|
|
);
|
|
assert_eq!(
|
|
options.get("bt-save-metadata"),
|
|
Some(&json!("true")),
|
|
"recorded addUri params: {add_params:?}"
|
|
);
|
|
assert_eq!(
|
|
options.get("follow-torrent"),
|
|
Some(&json!("false")),
|
|
"recorded addUri params: {add_params:?}"
|
|
);
|
|
assert_eq!(
|
|
options.get("follow-metalink"),
|
|
Some(&json!("false")),
|
|
"recorded addUri params: {add_params:?}"
|
|
);
|
|
tokio::fs::remove_dir_all(&probe_dir)
|
|
.await
|
|
.expect("successful probe fixture should be removable");
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn http_rpc_harness_reports_add_uri_failure_without_fabricating_cleanup() {
|
|
let server = ScriptedRpcServer::start(scripts([(
|
|
"aria2.addUri",
|
|
vec![ScriptedReply::HttpError(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"daemon is starting".to_string(),
|
|
)],
|
|
)]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
let error = run_metadata_probe(
|
|
server.client(),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect_err("addUri failure should fail metadata resolution");
|
|
assert!(matches!(
|
|
error,
|
|
ProbeFailure::Metadata(message) if message.contains("could not start")
|
|
));
|
|
assert_eq!(
|
|
server
|
|
.calls()
|
|
.iter()
|
|
.map(|(method, _)| method.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["aria2.addUri"]
|
|
);
|
|
assert!(probe_dir.exists());
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn http_rpc_harness_covers_terminal_malformed_and_missing_gid_statuses() {
|
|
for (initial_status, expected_message) in [
|
|
(
|
|
ScriptedReply::Result(json!({
|
|
"status": "error",
|
|
"errorCode": "19",
|
|
"errorMessage": "tracker rejected metadata",
|
|
})),
|
|
"could not resolve",
|
|
),
|
|
(status_reply("removed"), "could not resolve"),
|
|
(
|
|
ScriptedReply::Result(json!({ "unexpected": "shape" })),
|
|
"invalid metadata probe status",
|
|
),
|
|
(ScriptedReply::Malformed("{".to_string()), "status failed"),
|
|
] {
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![initial_status, status_reply("removed")],
|
|
),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![ScriptedReply::Result(json!("gid-1"))],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, _probe_dir, metadata_path) = probe_fixture().await;
|
|
let error = run_metadata_probe(
|
|
server.client(),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect_err("terminal or malformed status should fail the probe");
|
|
assert!(matches!(
|
|
error,
|
|
ProbeFailure::Metadata(message) if message.contains(expected_message)
|
|
));
|
|
server.shutdown().await;
|
|
}
|
|
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![ScriptedReply::RpcError("gid gid-1 not found".to_string())],
|
|
),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![ScriptedReply::RpcError("gid gid-1 not found".to_string())],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, _probe_dir, metadata_path) = probe_fixture().await;
|
|
let error = run_metadata_probe(
|
|
server.client(),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect_err("a missing GID should fail metadata resolution");
|
|
assert!(matches!(
|
|
error,
|
|
ProbeFailure::Metadata(message) if message.contains("removed")
|
|
));
|
|
assert_eq!(
|
|
server
|
|
.calls()
|
|
.iter()
|
|
.map(|(method, _)| method.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["aria2.addUri", "aria2.tellStatus", "aria2.forceRemove"]
|
|
);
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn http_rpc_harness_retries_polling_and_force_remove_outages() {
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![
|
|
ScriptedReply::HttpError(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"temporary polling outage".to_string(),
|
|
),
|
|
status_reply("complete"),
|
|
status_reply("removed"),
|
|
],
|
|
),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![ScriptedReply::Result(json!("gid-1"))],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
run_metadata_probe(
|
|
server.client(),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect("a transient polling outage should be retried");
|
|
assert_eq!(
|
|
server
|
|
.calls()
|
|
.iter()
|
|
.map(|(method, _)| method.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"aria2.addUri",
|
|
"aria2.tellStatus",
|
|
"aria2.tellStatus",
|
|
"aria2.forceRemove",
|
|
"aria2.tellStatus",
|
|
]
|
|
);
|
|
tokio::fs::remove_dir_all(&probe_dir)
|
|
.await
|
|
.expect("successful probe fixture should be removable");
|
|
server.shutdown().await;
|
|
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![status_reply("complete"), status_reply("complete")],
|
|
),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![ScriptedReply::HttpError(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"temporary cleanup outage".to_string(),
|
|
)],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
run_metadata_probe(
|
|
server.client(),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect("a completion race after forceRemove should be accepted");
|
|
assert_eq!(
|
|
server
|
|
.calls()
|
|
.iter()
|
|
.map(|(method, _)| method.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"aria2.addUri",
|
|
"aria2.tellStatus",
|
|
"aria2.forceRemove",
|
|
"aria2.tellStatus",
|
|
]
|
|
);
|
|
tokio::fs::remove_dir_all(&probe_dir)
|
|
.await
|
|
.expect("successful probe fixture should be removable");
|
|
server.shutdown().await;
|
|
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![
|
|
status_reply("complete"),
|
|
status_reply("active"),
|
|
status_reply("active"),
|
|
status_reply("removed"),
|
|
],
|
|
),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![
|
|
ScriptedReply::HttpError(
|
|
StatusCode::BAD_GATEWAY,
|
|
"temporary gateway outage".to_string(),
|
|
),
|
|
ScriptedReply::RpcError("connection reset by peer".to_string()),
|
|
ScriptedReply::Result(json!("gid-1")),
|
|
],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
let error = run_metadata_probe(
|
|
server.client(),
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&metadata_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
.expect_err("cleanup must report an unverified active GID");
|
|
assert!(matches!(error, ProbeFailure::Cleanup(_)));
|
|
server.wait_for_method("aria2.forceRemove", 3).await;
|
|
wait_for_path(&probe_dir, false).await;
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn http_rpc_harness_covers_cancellation_before_and_after_gid_assignment() {
|
|
let server = ScriptedRpcServer::start(scripts([(
|
|
"aria2.addUri",
|
|
vec![ScriptedReply::Delay(
|
|
Duration::from_millis(250),
|
|
Box::new(ScriptedReply::Result(json!("gid-1"))),
|
|
)],
|
|
)]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
let probe_path = metadata_path.clone();
|
|
let client = server.client();
|
|
let task = tokio::spawn(async move {
|
|
run_metadata_probe(
|
|
client,
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&probe_path,
|
|
Duration::from_secs(60),
|
|
Duration::from_secs(60),
|
|
)
|
|
.await
|
|
});
|
|
server.wait_for_method("aria2.addUri", 1).await;
|
|
task.abort();
|
|
let _ = task.await;
|
|
wait_for_path(&probe_dir, false).await;
|
|
server.shutdown().await;
|
|
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![ScriptedReply::Hang, status_reply("removed")],
|
|
),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![ScriptedReply::Result(json!("gid-1"))],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
let probe_path = metadata_path.clone();
|
|
let client = server.client();
|
|
let task = tokio::spawn(async move {
|
|
run_metadata_probe(
|
|
client,
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&probe_path,
|
|
Duration::from_secs(60),
|
|
Duration::from_secs(60),
|
|
)
|
|
.await
|
|
});
|
|
server.wait_for_method("aria2.tellStatus", 1).await;
|
|
task.abort();
|
|
let _ = task.await;
|
|
server.wait_for_method("aria2.forceRemove", 1).await;
|
|
wait_for_path(&probe_dir, false).await;
|
|
server.terminate().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn http_rpc_harness_retains_probe_when_daemon_shuts_down_during_resolution_or_cleanup() {
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
(
|
|
"aria2.tellStatus",
|
|
vec![ScriptedReply::Delay(
|
|
Duration::from_millis(250),
|
|
Box::new(status_reply("active")),
|
|
)],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
let probe_path = metadata_path.clone();
|
|
let client = server.client();
|
|
let task = tokio::spawn(async move {
|
|
run_metadata_probe(
|
|
client,
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&probe_path,
|
|
Duration::from_millis(40),
|
|
Duration::from_millis(5),
|
|
)
|
|
.await
|
|
});
|
|
server.wait_for_method("aria2.tellStatus", 1).await;
|
|
server.terminate().await;
|
|
let result = tokio::time::timeout(Duration::from_secs(2), task)
|
|
.await
|
|
.expect("probe should finish after daemon shutdown")
|
|
.expect("probe task should not panic")
|
|
.expect_err("daemon shutdown should not report metadata success");
|
|
assert!(matches!(result, ProbeFailure::Cleanup(_)));
|
|
assert!(probe_dir.exists());
|
|
|
|
let server = ScriptedRpcServer::start(scripts([
|
|
("aria2.addUri", vec![ScriptedReply::Result(json!("gid-1"))]),
|
|
("aria2.tellStatus", vec![status_reply("complete")]),
|
|
(
|
|
"aria2.forceRemove",
|
|
vec![ScriptedReply::Delay(
|
|
Duration::from_secs(1),
|
|
Box::new(ScriptedReply::Result(json!("gid-1"))),
|
|
)],
|
|
),
|
|
]))
|
|
.await;
|
|
let (_temporary, probe_dir, metadata_path) = probe_fixture().await;
|
|
let probe_path = metadata_path.clone();
|
|
let client = server.client();
|
|
let task = tokio::spawn(async move {
|
|
run_metadata_probe(
|
|
client,
|
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
|
Map::new(),
|
|
&probe_path,
|
|
Duration::from_secs(1),
|
|
Duration::ZERO,
|
|
)
|
|
.await
|
|
});
|
|
server.wait_for_method("aria2.forceRemove", 1).await;
|
|
server.terminate().await;
|
|
let result = tokio::time::timeout(Duration::from_secs(2), task)
|
|
.await
|
|
.expect("cleanup should finish after daemon shutdown")
|
|
.expect("cleanup task should not panic")
|
|
.expect_err("daemon shutdown should report cleanup uncertainty");
|
|
assert!(matches!(result, ProbeFailure::Cleanup(_)));
|
|
assert!(probe_dir.exists());
|
|
}
|
|
}
|