// THE PEER OF LAST RESORT (release 21 D4b): when the home seeder seeds a // torrent, and when it stands by. // // The home seeder exists so that a torrent with nobody else seeding it still // plays. It should not be the swarm's workhorse: every byte it uploads goes // through a VPN on a home connection. So per torrent it SEEDS only while no // other seeder is known, and STANDS BY (stops announcing, closes its peers, // keeps the data) while others are. This module is the decision and nothing // else — pure, clocked by the caller, so every edge is testable against a // fake scrape (lastResortSeeder.test.ts); `archilyzer seed` // (controller/seeder.ts) observes the swarm and applies what it returns. // // WHAT IT SEES, per torrent per poll (`SwarmObservation`): // - the trackers' scrape: `complete` (seeders) and `incomplete` (leechers), // or null when no tracker answered; // - whether the seeder itself is counted in that `complete` (it is while it // announces as a seeder; once it has sent `stopped`, it is not); // - its own wires: connected peers that have every piece (other sources) // and connected peers that do not (leechers). // // THE EDGES, both with hysteresis so a swarm hovering at one seeder does not // make it flap: // seeding → standby other seeders seen on EVERY poll for // `standbyAfterSeconds` (the clock restarts the moment // a poll sees none); // standby → seeding no other seeder AND a leecher waiting (the tracker's // `incomplete`, or a wire): at once — someone is trying // to play it now; // no other seeder on every poll for `resumeAfterSeconds` // with nobody waiting: so the torrent is never left with // no seeder for longer than that. // A transition restarts both clocks, so the reverse edge needs its full // window again. A failed scrape decides nothing (the state holds) — except // that a seeder in standby whose trackers have been silent for // `resumeAfterSeconds` resumes: it cannot know there is another seeder, and // being the last resort means assuming there is not. // // Known cost (the plan's): if the last other seeder leaves mid-stream, a // viewer stalls until the next poll sees it. export type SeederTorrentState = "seeding" | "standby"; export type SwarmObservation = { // Milliseconds (the caller's clock). at: number; scrape: { complete: number; incomplete: number } | null; // Whether `scrape.complete` includes the seeder itself. selfCounted: boolean; // Connected peers holding every piece, and the rest. connectedSeeds: number; connectedLeechers: number; }; export type SeederPolicy = { standbyAfterSeconds: number; resumeAfterSeconds: number; }; export type SeederMemory = { state: SeederTorrentState; // When the current state began. since: number; // Start of the current run of polls that saw other seeders / saw none / // got no scrape. Null when the last poll broke the run. othersSince: number | null; aloneSince: number | null; silentSince: number | null; }; export type SeederTransition = { from: SeederTorrentState; to: SeederTorrentState; reason: string; }; export type SeederStep = { memory: SeederMemory; transition: SeederTransition | null; // What this poll saw, in words, for the log. seen: string; }; // A torrent starts SEEDING: on boot the seeder knows nothing about the swarm, // and the safe assumption for a last resort is that it is the only one. export function initialSeederMemory(at: number): SeederMemory { return { state: "seeding", since: at, othersSince: null, aloneSince: null, silentSince: null }; } // Other seeders this poll saw: the scrape's count without the seeder itself, // or the seeds connected to it, whichever is more (a seed on another tracker // the scrape did not ask is still a source). Null when there was no scrape // and no connected seed — unknown, not zero. export function otherSeeders(o: SwarmObservation): number | null { const fromScrape = o.scrape ? Math.max(0, o.scrape.complete - (o.selfCounted ? 1 : 0)) : null; if (fromScrape === null) return o.connectedSeeds > 0 ? o.connectedSeeds : null; return Math.max(fromScrape, o.connectedSeeds); } function leechersWaiting(o: SwarmObservation): number { return Math.max(o.scrape?.incomplete ?? 0, o.connectedLeechers); } const secs = (ms: number) => `${Math.round(ms / 1000)}s`; export function stepSeeder( memory: SeederMemory, o: SwarmObservation, policy: SeederPolicy, ): SeederStep { const others = otherSeeders(o); const waiting = leechersWaiting(o); const seen = others === null ? `no scrape answered; ${o.connectedSeeds} seed(s) and ${o.connectedLeechers} leecher(s) connected` : `${others} other seeder(s), ${waiting} leecher(s)`; // Advance the three run clocks. const m: SeederMemory = { ...memory, othersSince: others !== null && others > 0 ? (memory.othersSince ?? o.at) : null, aloneSince: others === 0 ? (memory.aloneSince ?? o.at) : null, silentSince: others === null ? (memory.silentSince ?? o.at) : null, }; const standbyMs = policy.standbyAfterSeconds * 1000; const resumeMs = policy.resumeAfterSeconds * 1000; const go = (to: SeederTorrentState, reason: string): SeederStep => ({ memory: { state: to, since: o.at, othersSince: null, aloneSince: null, silentSince: null }, transition: { from: m.state, to, reason }, seen, }); if (m.state === "seeding") { if (m.othersSince !== null && o.at - m.othersSince >= standbyMs) { return go( "standby", `${others} other seeder(s) on every poll for ${secs(o.at - m.othersSince)} (standby after ${policy.standbyAfterSeconds}s)`, ); } return { memory: m, transition: null, seen }; } // standby if (others === 0 && waiting > 0) { return go("seeding", `no other seeder and ${waiting} leecher(s) waiting`); } if (m.aloneSince !== null && o.at - m.aloneSince >= resumeMs) { return go("seeding", `no other seeder on every poll for ${secs(o.at - m.aloneSince)} (resume after ${policy.resumeAfterSeconds}s)`); } if (m.silentSince !== null && o.at - m.silentSince >= resumeMs) { return go("seeding", `no tracker answered for ${secs(o.at - m.silentSince)}: assuming no other seeder`); } return { memory: m, transition: null, seen }; }