@torrent-tv/proxy 2.83.0 → 2.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/package.json +1 -1
  3. package/routes/api/sources/stats/get.js +11 -2
  4. package/routes/stream/get.js +74 -3
  5. package/services/delivery-probe.js +248 -43
  6. package/services/download/SwarmSelection.js +5 -5
  7. package/services/download/registry.js +20 -0
  8. package/services/encode/EncodeRun.js +1 -0
  9. package/services/encode/encode-exit.js +17 -0
  10. package/services/files/CompletedFiles.js +276 -0
  11. package/services/files/piece-from-whole-file.js +118 -0
  12. package/services/output/cut-grid.js +13 -3
  13. package/services/piece-store/piece-disk-store.js +72 -2
  14. package/services/piece-store/shared-piece-store.js +274 -27
  15. package/services/torrent-pool.js +259 -19
  16. package/services/torrent-worker/client.js +21 -0
  17. package/services/torrent-worker/protocol.js +9 -1
  18. package/services/torrent-worker/worker.js +183 -2
  19. package/test/completed-files.test.js +115 -0
  20. package/test/cuts-follow-published-grid.test.js +35 -0
  21. package/test/delivery-probe.test.js +114 -1
  22. package/test/encode-exit.test.js +18 -0
  23. package/test/piece-disk-store.test.js +26 -0
  24. package/test/piece-from-whole-file.test.js +129 -0
  25. package/test/piece-store-eviction.test.js +28 -15
  26. package/test/piece-store-never-refuses.test.js +153 -0
  27. package/test/piece-store-reservations.test.js +16 -3
  28. package/test/probe-wedge-certainty.test.js +3 -3
  29. package/test/shared-piece-store.test.js +27 -13
  30. package/test/stream-route.test.js +41 -0
  31. package/test/swarm-follows-readers.test.js +168 -0
  32. package/test/swarm-reach.test.js +5 -0
  33. package/test/upload-hurry.test.js +27 -0
