mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
170 lines
6.5 KiB
TypeScript
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);
|
||
|
|
});
|