Files
Firelink/scripts/smoke-aria2-resolver.js
T

233 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
const platform = {
darwin: 'apple-darwin',
linux: 'unknown-linux-gnu',
win32: 'pc-windows-msvc',
}[process.platform];
if (!arch || !platform) {
throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
}
const targetTriple = `${arch}-${platform}`;
const argumentIndex = process.argv.indexOf('--binary');
const binaryPath = path.resolve(
argumentIndex >= 0
? process.argv[argumentIndex + 1]
: path.join(
repoRoot,
'src-tauri',
'binaries',
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
),
);
if (!fs.existsSync(binaryPath)) {
throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
}
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
async function availablePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const address = server.address();
const port = address && typeof address !== 'string' ? address.port : undefined;
await new Promise(resolve => server.close(resolve));
if (!port) throw new Error('Could not reserve a local port');
return port;
}
async function rpc(port, secret, method, params = []) {
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method,
params: [`token:${secret}`, ...params],
}),
signal: AbortSignal.timeout(3000),
});
const body = await response.json();
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
return body.result;
}
function childExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForChildExit(child, timeoutMs = 3000) {
if (childExited(child)) return true;
return new Promise(resolve => {
let settled = false;
let timer;
const finish = result => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.off('exit', onExit);
resolve(result);
};
const onExit = () => finish(true);
timer = setTimeout(() => finish(false), timeoutMs);
child.once('exit', onExit);
if (childExited(child)) finish(true);
});
}
async function waitForRpc(port, secret) {
const deadline = Date.now() + 10000;
let lastError;
while (Date.now() < deadline) {
try {
return await rpc(port, secret, 'aria2.getVersion');
} catch (error) {
lastError = error;
await wait(100);
}
}
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
}
function bencode(value) {
if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]);
if (typeof value === 'string') return bencode(Buffer.from(value));
if (typeof value === 'number') return Buffer.from(`i${value}e`);
if (value && typeof value === 'object') {
const entries = Object.entries(value).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
return Buffer.concat([
Buffer.from('d'),
...entries.flatMap(([key, child]) => [bencode(key), bencode(child)]),
Buffer.from('e'),
]);
}
throw new Error(`Unsupported bencode value: ${typeof value}`);
}
async function stop(child, port, secret) {
if (!child || childExited(child)) return;
try {
await rpc(port, secret, 'aria2.shutdown');
} catch {
// The process may already have exited.
}
let exited = await waitForChildExit(child);
if (!exited) {
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
timeout: 3000,
});
} catch {
// The process may have exited between the timeout and taskkill.
}
} else {
child.kill('SIGTERM');
}
exited = await waitForChildExit(child);
}
if (!exited && process.platform !== 'win32') {
child.kill('SIGKILL');
exited = await waitForChildExit(child);
}
if (!exited) {
throw new Error(`Aria2 process ${child.pid} did not exit after forced cleanup`);
}
}
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-resolver-'));
const secret = `firelink-resolver-${crypto.randomUUID()}`;
const rpcPort = await availablePort();
const contentServer = http.createServer((_request, response) => {
response.writeHead(200, { 'content-length': '1' });
response.end('x');
});
await new Promise((resolve, reject) => {
contentServer.once('error', reject);
contentServer.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const contentPort = contentServer.address().port;
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
const environment = fs.existsSync(libraryPath)
? {
...process.env,
OPENSSL_MODULES: libraryPath,
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
}
: process.env;
const child = spawn(binaryPath, [
'--enable-rpc=true',
`--rpc-listen-port=${rpcPort}`,
'--rpc-listen-all=false',
`--rpc-secret=${secret}`,
`--dir=${tempRoot}`,
'--file-allocation=none',
'--enable-dht=false',
'--console-log-level=error',
'--quiet=true',
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
try {
const version = await waitForRpc(rpcPort, secret);
const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : [];
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`);
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
'async-dns': 'false',
out: 'resolver-normal.bin',
}]);
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
if (uriOptions['async-dns'] !== 'false') {
throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`);
}
const torrent = bencode({
info: {
length: 1,
name: 'resolver-torrent.bin',
pieces: Buffer.alloc(20),
'piece length': 16384,
},
}).toString('base64');
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
'async-dns': 'false',
dir: tempRoot,
}]);
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
if (torrentOptions['async-dns'] !== 'false') {
throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`);
}
console.log('[PASS] Aria2 retained system-resolver mode for normal and Torrent transfers');
} catch (error) {
const detail = stderr.trim();
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
} finally {
try {
await stop(child, rpcPort, secret);
} finally {
await new Promise(resolve => contentServer.close(resolve));
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}