@@ -177,7 +177,13 @@ test("the store says why it spills: what is asked of it, what it had to take, ho
177
177
  // eviction on ADMISSION — `asked.spills` above is zero, where before it all
178
178
  // six arrivals displaced a nearer piece and were read back moments later.
179
179
  assert.ok(after.fromDisk > 0, "the pieces that went to disk were read back from it");
180
- assert.ok(after.revivals > 0, "reading one back brings it into memory");
180
+ // Through the reader's own entry point, which is the one that populates
181
+ // memory. `get` is the torrent client's, and since 2026-09-11 it answers
182
+ // from disk without taking a block: a peer asks for kilobytes of a piece
183
+ // that is megabytes, and reviving the whole of it for that was how an
184
+ // upload capped at 512 KB/s produced 49 696 revivals in one session.
185
+ await store.reside(9);
186
+ assert.ok(store.stats().revivals > 0, "a read for playback brings the piece into memory");
181
187
  // No age to report, and that is right rather than missing: an age measures
182
188
  // how long an EVICTED piece stayed away, and these were never resident —
183
189
  // they were written on arrival and read back once.
@@ -252,11 +258,12 @@ test("before any reader has declared anything, an arriving piece still goes to m
252
258
  test("the store asks for what its readers declared, and for a whole window at least", async () => {
253
259
  const { store, directory } = await makeStore(64);
254
260
  try {
255
- // With nobody reading there is no demand to speak of, so the store asks for
256
- // what it is already allowed and the first revision after a read begins
257
- // brings it down.
261
+ // With nobody reading, the store asks for a floor one window's worth,
262
+ // and until it has been asked for anything, the minimum. It does NOT ask
263
+ // for everything it is allowed: pieces arriving for a reader still on its
264
+ // way have the disk.
258
265
  const idle = store.wantedBytes;
259
- assert.equal(idle, store.stats().budgetBytes);
266
+ assert.ok(idle > 0 && idle < store.stats().budgetBytes, "an idle store asks for a floor, not its whole allowance");
260
267
 
261
268
  // Two readers of one file — picture and sound — overlapping by
262
269
  // construction. The ask is their union, not their sum.
@@ -375,10 +382,11 @@ test("evicting a piece the disk already holds costs no second write", async () =
375
382
  assert.ok(written > 0);
376
383
  assert.equal(store.stats().spillsSkipped, 0, "the first write of a piece is a real one");
377
384
 
378
- // Read it back — it returns to memory and the copy stays on disk. Evicting
379
- // it again writes bytes that are already there, byte for byte, because only
380
- // `put` removes the disk copy and no `put` has happened.
385
+ // Read it back for playback — it returns to memory and the copy stays on
386
+ // disk. Evicting it again writes bytes that are already there, byte for
387
+ // byte, because only `put` removes the disk copy and no `put` has happened.
381
388
  assert.ok((await get(store, 0)).equals(pieceOf(0)));
389
+ await store.reside(0);
382
390
  for (let index = 10; index < 13; index += 1) {
383
391
  await put(store, index, pieceOf(index));
384
392
  }
@@ -396,19 +404,24 @@ test("a store whose readers have gone asks for nothing, one that never had them
396
404
  const { store, directory } = await makeStore(16);
397
405
  try {
398
406
  // Never had a reader: this is the initial download and the warm-up fetches
399
- // of the header and the tail, with a read on its way.
407
+ // of the header and the tail, with a read on its way. Those pieces have the
408
+ // disk, so the store asks for the minimum rather than for its opening
409
+ // allowance — a torrent nobody has read yet held 4180 MB of a machine's
410
+ // memory on 2026-09-11 while the film being watched was allowed 12 MB.
400
411
  const opening = store.wantedBytes;
401
- assert.equal(opening, store.stats().budgetBytes);
412
+ assert.ok(opening < store.stats().budgetBytes, "a store nobody has read keeps the minimum");
402
413
 
403
414
  store.protectRange("video", 0, 9);
404
415
  assert.equal(store.wantedBytes, 10 * PIECE);
405
416
 
406
- // The read ends. Its torrent sits until the pool's idle timer removes it,
407
- // and that timer needs a refcount of zero and can be a quarter of an hour
408
- // away. Holding the pieces for a reader that has gone is memory taken from
409
- // the machine for nothing.
417
+ // The read ends, and the store keeps room for ONE window what the next
418
+ // read will ask for within seconds. Falling to the minimum here is what
419
+ // left a store at three blocks when the viewer switched files on
420
+ // 2026-09-11, with the allowance re-derived a minute later and a claim
421
+ // giving up after five seconds.
410
422
  store.releaseProtection("video");
411
- assert.ok(store.wantedBytes < opening, "a store with no readers left asks for nothing");
423
+ assert.equal(store.wantedBytes, 10 * PIECE, "the floor between readers is the widest window seen");
424
+ assert.ok(store.wantedBytes > opening, "which is more than a store nobody has read keeps");
412
425
  } finally {
413
426
  store.destroy(() => undefined);
414
427
  await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @file A store that is short of memory must inconvenience a read, never end a
3
+ * torrent.
4
+ *
5
+ * The field failure of 2026-09-11, in one sentence: the store could not hand
6
+ * out a block, threw, the throw travelled out through the torrent client's own
7
+ * write callback, and the client destroyed the torrent. For the rest of the
8
+ * process every read of that film answered `File 1 not found in
9
+ * torrent:d4022ff4…`, `/stats` reported `peers=0 connected of 1186 known`, and
10
+ * the viewer could not open anything until the addon was restarted.
11
+ *
12
+ * Three properties hold it shut, and each is checked here on its own:
13
+ *
14
+ * 1. an arriving piece is never refused — it has the disk;
15
+ * 2. a read on the torrent client's own path takes no block at all, so an
16
+ * upload can neither wait for memory nor be refused it;
17
+ * 3. a store between readers keeps room for one window, because that is what
18
+ * the next read asks for and the allowance is otherwise re-derived a
19
+ * minute later — twelve times slower than a claim gives up.
20
+ */
21
+
22
+ import test from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import fs from "node:fs/promises";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
28
+
29
+ const PIECE = 1024;
30
+
31
+ /**
32
+ * @param {SharedPieceStore} store
33
+ * @param {number} index
34
+ * @returns {Promise<void>}
35
+ */
36
+ const put = (store, index) =>
37
+ new Promise((resolve, reject) => {
38
+ store.put(index, Buffer.alloc(PIECE, index % 251), (error) => (error ? reject(error) : resolve()));
39
+ });
40
+
41
+ /**
42
+ * @param {SharedPieceStore} store
43
+ * @param {number} index
44
+ * @param {{ offset: number, length: number }} range
45
+ * @returns {Promise<Buffer>}
46
+ */
47
+ const get = (store, index, range) =>
48
+ new Promise((resolve, reject) => {
49
+ store.get(index, range, (error, bytes) => (error ? reject(error) : resolve(bytes)));
50
+ });
51
+
52
+ /**
53
+ * @returns {Promise<string>}
54
+ */
55
+ const directory = () => fs.mkdtemp(path.join(os.tmpdir(), "never-refuses-"));
56
+
57
+ test("a piece is never refused for want of memory", async () => {
58
+ const root = await directory();
59
+ // Every block held by a piece that may not leave. The ceiling never falls
60
+ // below two, so this is how a store with nothing to give is reached: what
61
+ // held the blocks in the field was two writes on their way to disk, and a pin
62
+ // reaches the same state deterministically.
63
+ //
64
+ // This one takes the store's own patience to run — it must be seen to give
65
+ // up and keep the piece anyway.
66
+ const store = new SharedPieceStore(PIECE, {
67
+ length: PIECE * 8,
68
+ memoryBytes: PIECE * 2,
69
+ path: root,
70
+ name: "no-block-to-be-had"
71
+ });
72
+ try {
73
+ await put(store, 0);
74
+ await put(store, 1);
75
+ store.pin(0);
76
+ store.pin(1);
77
+
78
+ await put(store, 3);
79
+ const stats = store.stats();
80
+ assert.equal(stats.spilled, 1, "the piece is on disk");
81
+ assert.equal(store.locate(3), null, "and not in memory, since no block could be had for it");
82
+ assert.equal(stats.resident, 2, "the pinned pieces were not taken from under their reader");
83
+ assert.ok(
84
+ stats.admittedWithoutSlot >= 1,
85
+ "and the store says so, rather than the torrent client saying it with a destroyed torrent"
86
+ );
87
+ const bytes = await get(store, 3, { offset: 0, length: PIECE });
88
+ assert.equal(bytes.length, PIECE, "and it reads back");
89
+ assert.equal(bytes[0], 3 % 251, "with its own contents");
90
+ } finally {
91
+ await new Promise((resolve) => store.destroy(resolve));
92
+ await fs.rm(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("a read for the torrent client takes no block and only its own range", async () => {
97
+ const root = await directory();
98
+ const store = new SharedPieceStore(PIECE, {
99
+ length: PIECE * 8,
100
+ memoryBytes: PIECE,
101
+ path: root,
102
+ name: "ranged-read"
103
+ });
104
+ try {
105
+ // Two fit; the third displaces the oldest, which goes to disk.
106
+ await put(store, 0);
107
+ await put(store, 1);
108
+ await put(store, 2);
109
+ const before = store.stats();
110
+ assert.equal(before.spilled, 1, "the first piece is on disk");
111
+
112
+ const wanted = 16;
113
+ const bytes = await get(store, 0, { offset: PIECE - wanted, length: wanted });
114
+ assert.equal(bytes.length, wanted, "only what was asked for comes back");
115
+ assert.equal(bytes[0], 0, "and it is that piece's own bytes");
116
+
117
+ const after = store.stats();
118
+ assert.equal(
119
+ after.blocksAllocated,
120
+ before.blocksAllocated,
121
+ "and answering it took no block: a peer asks for kilobytes and a piece here is megabytes"
122
+ );
123
+ assert.equal(after.resident, before.resident, "nothing was revived for it");
124
+ assert.equal(after.fromDisk, before.fromDisk + 1, "and the read is counted as coming from disk");
125
+ } finally {
126
+ await new Promise((resolve) => store.destroy(resolve));
127
+ await fs.rm(root, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ test("a store between readers keeps room for one window", async () => {
132
+ const root = await directory();
133
+ const store = new SharedPieceStore(PIECE, {
134
+ length: PIECE * 200,
135
+ memoryBytes: PIECE * 64,
136
+ path: root,
137
+ name: "between-readers"
138
+ });
139
+ try {
140
+ store.protectRange("read-1", 10, 21, 100);
141
+ const asked = store.wantedBytes;
142
+ assert.ok(asked >= PIECE * 12, "with a reader, the window it declared is asked for");
143
+
144
+ store.releaseProtection("read-1");
145
+ assert.ok(
146
+ store.wantedBytes >= PIECE * 12,
147
+ "and with the reader gone the floor is still one window — the next read asks for the same again"
148
+ );
149
+ } finally {
150
+ await new Promise((resolve) => store.destroy(resolve));
151
+ await fs.rm(root, { recursive: true, force: true });
152
+ }
153
+ });
@@ -129,10 +129,12 @@ const get = (store, index) =>
129
129
  *
130
130
  * @param {() => boolean} holds
131
131
  * @param {string} what
132
+ * @param {number} [limit] - A backstop, never the measurement: one of the
133
+ * conditions here is the store's own patience, which is itself five seconds.
132
134
  * @returns {Promise<void>}
133
135
  */
134
- async function until(holds, what) {
135
- const deadline = Date.now() + 5_000;
136
+ async function until(holds, what, limit = 5_000) {
137
+ const deadline = Date.now() + limit;
136
138
  while (!holds()) {
137
139
  if (Date.now() > deadline) {
138
140
  throw new Error(`timed out waiting until ${what}`);
@@ -196,10 +198,21 @@ test("a claim that cannot be met ends in an error, not in waiting for ever", { t
196
198
  // other is pinned. The old rule waited while anything was nominally in
197
199
  // flight, which here is for ever.
198
200
  store.pin(1);
199
- await assert.rejects(() => put(store, 3), /nothing moved/);
201
+ // The claim gives up within its own patience — that is the property. What
202
+ // the caller does with the refusal is its own business, and since
203
+ // 2026-09-11 an arriving piece answers it by taking the disk instead of
204
+ // failing the torrent client's write.
205
+ const arriving = put(store, 3);
206
+ await until(
207
+ () => store.stats().blockedByPins > 0,
208
+ "the claim gave up rather than waiting for ever",
209
+ 20_000
210
+ );
200
211
 
201
212
  store.unpin(1);
202
213
  disk.releaseWrites();
214
+ await arriving;
215
+ disk.releaseWrites();
203
216
  await spilling;
204
217
  } finally {
205
218
  disk.releaseWrites();
@@ -12,7 +12,7 @@ test("a seen-counter bounded lag is not a wedge, however long it lasts", () => {
12
12
  stuckForMs: 3400,
13
13
  longestHealthySeenGapMs: 3500
14
14
  });
15
- assert.equal(verdict.certain, false);
15
+ assert.equal(verdict.isCertain, false);
16
16
  });
17
17
 
18
18
  test("a seen-counter frozen past this connection's own worst legitimate gap is a wedge", () => {
@@ -23,7 +23,7 @@ test("a seen-counter frozen past this connection's own worst legitimate gap is a
23
23
  stuckForMs: 90_000,
24
24
  longestHealthySeenGapMs: 3500
25
25
  });
26
- assert.equal(verdict.certain, true);
26
+ assert.equal(verdict.isCertain, true);
27
27
  });
28
28
 
29
29
  test("with no healthy history yet, one probe interval is still required", () => {
@@ -31,7 +31,7 @@ test("with no healthy history yet, one probe interval is still required", () =>
31
31
  stuckForMs: PROBE_INTERVAL_MS - 1,
32
32
  longestHealthySeenGapMs: 0
33
33
  });
34
- assert.equal(verdict.certain, false);
34
+ assert.equal(verdict.isCertain, false);
35
35
  assert.equal(verdict.needMs, PROBE_INTERVAL_MS);
36
36
  });
37
37
 
@@ -151,11 +151,16 @@ test("refuses to make room when every resident piece is being read", async () =>
151
151
  store.pin(0);
152
152
  store.pin(1);
153
153
 
154
- await assert.rejects(
155
- () => put(store, 2, piece(2)),
156
- /pinned/,
157
- "the store took memory from under a reader instead of refusing"
158
- );
154
+ // The pinned pieces stay where they are, and the arrival is kept anyway —
155
+ // on the disk, which is what it has. Refusing it was how a store short of
156
+ // memory ended a torrent: the refusal travelled out through the torrent
157
+ // client's own write callback and the client destroyed the torrent
158
+ // (field 2026-09-11).
159
+ await put(store, 2, piece(2));
160
+ assert.ok(store.locate(0), "the pinned piece was taken from under its reader");
161
+ assert.ok(store.locate(1), "the pinned piece was taken from under its reader");
162
+ assert.equal(store.locate(2), null, "and the arrival did not displace either of them");
163
+ assert.ok((await get(store, 2)).equals(piece(2)), "the arrival is kept, and reads back");
159
164
  } finally {
160
165
  store.unpin(0);
161
166
  store.unpin(1);
@@ -172,7 +177,7 @@ test("a piece revived from disk is readable by offset again", async () => {
172
177
  await put(store, 2, piece(2)); // pushes piece 0 out to disk
173
178
  assert.equal(store.locate(0), null, "piece 0 should have left memory");
174
179
 
175
- await get(store, 0); // brings it back
180
+ await store.reside(0); // brings it back, which is what a playback read does
176
181
  const located = store.locate(0);
177
182
  assert.ok(located, "piece 0 was not brought back into memory");
178
183
  const view = Buffer.from(located.buffer, located.offset, located.length);
@@ -248,11 +253,18 @@ test("counts where reads were served from, so the budget can be judged", async (
248
253
  const stats = store.stats();
249
254
  assert.equal(stats.fromMemory, 2, "memory reads miscounted");
250
255
  assert.equal(stats.fromDisk, 1, "disk reads miscounted");
251
- // Two: piece 0 goes out to make room for piece 2, then piece 1 goes out to
252
- // make room for piece 0 coming back. Reviving costs a spill of its own, and
253
- // that is worth seeing in the figures rather than hiding.
254
- assert.equal(stats.spills, 2, "spills miscounted");
255
- assert.equal(stats.revivals, 1, "revivals miscounted");
256
+ // One: piece 0 goes out to make room for piece 2. The read that follows is
257
+ // the torrent client's, and since 2026-09-11 it is answered from the disk
258
+ // copy without taking a block so nothing has to be written out for it and
259
+ // nothing is revived. A read for PLAYBACK still populates memory, and pays
260
+ // for it with a spill; that is the next assertion.
261
+ assert.equal(stats.spills, 1, "spills miscounted");
262
+ assert.equal(stats.revivals, 0, "the client's own read does not revive");
263
+
264
+ await store.reside(0);
265
+ const afterPlayback = store.stats();
266
+ assert.equal(afterPlayback.revivals, 1, "a read for playback revives");
267
+ assert.equal(afterPlayback.spills, 2, "and pays for its block with a spill");
256
268
  assert.equal(stats.blockedByPins, 0);
257
269
  assert.equal(stats.capacity, 2);
258
270
  } finally {
@@ -268,9 +280,11 @@ test("counts a refusal caused by pinned pieces", async () => {
268
280
  await put(store, 1, piece(1));
269
281
  store.pin(0);
270
282
  store.pin(1);
271
- await assert.rejects(() => put(store, 2, piece(2)));
283
+ await put(store, 2, piece(2));
272
284
 
273
- assert.equal(store.stats().blockedByPins, 1, "a refusal went unrecorded");
285
+ const stats = store.stats();
286
+ assert.equal(stats.blockedByPins, 1, "the store not being able to give a block went unrecorded");
287
+ assert.equal(stats.admittedWithoutSlot, 1, "and what it did instead went unrecorded");
274
288
  } finally {
275
289
  store.unpin(0);
276
290
  store.unpin(1);
@@ -10,6 +10,9 @@
10
10
 
11
11
  import test from "node:test";
12
12
  import assert from "node:assert/strict";
13
+ import fs from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
13
16
  import { handleStreamGet } from "../routes/stream/get.js";
14
17
 
15
18
  /**
@@ -143,3 +146,41 @@ test("a ranged GET is reported as a real read position", async () => {
143
146
 
144
147
  assert.deepEqual(state.prioritized, [{ byteStart: 4_390_000_000, wholeFileRead: false }]);
145
148
  });
149
+
150
+ test("a file downloaded whole is served from disk without touching the torrent", async () => {
151
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "stream-whole-"));
152
+ const where = path.join(root, "0");
153
+ const bytes = Buffer.from("a film that is a file now", "utf8");
154
+ await fs.writeFile(where, bytes);
155
+ try {
156
+ const { req, reply, sent, deps } = harness({ method: "GET", range: "bytes=2-6" });
157
+ let asked = false;
158
+ deps.torrentPool.getTorrent = async () => {
159
+ asked = true;
160
+ throw new Error("the torrent was asked for, which is what this avoids");
161
+ };
162
+ deps.torrentPool.wholeFiles = new Map([
163
+ ["abc/0", { path: where, length: bytes.length, name: "film.mkv" }]
164
+ ]);
165
+ // The source key IS the identity — `torrent:<infohash>` — and it is all the
166
+ // route needs to find the file.
167
+ deps.sourceRegistry = { get: () => ({ sourceType: "torrent", source: "magnet:?xt=urn:btih:abc" }) };
168
+ req.query = { sourceKey: "torrent:abc", fileIndex: "0" };
169
+
170
+ await handleStreamGet(req, reply, deps);
171
+
172
+ assert.equal(asked, false, "the torrent was asked for");
173
+ assert.equal(sent.code, 206);
174
+ assert.equal(sent.headers["content-range"], `bytes 2-6/${bytes.length}`);
175
+ assert.equal(sent.headers["content-length"], "5");
176
+ } finally {
177
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
178
+ }
179
+ });
180
+
181
+ test("a file this proxy does not have whole still goes to the torrent", async () => {
182
+ const { req, reply, state, deps } = harness({ method: "GET", range: "bytes=0-99" });
183
+ deps.torrentPool.wholeFiles = new Map([["other/7", { path: "/nowhere", length: 1, name: "x" }]]);
184
+ await handleStreamGet(req, reply, deps);
185
+ assert.equal(state.claims, 1, "the ordinary path was not taken");
186
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @file A proxy that needs nothing from a swarm is not in that swarm.
3
+ *
4
+ * WebTorrent enforces `maxConns` only on peers it dials (`_drain`), while
5
+ * `_addIncomingPeer` checks that the torrent is neither destroyed nor paused
6
+ * and registers the peer. A proxy with its port mapped is reachable, so
7
+ * connections arrive and are never turned away — field 2026-09-11: 249
8
+ * connected at the start of one viewing, 596 at the end, 15 862 more queued, on
9
+ * a file that had been complete for three quarters of an hour, each connection
10
+ * served by reading a 4 MB piece off the disk for every 16 KB sent.
11
+ *
12
+ * The rule is not a limit on connections. While anybody is reading, every
13
+ * connection is worth keeping: the one that has delivered nothing yet may
14
+ * deliver next. It is about a torrent nobody is reading at all.
15
+ */
16
+
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { TorrentPool } from "../services/torrent-pool.js";
20
+ import { demandFor, forgetTorrent } from "../services/download/registry.js";
21
+
22
+ /**
23
+ * A torrent that records what was done to it. Only the surface the rule
24
+ * touches: `paused` is the library's own flag, and the peers are what the pause
25
+ * does not close by itself.
26
+ *
27
+ * @param {number} peerCount
28
+ * @returns {object}
29
+ */
30
+ function torrentWith(peerCount) {
31
+ const peers = new Map();
32
+ for (let index = 0; index < peerCount; index += 1) {
33
+ peers.set(String(index), {
34
+ wire: { downloaded: 0 },
35
+ destroy() {
36
+ peers.delete(String(index));
37
+ }
38
+ });
39
+ }
40
+ return {
41
+ infoHash: "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0",
42
+ files: [{ length: 1024 }],
43
+ paused: false,
44
+ wires: [...peers.values()].map((peer) => peer.wire),
45
+ _peers: peers,
46
+ pause() {
47
+ this.paused = true;
48
+ },
49
+ resume() {
50
+ this.paused = false;
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param {object} torrent
57
+ * @returns {TorrentPool}
58
+ */
59
+ function poolWith(torrent) {
60
+ const pool = Object.create(TorrentPool.prototype);
61
+ pool.torrents = new Map([["source", torrent]]);
62
+ pool.fileUsageByTorrent = new WeakMap();
63
+ return pool;
64
+ }
65
+
66
+ test("a torrent nobody is reading is let go of, and its data is not", () => {
67
+ const torrent = torrentWith(6);
68
+ const pool = poolWith(torrent);
69
+ try {
70
+ // Read once and left, which is the state this is about: a film nobody has
71
+ // opened yet is being set up, and the test below covers that.
72
+ pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
73
+ pool.followTheReaders(torrent);
74
+ pool.fileUsageByTorrent.set(torrent, new Map());
75
+ pool.followTheReaders(torrent);
76
+
77
+ assert.equal(torrent.paused, true, "the swarm was not left");
78
+ assert.equal(torrent._peers.size, 0, "the connections the pause does not close were not let go");
79
+ } finally {
80
+ forgetTorrent(torrent);
81
+ }
82
+ });
83
+
84
+ test("a torrent somebody is reading keeps every connection it has", () => {
85
+ const torrent = torrentWith(6);
86
+ const pool = poolWith(torrent);
87
+ pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
88
+ try {
89
+ pool.followTheReaders(torrent);
90
+
91
+ assert.equal(torrent.paused, false, "a torrent being read was taken out of its swarm");
92
+ assert.equal(torrent._peers.size, 6, "connections were closed while somebody was reading");
93
+ } finally {
94
+ forgetTorrent(torrent);
95
+ }
96
+ });
97
+
98
+ test("a stated window keeps the swarm even with no file held", () => {
99
+ // The register is what the download layer states; a reader that has declared
100
+ // a window but not yet taken a file hold is still a reader.
101
+ const torrent = torrentWith(2);
102
+ const pool = poolWith(torrent);
103
+ demandFor(torrent).register.state({
104
+ claimant: "read-1",
105
+ fileIndex: 0,
106
+ byteStart: 0,
107
+ byteEnd: 1023,
108
+ urgency: 0
109
+ });
110
+ try {
111
+ pool.followTheReaders(torrent);
112
+ assert.equal(torrent.paused, false, "a declared window did not count as somebody reading");
113
+ } finally {
114
+ forgetTorrent(torrent);
115
+ }
116
+ });
117
+
118
+ test("the swarm is rejoined when a reader comes back", () => {
119
+ const torrent = torrentWith(3);
120
+ const pool = poolWith(torrent);
121
+ try {
122
+ pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
123
+ pool.followTheReaders(torrent);
124
+ pool.fileUsageByTorrent.set(torrent, new Map());
125
+ pool.followTheReaders(torrent);
126
+ assert.equal(torrent.paused, true);
127
+
128
+ pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
129
+ pool.followTheReaders(torrent);
130
+ assert.equal(torrent.paused, false, "the next reader was left with a torrent that fetches nothing");
131
+ } finally {
132
+ forgetTorrent(torrent);
133
+ }
134
+ });
135
+
136
+ test("a torrent nobody has read yet is being set up, and is left alone", () => {
137
+ // Field 2026-09-11, and it broke playback outright: a torrent added at
138
+ // 20:57:17 had its first peer at 20:57:18 and was taken out of the swarm at
139
+ // 20:57:21 — while the read of the file's edges was still in flight. Nothing
140
+ // rejoined it, because rejoining waits for a reader and the reader was
141
+ // waiting for the header the swarm was fetching.
142
+ const torrent = torrentWith(103);
143
+ const pool = poolWith(torrent);
144
+ try {
145
+ pool.followTheReaders(torrent);
146
+ assert.equal(torrent.paused, false, "a torrent being opened was taken out of its swarm");
147
+ assert.equal(torrent._peers.size, 103, "and its connections were let go");
148
+ } finally {
149
+ forgetTorrent(torrent);
150
+ }
151
+ });
152
+
153
+ test("once it has been read, leaving is allowed again", () => {
154
+ const torrent = torrentWith(4);
155
+ const pool = poolWith(torrent);
156
+ try {
157
+ // A reader arrives and goes.
158
+ pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
159
+ pool.followTheReaders(torrent);
160
+ assert.equal(torrent.paused, false);
161
+
162
+ pool.fileUsageByTorrent.set(torrent, new Map());
163
+ pool.followTheReaders(torrent);
164
+ assert.equal(torrent.paused, true, "a film somebody left keeps its swarm for nobody");
165
+ } finally {
166
+ forgetTorrent(torrent);
167
+ }
168
+ });
@@ -25,6 +25,7 @@ test("connected and known are separate numbers", () => {
25
25
  const torrent = { wires: [{}, {}], _peersLength: 7, _numQueued: 4 };
26
26
  assert.deepEqual(describeSwarmReach(torrent), {
27
27
  connectedPeers: 2,
28
+ deliveringPeers: 0,
28
29
  knownPeers: 7,
29
30
  queuedPeers: 4
30
31
  });
@@ -46,16 +47,19 @@ test("internals that are gone say nothing rather than breaking the poll", () =>
46
47
  // cost the field and not the answer.
47
48
  assert.deepEqual(describeSwarmReach({ wires: [{}] }), {
48
49
  connectedPeers: 1,
50
+ deliveringPeers: 0,
49
51
  knownPeers: null,
50
52
  queuedPeers: null
51
53
  });
52
54
  assert.deepEqual(describeSwarmReach({}), {
53
55
  connectedPeers: 0,
56
+ deliveringPeers: 0,
54
57
  knownPeers: null,
55
58
  queuedPeers: null
56
59
  });
57
60
  assert.deepEqual(describeSwarmReach(null), {
58
61
  connectedPeers: 0,
62
+ deliveringPeers: 0,
59
63
  knownPeers: null,
60
64
  queuedPeers: null
61
65
  });
@@ -67,6 +71,7 @@ test("internals that are gone say nothing rather than breaking the poll", () =>
67
71
  };
68
72
  assert.deepEqual(describeSwarmReach(throwing), {
69
73
  connectedPeers: 0,
74
+ deliveringPeers: 0,
70
75
  knownPeers: null,
71
76
  queuedPeers: null
72
77
  });
@@ -119,3 +119,30 @@ test("a torrent nobody is reading is not treated as starving", () => {
119
119
  "a reader that IS waiting must still earn unchoke slots"
120
120
  );
121
121
  });
122
+
123
+ test("a torrent short of nothing is not worth uploading for", () => {
124
+ // Field 2026-09-11: the file being watched was complete, its windows all
125
+ // present, and the proxy went on offering 512 KB/s to 596 peers for
126
+ // forty-eight minutes — reading a 4 MB piece off the disk for every 16 KB it
127
+ // sent. Upload is bought with reciprocity, and reciprocity is only worth
128
+ // buying while somebody is still short of bytes.
129
+ const complete = {
130
+ name: "watched to the end",
131
+ hasActiveReader: true,
132
+ hasUnmetDemand: false,
133
+ done: false,
134
+ downloadSpeed: 0,
135
+ wires: [
136
+ { amInterested: true, peerChoking: true },
137
+ { amInterested: true, peerChoking: true },
138
+ { amInterested: true, peerChoking: true }
139
+ ]
140
+ };
141
+ const decided = decideUploadLimit([complete]);
142
+ assert.equal(decided.bytesPerSec, 8 * 1024, "a complete torrent is given the idle floor");
143
+ assert.match(decided.reason, /buys nothing/);
144
+
145
+ // And a torrent that IS short of something still earns its unchoke.
146
+ const short = { ...complete, hasUnmetDemand: true };
147
+ assert.ok(decideUploadLimit([short]).bytesPerSec > 8 * 1024, "a starving torrent still buys reciprocity");
148
+ });