fix(ci): support stock Aria2 route fallback (#38)

- Keep normal and Torrent transfers on the system resolver while preserving proxy routing.\n- Gate the alternate magnet resolver on the optional Firelink capability and disable its default target policy for system-route transfers.\n- Allow stock Windows/Linux engine payloads and make the metadata timeout regression deterministic.
This commit is contained in:
NimBold
2026-08-31 08:50:13 +03:30
parent 4777f1e3c3
commit 050932dc27
12 changed files with 531 additions and 186 deletions
+47 -4
View File
@@ -4,6 +4,17 @@ export const ARIA2_NETWORK_TARGET_POLICY = 'firelink-v1';
export const ARIA2_NETWORK_TARGET_POLICY_DIGEST =
'sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705';
// The standard Aria2 option is the production default. It keeps hostname
// resolution on the OS/TUN route and is accepted by stock and patched builds.
// There is intentionally no daemon-wide flag here: async-dns=false is stamped
// per transfer after the caller has selected the compatible route.
export const ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS = Object.freeze([]);
export const ARIA2_SYSTEM_RESOLVER_OPTIONS = Object.freeze({
'async-dns': 'false',
});
// Firelink-patched Aria2 exposes these options for the bounded alternate
// magnet-probe attempt. They must never be sent to an unverified binary.
export const ARIA2_ROUTE_DAEMON_ARGS = Object.freeze([
'--async-dns=true',
`--dns-resolver=${ARIA2_DNS_RESOLVER}`,
@@ -17,12 +28,18 @@ export const ARIA2_ROUTE_OPTIONS = Object.freeze({
});
// Local HTTP servers are used only by engine smoke tests. Product transfers
// never apply this override; Firelink keeps the literal-target policy enabled.
// never apply the fixture exception; Firelink's own admission policy still
// rejects literal local targets before Aria2 is contacted.
export const ARIA2_LOCAL_FIXTURE_OPTIONS = Object.freeze({
...ARIA2_ROUTE_OPTIONS,
'network-target-policy': 'none',
...ARIA2_SYSTEM_RESOLVER_OPTIONS,
});
export function assertAria2Baseline(version) {
if (typeof version?.version !== 'string' || version.version.trim() === '') {
throw new Error(`aria2 returned an invalid baseline version response: ${JSON.stringify(version)}`);
}
}
export function assertAria2RouteCapabilities(version) {
if (!Array.isArray(version?.enabledFeatures)
|| !version.enabledFeatures.includes('Async DNS')) {
@@ -30,7 +47,6 @@ export function assertAria2RouteCapabilities(version) {
}
const expected = {
firelinkRevision: ARIA2_FIRELINK_REVISION,
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
};
for (const [field, value] of Object.entries(expected)) {
@@ -38,12 +54,39 @@ export function assertAria2RouteCapabilities(version) {
throw new Error(`aria2 route capability mismatch for ${field}: ${JSON.stringify(version)}`);
}
}
if (!Array.isArray(version.firelinkDnsResolvers)
|| !version.firelinkDnsResolvers.includes(ARIA2_DNS_RESOLVER)) {
throw new Error(`aria2 does not advertise the Firelink native resolver: ${JSON.stringify(version)}`);
}
if (!Array.isArray(version.firelinkNetworkTargetPolicies)
|| !version.firelinkNetworkTargetPolicies.includes(ARIA2_NETWORK_TARGET_POLICY)) {
throw new Error(`aria2 does not advertise the Firelink target policy: ${JSON.stringify(version)}`);
}
}
export function hasAria2RouteCapabilities(version) {
try {
assertAria2RouteCapabilities(version);
return true;
} catch {
return false;
}
}
export function assertAria2SystemResolverOptions(options, context = 'aria2 transfer') {
if (options?.['async-dns'] !== 'false') {
throw new Error(`${context} did not retain async-dns=false: ${JSON.stringify(options)}`);
}
if (Object.hasOwn(options || {}, 'dns-resolver')
&& options['dns-resolver'] !== ARIA2_DNS_RESOLVER) {
throw new Error(`${context} retained an unknown resolver mode: ${JSON.stringify(options)}`);
}
if (Object.hasOwn(options || {}, 'network-target-policy')
&& options['network-target-policy'] !== 'none') {
throw new Error(`${context} retained an active target policy: ${JSON.stringify(options)}`);
}
}
export function assertAria2RouteContract(version) {
assertAria2RouteCapabilities(version);
const expected = {
+19
View File
@@ -5,6 +5,7 @@ import {
ARIA2_FIRELINK_REVISION,
ARIA2_NETWORK_TARGET_POLICY,
ARIA2_NETWORK_TARGET_POLICY_DIGEST,
assertAria2SystemResolverOptions,
assertAria2RouteCapabilities,
assertAria2RouteContract,
assertAria2RouteSource,
@@ -14,6 +15,7 @@ const secureVersion = {
enabledFeatures: ['Async DNS'],
firelinkRevision: ARIA2_FIRELINK_REVISION,
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
firelinkDnsResolvers: [ARIA2_DNS_RESOLVER],
firelinkNetworkTargetPolicies: ['none', ARIA2_NETWORK_TARGET_POLICY],
firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
@@ -62,3 +64,20 @@ test('route capabilities are distinct from the active local-fixture policy', ()
/route contract mismatch for firelinkNetworkTargetPolicy/,
);
});
test('system resolver options cannot retain the custom target policy', () => {
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
'async-dns': 'false',
}));
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
'async-dns': 'false',
'network-target-policy': 'none',
}));
assert.throws(
() => assertAria2SystemResolverOptions({
'async-dns': 'false',
'network-target-policy': ARIA2_NETWORK_TARGET_POLICY,
}),
/active target policy/,
);
});
+7 -9
View File
@@ -40,15 +40,13 @@ if (!targetSources) {
process.exit(1);
}
try {
// Firelink passes route-contract options to every production Aria2 daemon.
// A stock archive would either reject those options or silently omit the
// literal-target policy on non-macOS targets, so fail before downloading or
// staging an unusable payload.
assertAria2RouteSource(targetSources.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
if (targetSources.aria2c?.firelinkRouteContract) {
try {
assertAria2RouteSource(targetSources.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
+39 -17
View File
@@ -9,10 +9,13 @@ import path from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
ARIA2_ROUTE_DAEMON_ARGS,
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
ARIA2_SYSTEM_RESOLVER_OPTIONS,
ARIA2_ROUTE_OPTIONS,
assertAria2RouteContract,
assertAria2Baseline,
assertAria2RouteOptions,
assertAria2SystemResolverOptions,
hasAria2RouteCapabilities,
} from './aria2-route-contract.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -201,7 +204,7 @@ const child = spawn(binaryPath, [
`--dir=${tempRoot}`,
'--file-allocation=none',
'--enable-dht=false',
...ARIA2_ROUTE_DAEMON_ARGS,
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
'--console-log-level=error',
'--quiet=true',
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
@@ -210,18 +213,26 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
try {
const version = await waitForRpc(rpcPort, secret);
assertAria2RouteContract(version);
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Firelink route contract verified`);
assertAria2Baseline(version);
const routeCapabilitiesAvailable = hasAria2RouteCapabilities(version);
if (routeCapabilitiesAvailable) {
// The fixture server is intentionally loopback. Disable only the custom
// target policy for this smoke daemon after capabilities are attested.
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
console.log(`[INFO] aria2 ${version.version || 'unknown'}; optional Firelink route capabilities available`);
} else {
console.log(`[INFO] aria2 ${version.version || 'unknown'}; using stock system-resolver capabilities`);
}
const systemFixtureOptions = routeCapabilitiesAvailable
? { ...ARIA2_SYSTEM_RESOLVER_OPTIONS, 'network-target-policy': 'none' }
: { ...ARIA2_SYSTEM_RESOLVER_OPTIONS };
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
...ARIA2_ROUTE_OPTIONS,
'network-target-policy': 'none',
...systemFixtureOptions,
out: 'resolver-normal.bin',
}]);
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
if (uriOptions['async-dns'] !== 'true' || uriOptions['dns-resolver'] !== 'native-async') {
throw new Error(`direct fixture transfer did not retain native asynchronous DNS: ${JSON.stringify(uriOptions)}`);
}
assertAria2SystemResolverOptions(uriOptions, 'direct aria2.addUri');
const torrent = bencode({
info: {
@@ -232,16 +243,16 @@ try {
},
}).toString('base64');
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
...ARIA2_ROUTE_OPTIONS,
...systemFixtureOptions,
dir: tempRoot,
}]);
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
assertAria2RouteOptions(torrentOptions, 'direct aria2.addTorrent');
assertAria2SystemResolverOptions(torrentOptions, 'direct aria2.addTorrent');
const proxyRoute = 'http://127.0.0.1:9';
const normalizedProxyRoute = new URL(proxyRoute).toString();
const proxiedUriResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/file'], {
...ARIA2_ROUTE_OPTIONS,
...systemFixtureOptions,
'all-proxy': proxyRoute,
pause: 'true',
out: 'resolver-proxied.bin',
@@ -250,10 +261,10 @@ try {
if (proxiedUriOptions['all-proxy'] !== normalizedProxyRoute) {
throw new Error(`aria2.addUri did not retain the configured proxy route: ${JSON.stringify(proxiedUriOptions)}`);
}
assertAria2RouteOptions(proxiedUriOptions, 'proxied aria2.addUri');
assertAria2SystemResolverOptions(proxiedUriOptions, 'proxied aria2.addUri');
const proxiedTorrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
...ARIA2_ROUTE_OPTIONS,
...systemFixtureOptions,
'all-proxy': proxyRoute,
pause: 'true',
dir: tempRoot,
@@ -262,11 +273,22 @@ try {
if (proxiedTorrentOptions['all-proxy'] !== normalizedProxyRoute) {
throw new Error(`aria2.addTorrent did not retain the configured proxy route: ${JSON.stringify(proxiedTorrentOptions)}`);
}
assertAria2RouteOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent');
assertAria2SystemResolverOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent');
if (routeCapabilitiesAvailable) {
const alternateResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/alternate'], {
...ARIA2_ROUTE_OPTIONS,
pause: 'true',
out: 'resolver-alternate.bin',
}]);
const alternateOptions = await rpc(rpcPort, secret, 'aria2.getOption', [alternateResult]);
assertAria2RouteOptions(alternateOptions, 'alternate aria2.addUri');
await forceRemoveIfPresent(rpcPort, secret, alternateResult);
}
await forceRemoveIfPresent(rpcPort, secret, proxiedUriResult);
await forceRemoveIfPresent(rpcPort, secret, proxiedTorrentResult);
console.log('[PASS] Aria2 kept asynchronous DNS for fresh direct and proxied normal/Torrent transfers');
console.log('[PASS] Aria2 preserved system route options for direct/proxied normal/Torrent transfers');
} catch (error) {
const detail = stderr.trim();
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
+11 -5
View File
@@ -10,8 +10,9 @@ import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
ARIA2_LOCAL_FIXTURE_OPTIONS,
ARIA2_ROUTE_DAEMON_ARGS,
assertAria2RouteContract,
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
assertAria2Baseline,
hasAria2RouteCapabilities,
} from './aria2-route-contract.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -331,7 +332,7 @@ const child = spawn(binaryPath, [
'--enable-dht=false',
'--console-log-level=error',
'--quiet=true',
...ARIA2_ROUTE_DAEMON_ARGS,
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
`--server-stat-if=${serverStatPath}`,
`--server-stat-of=${serverStatPath}`,
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
@@ -340,8 +341,13 @@ child.stderr.on('data', chunk => { stderr += chunk.toString(); });
try {
const version = await waitForRpc(rpcPort, secret);
assertAria2RouteContract(version);
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke`);
assertAria2Baseline(version);
if (hasAria2RouteCapabilities(version)) {
// The fixture server is intentionally loopback. Disable only the custom
// target policy after the optional capabilities have been attested.
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
}
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke (system resolver)`);
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
+23 -10
View File
@@ -10,7 +10,9 @@ import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
ARIA2_DNS_RESOLVER,
assertAria2RouteCapabilities,
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
assertAria2Baseline,
hasAria2RouteCapabilities,
} from './aria2-route-contract.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -73,10 +75,10 @@ let signalTerminationRequested = false;
class DaemonExitedError extends Error {}
function assertFixtureRouteOptions(options, context) {
if (options['async-dns'] !== 'true'
|| options['dns-resolver'] !== ARIA2_DNS_RESOLVER
|| options['network-target-policy'] !== 'none') {
throw new Error(`${context} did not retain the local-fixture resolver contract: ${JSON.stringify(options)}`);
if (options['async-dns'] !== 'false'
|| (Object.hasOwn(options, 'dns-resolver')
&& options['dns-resolver'] !== ARIA2_DNS_RESOLVER)) {
throw new Error(`${context} did not retain the system resolver contract: ${JSON.stringify(options)}`);
}
}
@@ -320,9 +322,7 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [
'--enable-dht=false',
'--enable-peer-exchange=false',
'--bt-enable-lpd=false',
'--async-dns=true',
`--dns-resolver=${ARIA2_DNS_RESOLVER}`,
'--network-target-policy=none',
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
'--console-log-level=error',
'--quiet=true',
...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []),
@@ -352,7 +352,20 @@ async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [
return false;
}
}, 10000);
assertAria2RouteCapabilities(version);
assertAria2Baseline(version);
if (Array.isArray(version.enabledFeatures) && version.enabledFeatures.includes('Async DNS')) {
// Use the standard system resolver for every fixture transfer. This
// also covers fixture additions that intentionally omit per-transfer
// route fields; product additions still stamp async-dns=false on
// every request.
await rpc(selectedRpcPort, secret, 'aria2.changeGlobalOption', [{ 'async-dns': 'false' }]);
}
if (hasAria2RouteCapabilities(version)) {
// The smoke fixtures intentionally use loopback tracker/peer routes.
// Disable only the custom target policy after capabilities are
// attested; stock Aria2 has no such option to send.
await rpc(selectedRpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
}
return daemon;
} catch (error) {
lastError = error;
@@ -875,7 +888,7 @@ async function main() {
assertFixtureRouteOptions(proxiedProbeOptions, 'proxied magnet metadata probe');
assert(await forceRemoveIfPresent(client, proxiedProbeGid), 'proxied magnet metadata probe was not removable');
await waitForRemoved(client, proxiedProbeGid);
console.log('[OK] metadata probe was removed after resolution; direct and proxied probes kept asynchronous DNS');
console.log('[OK] metadata probe was removed after resolution; direct and proxied probes kept system resolver options');
const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
trackerlessTorrentBytes.toString('base64'),
+14 -10
View File
@@ -46,11 +46,13 @@ if (!source) {
}
if (targetLock) {
try {
assertAria2RouteSource(targetLock.engines?.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
if (targetLock.engines?.aria2c?.firelinkRouteContract) {
try {
assertAria2RouteSource(targetLock.engines.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
for (const engine of engines) {
@@ -86,11 +88,13 @@ if (targetLock) {
console.error(`Payload manifest target mismatch: ${manifest.target}`);
process.exit(1);
}
try {
assertAria2RouteSource(manifest.generatedFrom?.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
try {
assertAria2RouteSource(manifest.generatedFrom.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
for (const [relative, expected] of Object.entries(manifest.files || {})) {
const file = path.join(source, relative);
+4 -4
View File
@@ -10,8 +10,8 @@ import {
resolveTargetTriple,
} from './engine-workspace.js';
import {
ARIA2_ROUTE_DAEMON_ARGS,
assertAria2RouteContract,
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
assertAria2Baseline,
} from './aria2-route-contract.js';
const __filename = fileURLToPath(import.meta.url);
@@ -473,7 +473,7 @@ if (canExecuteTarget) {
'--quiet',
'--console-log-level=error',
'--rpc-listen-all=false',
...ARIA2_ROUTE_DAEMON_ARGS,
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
], {
env: engineEnv('aria2c'),
stdio: ['ignore', 'ignore', 'pipe'],
@@ -568,7 +568,7 @@ if (canExecuteTarget) {
try {
const resp = JSON.parse(result.data);
if (resp?.result?.version) {
assertAria2RouteContract(resp.result);
assertAria2Baseline(resp.result);
ok(`aria2 RPC version: ${resp.result.version}`);
} else {
fail(`aria2 RPC unexpected response: ${result.data}`);
+145 -64
View File
@@ -3463,6 +3463,7 @@ struct Aria2DaemonGuard {
startup_error: Mutex<Option<String>>,
last_stderr: Mutex<String>,
config_path: Mutex<Option<tempfile::TempPath>>,
route_contract_available: AtomicBool,
shutdown_state: AtomicU8,
}
@@ -3473,10 +3474,20 @@ impl Aria2DaemonGuard {
startup_error: Mutex::new(None),
last_stderr: Mutex::new(String::new()),
config_path: Mutex::new(None),
route_contract_available: AtomicBool::new(false),
shutdown_state: AtomicU8::new(0),
}
}
fn route_contract_available(&self) -> bool {
self.route_contract_available.load(Ordering::Acquire)
}
fn set_route_contract_available(&self, available: bool) {
self.route_contract_available
.store(available, Ordering::Release);
}
fn exit_allowed(&self) -> bool {
self.shutdown_state.load(Ordering::SeqCst) == 2
}
@@ -4369,7 +4380,7 @@ async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) ->
let rpc_ready = if daemon_alive {
rpc_call(port, secret, "aria2.getVersion", serde_json::json!([]))
.await
.is_ok_and(|version| crate::network::aria2_route_contract_error(&version).is_none())
.is_ok_and(|version| crate::network::aria2_baseline_error(&version).is_none())
} else {
false
};
@@ -9188,6 +9199,20 @@ async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(),
}
}
const MAGNET_METADATA_TOTAL_TIMEOUT: Duration = Duration::from_secs(60);
const MAGNET_SYSTEM_RESOLVER_ATTEMPT: Duration = Duration::from_secs(20);
const MAGNET_ALTERNATE_RESOLVER_ATTEMPT: Duration = Duration::from_secs(40);
const MAGNET_PROBE_CLEANUP_RESERVE: Duration = Duration::from_secs(5);
fn magnet_probe_schedule(total_timeout: Duration) -> crate::torrent_probe::MetadataProbeSchedule {
crate::torrent_probe::MetadataProbeSchedule {
total_timeout,
metadata_timeout: total_timeout.saturating_sub(MAGNET_PROBE_CLEANUP_RESERVE),
cleanup_reserve: MAGNET_PROBE_CLEANUP_RESERVE.min(total_timeout),
poll_interval: Duration::from_millis(250),
}
}
async fn resolve_magnet_metadata(
app_handle: &tauri::AppHandle,
state: &AppState,
@@ -9235,6 +9260,7 @@ async fn resolve_magnet_metadata(
let storage_root = managed_path
.parent()
.ok_or_else(|| "torrent storage has no parent directory".to_string())?;
let operation_deadline = Instant::now() + MAGNET_METADATA_TOTAL_TIMEOUT;
let probe_dir = storage_root.join(format!(".probe-{}", uuid::Uuid::new_v4().simple()));
tokio::fs::create_dir_all(&probe_dir)
.await
@@ -9245,20 +9271,20 @@ async fn resolve_magnet_metadata(
.load(std::sync::atomic::Ordering::Relaxed);
let secret = state.aria2_secret.clone();
let metadata_path = probe_dir.join(format!("{}.torrent", expected.info_hash));
let mut options = serde_json::Map::new();
options.insert(
let mut base_options = serde_json::Map::new();
base_options.insert(
"dir".to_string(),
serde_json::json!(probe_dir.to_string_lossy().to_string()),
);
options.insert("bt-metadata-only".to_string(), serde_json::json!("true"));
options.insert("bt-save-metadata".to_string(), serde_json::json!("true"));
options.insert("max-tries".to_string(), serde_json::json!("3"));
options.insert("retry-wait".to_string(), serde_json::json!("2"));
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
options.insert("timeout".to_string(), serde_json::json!("60"));
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
base_options.insert("bt-metadata-only".to_string(), serde_json::json!("true"));
base_options.insert("bt-save-metadata".to_string(), serde_json::json!("true"));
base_options.insert("max-tries".to_string(), serde_json::json!("3"));
base_options.insert("retry-wait".to_string(), serde_json::json!("2"));
base_options.insert("connect-timeout".to_string(), serde_json::json!("20"));
base_options.insert("timeout".to_string(), serde_json::json!("60"));
base_options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
if let Some(proxy) = proxy_value.as_deref() {
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
base_options.insert("all-proxy".to_string(), serde_json::json!(proxy));
}
let mut header_list = Vec::new();
if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) {
@@ -9274,33 +9300,93 @@ async fn resolve_magnet_metadata(
);
}
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
base_options.insert("header".to_string(), serde_json::json!(header_list));
}
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
let resolver_mode = if proxy_value
let direct_route = proxy_value
.as_deref()
.is_none_or(|proxy| proxy.trim().is_empty())
{
"automatic"
} else {
"configured"
};
let metadata_result = crate::torrent_probe::run_bounded_metadata_probe(
.is_none_or(|proxy| proxy.trim().is_empty());
let route_contract_available = app_handle
.state::<Aria2DaemonGuard>()
.route_contract_available();
let mut system_options = base_options.clone();
crate::network::apply_aria2_system_resolver_for_daemon(
&mut system_options,
route_contract_available,
);
let first_result = crate::torrent_probe::run_bounded_metadata_probe(
client,
&sanitized_source,
options,
system_options,
&metadata_path,
resolver_mode,
crate::torrent_probe::MetadataProbeSchedule {
total_timeout: Duration::from_secs(60),
metadata_timeout: Duration::from_secs(55),
cleanup_reserve: Duration::from_secs(5),
poll_interval: Duration::from_millis(250),
},
"system",
magnet_probe_schedule(MAGNET_SYSTEM_RESOLVER_ATTEMPT),
)
.await;
let metadata_result = match first_result {
error @ Err(_) if direct_route
&& route_contract_available
&& error
.as_ref()
.err()
.is_some_and(crate::torrent_probe::allows_resolver_fallback) =>
{
let cleanup_budget = operation_deadline.saturating_duration_since(Instant::now());
match tokio::time::timeout(cleanup_budget, remove_magnet_metadata_probe_dir(&probe_dir))
.await
{
Ok(Ok(())) => {}
Ok(Err(error)) => {
return Err(format!(
"could not clean up magnet metadata probe before resolver fallback: {error}"
));
}
Err(_) => {
return Err(
"could not clean up magnet metadata probe before resolver fallback: cleanup timed out"
.to_string(),
);
}
}
let create_budget = operation_deadline.saturating_duration_since(Instant::now());
match tokio::time::timeout(create_budget, tokio::fs::create_dir_all(&probe_dir)).await {
Ok(Ok(())) => {}
Ok(Err(error)) => {
return Err(format!(
"could not recreate magnet metadata probe before resolver fallback: {error}"
));
}
Err(_) => {
return Err(
"could not recreate magnet metadata probe before resolver fallback: creation timed out"
.to_string(),
);
}
}
let alternate_budget = MAGNET_ALTERNATE_RESOLVER_ATTEMPT
.min(operation_deadline.saturating_duration_since(Instant::now()));
let mut alternate_options = base_options;
crate::network::apply_aria2_route_contract(&mut alternate_options);
crate::torrent_probe::run_bounded_metadata_probe(
std::sync::Arc::new(Aria2RpcClient {
port: state
.aria2_port
.load(std::sync::atomic::Ordering::Relaxed),
secret: state.aria2_secret.clone(),
}),
&sanitized_source,
alternate_options,
&metadata_path,
"alternate",
magnet_probe_schedule(alternate_budget),
)
.await
}
other => other,
};
let bytes = match metadata_result {
Ok(bytes) => bytes,
Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => {
@@ -18444,8 +18530,8 @@ pub fn run() {
let frontend_exit_flush = Arc::new(FrontendExitFlush::new());
// Zero is the explicit not-ready sentinel. Publishing a candidate port
// before the daemon's route contract is attested could make commands or
// the WebSocket listener attach to an unrelated local service.
// before the daemon's baseline RPC is attested could make commands or the
// WebSocket listener attach to an unrelated local service.
let aria2_port = Arc::new(std::sync::atomic::AtomicU16::new(0));
let aria2_port_clone = Arc::clone(&aria2_port);
let aria2_secret = uuid::Uuid::new_v4().to_string();
@@ -18846,6 +18932,9 @@ pub fn run() {
continue;
}
attempted_rpc_port = true;
app_handle_bg
.state::<Aria2DaemonGuard>()
.set_route_contract_available(false);
let mut cmd = std::process::Command::new(&binary_path);
crate::platform::hide_child_console(&mut cmd);
crate::engines::apply_aria2_environment(&mut cmd, &binary_path);
@@ -18868,18 +18957,12 @@ pub fn run() {
.arg("--download-result=hide")
.arg("--max-concurrent-downloads=9999")
.arg("--check-certificate=true")
// Firelink relies on Aria2's asynchronous
// resolver so a slow DNS answer cannot block
// the daemon's shared RPC/control loop.
.arg("--async-dns=true")
.arg(format!(
"--dns-resolver={}",
crate::network::ARIA2_DNS_RESOLVER
))
.arg(format!(
"--network-target-policy={}",
crate::network::ARIA2_NETWORK_TARGET_POLICY
))
// Resolver choice is explicit per transfer.
// Do not force Firelink-only options at daemon
// startup: the locked Windows/Linux engines
// are stock Aria2 builds, while a patched
// build advertises its optional fallback via
// getVersion.
.arg(format!("--stop-with-process={}", std::process::id()));
apply_aria2_torrent_global_options(
@@ -19003,31 +19086,30 @@ pub fn run() {
while start.elapsed() < std::time::Duration::from_secs(5) {
match rpc_call(attempt_port, &aria2_secret_clone, "aria2.getVersion", serde_json::json!([])).await {
Ok(ver) => {
if let Some(error) =
crate::network::aria2_baseline_error(&ver)
{
last_err = error;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
let v = ver.get("version").and_then(|v| v.as_str()).unwrap_or("unknown");
let contract_error =
crate::network::aria2_route_contract_error(&ver);
let capability_error =
crate::network::aria2_route_capability_error(&ver);
log::info!(
"aria2 daemon ready (version {}) on port {} (route contract: {})",
"aria2 daemon ready (version {}) on port {} (alternate resolver: {})",
v,
attempt_port,
if contract_error.is_none() { "verified" } else { "incompatible" }
if capability_error.is_none() { "available" } else { "unavailable" }
);
if let Some(contract_error) = contract_error {
let error = format!("{contract_error}; network transfers are disabled");
log::error!("{}", error);
*guard.startup_error.lock().unwrap() =
Some(error);
// The route-safe Aria2 contract depends on
// asynchronous DNS. Do not leave a daemon
// running in a mode that can block its RPC
// loop behind a system resolver.
shutdown_aria2_daemon(app_handle_bg.clone())
.await;
aria2_port_clone.store(
0,
std::sync::atomic::Ordering::Relaxed,
guard.set_route_contract_available(
capability_error.is_none(),
);
if let Some(capability_error) = capability_error {
log::warn!(
"aria2 alternate resolver unavailable; using system resolver only: {}",
capability_error
);
return;
}
aria2_port_clone.store(
attempt_port,
@@ -19267,9 +19349,8 @@ pub fn run() {
interval.tick().await;
if poll_port.load(std::sync::atomic::Ordering::Acquire) == 0 {
// The daemon launcher publishes the port only after
// the exact route contract is attested. Avoid
// generating RPC failures while startup is pending or
// permanently unavailable.
// baseline RPC readiness is attested. Avoid generating
// RPC failures while startup is pending or unavailable.
continue;
}
// Terminal cleanup removes a download's GID mapping. Do
+157 -30
View File
@@ -15,9 +15,43 @@ pub(crate) const ARIA2_NETWORK_TARGET_POLICY: &str = "firelink-v1";
pub(crate) const ARIA2_NETWORK_TARGET_POLICY_DIGEST: &str =
"sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705";
/// Stamp the route contract on each Aria2 request as well as on the daemon.
/// This keeps a later global-option mutation from silently changing the DNS or
/// literal-target policy of a queued transfer.
/// Select Aria2's standard resolver path for a transfer.
///
/// This is deliberately the production default: hostname resolution remains
/// owned by the OS/TUN route and stock Aria2 builds remain usable. Remove the
/// optional Firelink-only fields as well so a caller cannot accidentally carry
/// alternate-resolver options into a system-resolver request.
pub(crate) fn apply_aria2_system_resolver(
options: &mut serde_json::Map<String, serde_json::Value>,
) {
options.insert("async-dns".to_string(), serde_json::json!("false"));
options.remove("dns-resolver");
options.remove("network-target-policy");
}
/// Select the system resolver for a transfer on a daemon that has already
/// advertised the optional Firelink route contract.
///
/// The custom daemon's default target policy is route-aware and would still
/// reject private answers if the policy were merely omitted. Set it to `none`
/// explicitly for the system-resolver route; stock daemons never receive this
/// optional field.
pub(crate) fn apply_aria2_system_resolver_for_daemon(
options: &mut serde_json::Map<String, serde_json::Value>,
firelink_route_contract_available: bool,
) {
apply_aria2_system_resolver(options);
if firelink_route_contract_available {
options.insert(
"network-target-policy".to_string(),
serde_json::json!("none"),
);
}
}
/// Select the optional Firelink-patched Aria2 resolver and target policy for a
/// transfer. Callers must first attest the daemon capabilities with
/// `aria2_route_capability_error`.
pub(crate) fn apply_aria2_route_contract(
options: &mut serde_json::Map<String, serde_json::Value>,
) {
@@ -32,8 +66,19 @@ pub(crate) fn apply_aria2_route_contract(
);
}
/// Verify the exact fork contract before Firelink admits network work.
pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option<String> {
fn has_string_capability(version: &serde_json::Value, field: &str, expected: &str) -> bool {
version
.get(field)
.and_then(serde_json::Value::as_array)
.is_some_and(|values| values.iter().any(|value| value.as_str() == Some(expected)))
}
/// Verify that an Aria2 daemon exposes the optional Firelink route features.
///
/// The selected resolver and policy are intentionally not checked here: the
/// daemon starts in system-resolver mode and the alternate settings are
/// applied only to an individual fallback transfer.
pub(crate) fn aria2_route_capability_error(version: &serde_json::Value) -> Option<String> {
let async_dns = version
.get("enabledFeatures")
.and_then(serde_json::Value::as_array)
@@ -47,21 +92,7 @@ pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option<
}
for (field, expected, label) in [
(
"firelinkRevision",
ARIA2_FIRELINK_REVISION,
"Firelink engine revision",
),
(
"firelinkDnsResolver",
ARIA2_DNS_RESOLVER,
"native DNS resolver",
),
(
"firelinkNetworkTargetPolicy",
ARIA2_NETWORK_TARGET_POLICY,
"network target policy",
),
("firelinkRevision", ARIA2_FIRELINK_REVISION, "Firelink engine revision"),
(
"firelinkNetworkTargetPolicyDigest",
ARIA2_NETWORK_TARGET_POLICY_DIGEST,
@@ -72,6 +103,40 @@ pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option<
return Some(format!("bundled aria2 has an incompatible {label}"));
}
}
if !has_string_capability(version, "firelinkDnsResolvers", ARIA2_DNS_RESOLVER) {
return Some("bundled aria2 does not expose the native DNS resolver".to_string());
}
if !has_string_capability(
version,
"firelinkNetworkTargetPolicies",
ARIA2_NETWORK_TARGET_POLICY,
) {
return Some("bundled aria2 does not expose the network target policy".to_string());
}
None
}
/// Verify that the optional Firelink route settings are active for the
/// current transfer/daemon option scope.
#[cfg(test)]
pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option<String> {
if let Some(error) = aria2_route_capability_error(version) {
return Some(error);
}
if version
.get("firelinkDnsResolver")
.and_then(serde_json::Value::as_str)
!= Some(ARIA2_DNS_RESOLVER)
{
return Some("bundled aria2 is not using the native DNS resolver".to_string());
}
if version
.get("firelinkNetworkTargetPolicy")
.and_then(serde_json::Value::as_str)
!= Some(ARIA2_NETWORK_TARGET_POLICY)
{
return Some("bundled aria2 is not using the network target policy".to_string());
}
if version
.get("firelinkNetworkTargetPolicyEnforced")
.and_then(serde_json::Value::as_bool)
@@ -82,6 +147,18 @@ pub(crate) fn aria2_route_contract_error(version: &serde_json::Value) -> Option<
None
}
/// Verify the standard RPC response needed by every supported Aria2 build.
pub(crate) fn aria2_baseline_error(version: &serde_json::Value) -> Option<String> {
if version
.get("version")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty())
{
return Some("bundled aria2 returned an invalid version response".to_string());
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum NetworkRoute {
/// Preserve reqwest's normal environment/OS route selection.
@@ -332,30 +409,38 @@ mod tests {
}
#[test]
fn aria2_route_contract_requires_exact_capabilities() {
fn aria2_route_capabilities_are_distinct_from_active_options() {
let valid = serde_json::json!({
"version": "1.37.0",
"enabledFeatures": ["Async DNS", "BitTorrent"],
"firelinkRevision": ARIA2_FIRELINK_REVISION,
"firelinkDnsResolver": ARIA2_DNS_RESOLVER,
"firelinkDnsResolver": "disabled",
"firelinkDnsResolvers": [ARIA2_DNS_RESOLVER],
"firelinkNetworkTargetPolicy": ARIA2_NETWORK_TARGET_POLICY,
"firelinkNetworkTargetPolicies": ["none", ARIA2_NETWORK_TARGET_POLICY],
"firelinkNetworkTargetPolicyDigest": ARIA2_NETWORK_TARGET_POLICY_DIGEST,
"firelinkNetworkTargetPolicyEnforced": true,
"firelinkNetworkTargetPolicyEnforced": false,
});
assert_eq!(aria2_route_contract_error(&valid), None);
assert_eq!(aria2_route_capability_error(&valid), None);
assert!(aria2_route_contract_error(&valid).is_some());
let mut active = valid.clone();
active["firelinkDnsResolver"] = serde_json::json!(ARIA2_DNS_RESOLVER);
active["firelinkNetworkTargetPolicyEnforced"] = serde_json::json!(true);
assert_eq!(aria2_route_contract_error(&active), None);
for field in [
"firelinkRevision",
"firelinkDnsResolver",
"firelinkNetworkTargetPolicy",
"firelinkDnsResolvers",
"firelinkNetworkTargetPolicies",
"firelinkNetworkTargetPolicyDigest",
"firelinkNetworkTargetPolicyEnforced",
] {
let mut invalid = valid.clone();
let mut invalid = active.clone();
invalid.as_object_mut().unwrap().remove(field);
assert!(aria2_route_contract_error(&invalid).is_some(), "{field}");
assert!(aria2_route_capability_error(&invalid).is_some(), "{field}");
}
let mut wrong_digest = valid.clone();
let mut wrong_digest = active.clone();
wrong_digest["firelinkNetworkTargetPolicyDigest"] = serde_json::json!("sha256:wrong");
assert!(aria2_route_contract_error(&wrong_digest).is_some());
assert!(aria2_route_contract_error(&serde_json::json!({
@@ -379,6 +464,48 @@ mod tests {
);
}
#[test]
fn aria2_system_options_remove_optional_route_fields() {
let mut options = serde_json::Map::from_iter([
("dns-resolver".to_string(), serde_json::json!(ARIA2_DNS_RESOLVER)),
(
"network-target-policy".to_string(),
serde_json::json!(ARIA2_NETWORK_TARGET_POLICY),
),
]);
apply_aria2_system_resolver(&mut options);
assert_eq!(options.get("async-dns"), Some(&serde_json::json!("false")));
assert!(!options.contains_key("dns-resolver"));
assert!(!options.contains_key("network-target-policy"));
}
#[test]
fn aria2_system_options_disable_the_custom_default_policy_only_when_attested() {
let mut stock_options = serde_json::Map::new();
apply_aria2_system_resolver_for_daemon(&mut stock_options, false);
assert!(!stock_options.contains_key("network-target-policy"));
let mut patched_options = serde_json::Map::new();
apply_aria2_system_resolver_for_daemon(&mut patched_options, true);
assert_eq!(
patched_options.get("network-target-policy"),
Some(&serde_json::json!("none"))
);
assert_eq!(patched_options.get("async-dns"), Some(&serde_json::json!("false")));
}
#[test]
fn stock_aria2_baseline_response_is_accepted_without_firelink_fields() {
assert_eq!(
aria2_baseline_error(&serde_json::json!({
"version": "1.37.0",
"enabledFeatures": ["Async DNS", "BitTorrent"],
})),
None
);
assert!(aria2_baseline_error(&serde_json::json!({})).is_some());
}
#[test]
fn rejects_literal_local_and_mapped_addresses() {
for raw in [
+13 -2
View File
@@ -1010,7 +1010,7 @@ fn aria2_resolver_route_for_log(payload: &SpawnPayload) -> &'static str {
{
"configured"
} else {
"automatic"
"system"
}
}
@@ -8342,7 +8342,18 @@ impl SidecarSpawner for ProductionSpawner {
let attempt_epoch = state.queue_manager.current_aria2_control_epoch(id).await;
let admission_started = Instant::now();
let mut options = serde_json::Map::new();
crate::network::apply_aria2_route_contract(&mut options);
// Keep hostname resolution on the system/TUN route for every normal
// and Torrent transfer. The optional Firelink resolver is reserved for
// an explicit magnet metadata fallback and is never forced onto stock
// Aria2 builds.
let route_contract_available = self
.app_handle
.state::<crate::Aria2DaemonGuard>()
.route_contract_available();
crate::network::apply_aria2_system_resolver_for_daemon(
&mut options,
route_contract_available,
);
let mut connection_options = None;
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
if !crate::is_safe_path(&resolved_dest, &self.app_handle) {
+52 -31
View File
@@ -33,11 +33,12 @@ pub(crate) struct MetadataProbeSchedule {
pub(crate) async fn run_metadata_probe<C: RpcClient + 'static>(
client: Arc<C>,
source: &str,
options: Map<String, Value>,
mut options: Map<String, Value>,
metadata_path: &Path,
timeout: Duration,
poll_interval: Duration,
) -> Result<Vec<u8>, ProbeFailure> {
crate::network::apply_aria2_system_resolver(&mut options);
let deadline = Instant::now() + timeout;
run_metadata_probe_with_deadlines(
client,
@@ -66,8 +67,9 @@ pub(crate) async fn run_metadata_probe_with_deadlines<C: RpcClient + 'static>(
) -> 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 the complete addUri lifecycle.
crate::network::apply_aria2_route_contract(&mut options);
// the probe cleanup guard owns the complete addUri lifecycle. The caller
// selects the system or optional alternate resolver before entering this
// lifecycle; this function must not silently change that route.
options.insert("follow-torrent".to_string(), json!("false"));
options.insert("follow-metalink".to_string(), json!("false"));
let planned_gid = new_probe_gid();
@@ -283,11 +285,10 @@ async fn finish_failed_probe<C: RpcClient + 'static>(
}
}
/// Run magnet metadata discovery through Aria2's asynchronous resolver while
/// reserving part of the absolute deadline for deterministic GID cleanup. A
/// synchronous system-resolver retry is intentionally excluded because
/// Aria2 executes getaddrinfo on its shared event loop and can otherwise
/// stall every transfer and the local RPC control channel.
/// Run one bounded magnet metadata attempt while reserving part of the
/// absolute deadline for deterministic GID cleanup. Resolver selection is
/// explicit in `options`; this function never starts a second route or hides a
/// cleanup failure behind a retry.
pub(crate) async fn run_bounded_metadata_probe<C: RpcClient + 'static>(
client: Arc<C>,
source: &str,
@@ -354,6 +355,16 @@ fn probe_error_class(error: &str) -> &'static str {
}
}
pub(crate) fn allows_resolver_fallback(error: &ProbeFailure) -> bool {
match error {
ProbeFailure::Cleanup(_) => false,
ProbeFailure::Metadata(error) => {
matches!(probe_error_class(error), "timeout" | "name_resolution")
|| crate::retry::is_transient_network_error(error)
}
}
}
struct ProbeCleanupGuard<C: RpcClient + 'static> {
client: Arc<C>,
gids: Vec<String>,
@@ -1178,6 +1189,7 @@ mod tests {
statuses: Mutex<VecDeque<Result<Value, String>>>,
force_remove: Mutex<Result<Value, String>>,
removed: AtomicBool,
hang_first_status: AtomicBool,
calls: Mutex<Vec<String>>,
call_notification: tokio::sync::Notify,
}
@@ -1191,11 +1203,16 @@ mod tests {
statuses: Mutex::new(statuses.into_iter().collect()),
force_remove: Mutex::new(force_remove),
removed: AtomicBool::new(false),
hang_first_status: AtomicBool::new(false),
calls: Mutex::new(Vec::new()),
call_notification: tokio::sync::Notify::new(),
}
}
fn hang_first_status(&self) {
self.hang_first_status.store(true, Ordering::Release);
}
fn status(name: &str) -> Result<Value, String> {
Ok(json!({ "status": name }))
}
@@ -1228,6 +1245,10 @@ mod tests {
match method {
"aria2.addUri" => Ok(json!("gid-1")),
"aria2.tellStatus" => {
if self.hang_first_status.swap(false, Ordering::AcqRel) {
std::future::pending::<()>().await;
unreachable!("the first status call should remain pending");
}
if let Some(status) = self
.statuses
.lock()
@@ -1576,25 +1597,30 @@ mod tests {
#[tokio::test(flavor = "current_thread")]
async fn generic_metadata_timeout_does_not_enter_blocking_system_resolver() {
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;
// Use a deterministic pending RPC rather than a loopback HTTP server.
// The assertion is about the probe deadline and cleanup ownership, so
// socket scheduling must not decide whether the addUri call settles
// before a 100ms test budget on a busy hosted runner.
let rpc = Arc::new(FakeRpc::new(
[FakeRpc::status("removed")],
Ok(json!("gid-1")),
));
rpc.hang_first_status();
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 error = run_bounded_metadata_probe(
server.client(),
rpc.clone(),
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
Map::new(),
{
let mut options = Map::new();
crate::network::apply_aria2_system_resolver(&mut options);
options
},
&metadata_path,
"automatic",
"system",
MetadataProbeSchedule {
total_timeout: Duration::from_millis(500),
metadata_timeout: Duration::from_millis(100),
@@ -1606,18 +1632,13 @@ mod tests {
.expect_err("a metadata timeout should remain on the non-blocking route");
assert!(matches!(error, ProbeFailure::Metadata(message) if message.contains("timed out")));
assert_eq!(
server
.calls()
rpc.call_names()
.iter()
.filter(|(method, _)| method == "aria2.addUri")
.filter(|method| method.as_str() == "aria2.addUri")
.count(),
1,
"peer or tracker timeouts must not trigger the synchronous resolver"
);
tokio::fs::remove_dir_all(&probe_dir)
.await
.expect("timed-out probe fixture should be removable");
server.shutdown().await;
}
#[tokio::test(flavor = "current_thread")]