Files
torlink/scripts/verify-seeding.ts
bairon.dev 14b4a5ede6 feat: seed downloads by default (opt-out) with robust resume and clean quit
Finished downloads now keep seeding automatically and you opt out per item with
p, reusing the live torrent instead of tearing it down. Resume and restore seed
from the .torrent metadata captured at download time, so they verify the local
file immediately rather than depending on re-fetching metadata from the swarm.

Quitting now flushes queue, history, and seeds synchronously and exits
immediately, fixing the hang when quitting while seeding or with a paused seed.
Paused seeds persist their state and stay paused across restarts. Hide the
hardware cursor on the splash, and clarify that c removes a finished item the
same way in Downloads and Seeding.

Tests are isolated from the real data dir via a TORLINK_STATE_DIR override plus
vitest config, so runs never touch user data. Adds scripts/verify-seeding.ts
(npm run verify:seeding), an offline-deterministic harness proving seed, pause,
and resume transfer real bytes.
2026-06-27 17:02:22 -04:00

170 lines
6.5 KiB
TypeScript

/**
* Proves torlink's seeding actually transfers bytes (not just a UI label), and
* that pause and resume really stop/restart it.
*
* Offline-deterministic: the leechers run with dht/tracker/lsd OFF and are
* wired straight to our seeder via addPeer("127.0.0.1:<port>"), so the ONLY
* possible source of data is the TorrentEngine under test.
*
* npx tsx scripts/verify-seeding.ts
*/
import WebTorrent from "webtorrent";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { TorrentEngine } from "../src/download/engine";
const FILE_BYTES = 4 * 1024 * 1024; // 4 MB
const PULL_TIMEOUT = 20_000;
const PAUSE_WINDOW = 6_000;
function log(msg: string): void {
process.stdout.write(`${msg}\n`);
}
function randomBytes(n: number): Buffer {
// Deterministic-ish filler; content doesn't matter, only that it transfers.
const buf = Buffer.allocUnsafe(n);
for (let i = 0; i < n; i++) buf[i] = (i * 31 + 7) & 0xff;
return buf;
}
// Spin a leecher wired directly to the seeder; resolve with bytes downloaded.
// `source` is the .torrent path (metadata) so the leecher knows the pieces and
// the only open question is whether our seeder actually serves them.
function leech(
source: string,
port: number,
dir: string,
timeout: number,
): Promise<{ done: boolean; downloaded: number }> {
return new Promise((resolve) => {
const client = new WebTorrent({ dht: false, tracker: false, lsd: false });
const torrent = client.add(source, { path: dir, announce: [] });
let settled = false;
let rewire: ReturnType<typeof setInterval> | null = null;
const timer = setTimeout(() => finish(false), timeout);
const finish = (done: boolean): void => {
if (settled) return;
settled = true;
const downloaded = client.torrents[0]?.downloaded ?? 0;
clearTimeout(timer);
if (rewire) clearInterval(rewire);
client.destroy(() => resolve({ done, downloaded }));
};
// A magnet has no metadata yet, so the leecher must learn it FROM this peer;
// addPeer is only legal after the infoHash event. webtorrent's server binds
// IPv6 (::), so use the v6 loopback (v4 too for good measure). Re-wire on a
// timer: with discovery off, a dropped connection won't be re-found, so we
// keep nudging the peer back in until the transfer completes.
const wire = (): void => {
torrent.addPeer(`[::1]:${port}`);
torrent.addPeer(`127.0.0.1:${port}`);
};
torrent.on("infoHash", () => {
wire();
rewire = setInterval(wire, 1000);
});
torrent.on("done", () => finish(true));
});
}
// Wait until the engine reports the torrent fully verified (progress 1), so we
// only start leeching once there is something complete to pull.
async function waitSeederReady(
engine: TorrentEngine,
id: string,
timeout: number,
): Promise<{ progress: number; peers: number }> {
const start = Date.now();
for (;;) {
const s = engine.stats(id);
if (s && s.progress >= 1) return { progress: s.progress, peers: s.peers };
if (Date.now() - start > timeout) return { progress: s?.progress ?? 0, peers: s?.peers ?? 0 };
await new Promise((r) => setTimeout(r, 200));
}
}
async function main(): Promise<void> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-seedcheck-"));
const seedDir = path.join(root, "seed");
await fs.mkdir(seedDir, { recursive: true });
const filePath = path.join(seedDir, "payload.bin");
await fs.writeFile(filePath, randomBytes(FILE_BYTES));
log(`payload: ${filePath} (${FILE_BYTES} bytes)`);
// Mint the .torrent metadata for the on-disk file via a throwaway client, then
// drop it. This is what torlink now captures at download time and seeds from.
const minter = new WebTorrent({ dht: false, tracker: false, lsd: false });
const { meta, infoHash } = await new Promise<{ meta: Uint8Array; infoHash: string }>(
(resolve) => {
minter.seed(filePath, { announce: [] }, (t) =>
resolve({ meta: Uint8Array.from(t.torrentFile), infoHash: t.infoHash }),
);
},
);
await new Promise<void>((r) => minter.destroy(() => r()));
const torrentPath = path.join(root, "meta.torrent");
await fs.writeFile(torrentPath, meta);
log(`metadata minted: ${infoHash}`);
const engine = new TorrentEngine();
let failures = 0;
const check = (label: string, ok: boolean, detail: string): void => {
log(`${ok ? "PASS" : "FAIL"} ${label} ${detail}`);
if (!ok) failures++;
};
// 1) SEED — engine verifies the on-disk file from metadata and uploads it.
engine.add(infoHash, torrentPath, seedDir, {
onError: (m) => log(`seed onError: ${m}`),
});
const ready1 = await waitSeederReady(engine, infoHash, 10_000);
const port1 = engine.listenPort();
log(`engine seeding: progress=${ready1.progress} port=${port1}`);
const r1 = await leech(torrentPath, port1 ?? 0, path.join(root, "leech1"), PULL_TIMEOUT);
const s1 = engine.stats(infoHash);
check(
"seed uploads to a peer",
r1.done && r1.downloaded === FILE_BYTES,
`downloaded=${r1.downloaded}/${FILE_BYTES} done=${r1.done} engineUploaded=${s1?.uploaded ?? 0} peers=${s1?.peers ?? 0}`,
);
// 2) PAUSE — engine.remove tears the torrent down; a fresh leecher gets nothing.
engine.remove(infoHash);
await new Promise((r) => setTimeout(r, 500));
const r2 = await leech(torrentPath, port1 ?? 0, path.join(root, "leech2"), PAUSE_WINDOW);
check(
"pause stops seeding",
!r2.done && r2.downloaded === 0,
`downloaded=${r2.downloaded} done=${r2.done} (expected 0 / false)`,
);
// 3) RESUME — re-add and a fresh leecher completes again.
engine.add(infoHash, torrentPath, seedDir, {
onError: (m) => log(`resume onError: ${m}`),
});
const ready3 = await waitSeederReady(engine, infoHash, 10_000);
const port3 = engine.listenPort();
log(`engine re-seeding: progress=${ready3.progress} port=${port3}`);
const r3 = await leech(torrentPath, port3 ?? 0, path.join(root, "leech3"), PULL_TIMEOUT);
const s3 = engine.stats(infoHash);
check(
"resume re-seeds",
r3.done && r3.downloaded === FILE_BYTES,
`downloaded=${r3.downloaded}/${FILE_BYTES} done=${r3.done} engineUploaded=${s3?.uploaded ?? 0}`,
);
engine.destroy();
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
log("");
log(failures === 0 ? "RESULT: seeding is REAL ✓ (seed/pause/resume verified)" : `RESULT: ${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((e) => {
log(`harness error: ${e instanceof Error ? e.stack : String(e)}`);
process.exit(1);
});