@torrent-tv/proxy 2.83.0 → 2.83.1
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.
- package/CHANGELOG.md +35 -0
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +11 -2
- package/routes/stream/get.js +74 -3
- package/services/delivery-probe.js +248 -43
- package/services/download/SwarmSelection.js +5 -5
- package/services/download/registry.js +20 -0
- package/services/encode/EncodeRun.js +1 -0
- package/services/encode/encode-exit.js +17 -0
- package/services/files/CompletedFiles.js +276 -0
- package/services/files/piece-from-whole-file.js +118 -0
- package/services/output/cut-grid.js +13 -3
- package/services/piece-store/piece-disk-store.js +72 -2
- package/services/piece-store/shared-piece-store.js +274 -27
- package/services/torrent-pool.js +238 -19
- package/services/torrent-worker/client.js +21 -0
- package/services/torrent-worker/protocol.js +9 -1
- package/services/torrent-worker/worker.js +183 -2
- package/test/completed-files.test.js +115 -0
- package/test/cuts-follow-published-grid.test.js +35 -0
- package/test/delivery-probe.test.js +114 -1
- package/test/encode-exit.test.js +18 -0
- package/test/piece-disk-store.test.js +26 -0
- package/test/piece-from-whole-file.test.js +129 -0
- package/test/piece-store-eviction.test.js +28 -15
- package/test/piece-store-never-refuses.test.js +153 -0
- package/test/piece-store-reservations.test.js +16 -3
- package/test/probe-wedge-certainty.test.js +3 -3
- package/test/shared-piece-store.test.js +27 -13
- package/test/stream-route.test.js +41 -0
- package/test/swarm-follows-readers.test.js +126 -0
- package/test/swarm-reach.test.js +5 -0
- package/test/upload-hurry.test.js +27 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Files downloaded whole are files, and outlive the torrent that fetched
|
|
3
|
+
* them.
|
|
4
|
+
*
|
|
5
|
+
* Asked for in these words on 2026-09-11: as soon as a torrent is fully
|
|
6
|
+
* downloaded, downloading stops, the torrent is deleted, and the artefacts —
|
|
7
|
+
* what was downloaded — stay for as long as they are wanted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { Readable } from "node:stream";
|
|
16
|
+
import { CompletedFiles } from "../services/files/CompletedFiles.js";
|
|
17
|
+
|
|
18
|
+
const INFO_HASH = "abcdef0123456789abcdef0123456789abcdef01";
|
|
19
|
+
|
|
20
|
+
/** @returns {Promise<string>} */
|
|
21
|
+
const directory = () => fs.mkdtemp(path.join(os.tmpdir(), "whole-files-"));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {Buffer} bytes
|
|
25
|
+
* @param {number} [chunk]
|
|
26
|
+
* @returns {() => NodeJS.ReadableStream}
|
|
27
|
+
*/
|
|
28
|
+
const opens = (bytes, chunk = 7) => () => {
|
|
29
|
+
const parts = [];
|
|
30
|
+
for (let at = 0; at < bytes.length; at += chunk) {
|
|
31
|
+
parts.push(bytes.subarray(at, Math.min(at + chunk, bytes.length)));
|
|
32
|
+
}
|
|
33
|
+
return Readable.from(parts);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
test("a file read whole is kept whole, and reads back byte for byte", async () => {
|
|
37
|
+
const root = await directory();
|
|
38
|
+
const files = new CompletedFiles({ root });
|
|
39
|
+
const bytes = Buffer.from("the whole of a small film", "utf8");
|
|
40
|
+
try {
|
|
41
|
+
const kept = await files.keep({ infoHash: INFO_HASH, fileIndex: 3, length: bytes.length, name: "film.mkv", open: opens(bytes) });
|
|
42
|
+
assert.ok(kept, "the file was not kept");
|
|
43
|
+
assert.equal(kept.length, bytes.length);
|
|
44
|
+
|
|
45
|
+
const found = files.find(INFO_HASH, 3);
|
|
46
|
+
assert.deepEqual(found, kept, "what was kept is not what is found");
|
|
47
|
+
assert.deepEqual(await fs.readFile(found.path), bytes, "the bytes came back changed");
|
|
48
|
+
} finally {
|
|
49
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a read that ends early leaves nothing that looks whole", async () => {
|
|
54
|
+
const root = await directory();
|
|
55
|
+
const files = new CompletedFiles({ root });
|
|
56
|
+
const bytes = Buffer.from("half a film", "utf8");
|
|
57
|
+
try {
|
|
58
|
+
// The torrent says the file is longer than what its read produced — the
|
|
59
|
+
// data went away mid-write, which over a torrent is ordinary.
|
|
60
|
+
const kept = await files.keep({
|
|
61
|
+
infoHash: INFO_HASH,
|
|
62
|
+
fileIndex: 0,
|
|
63
|
+
length: bytes.length + 100,
|
|
64
|
+
open: opens(bytes)
|
|
65
|
+
});
|
|
66
|
+
assert.equal(kept, null, "a short file was kept as whole");
|
|
67
|
+
assert.equal(files.find(INFO_HASH, 0), null);
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
(await fs.readdir(path.join(root, INFO_HASH))).filter((entry) => entry !== "manifest.json"),
|
|
70
|
+
[],
|
|
71
|
+
"a partial file was left behind"
|
|
72
|
+
);
|
|
73
|
+
} finally {
|
|
74
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("what a previous life left is taken up, and what is the wrong size is not", async () => {
|
|
79
|
+
const root = await directory();
|
|
80
|
+
const bytes = Buffer.from("a film from the last time this ran", "utf8");
|
|
81
|
+
try {
|
|
82
|
+
const first = new CompletedFiles({ root });
|
|
83
|
+
await first.keep({ infoHash: INFO_HASH, fileIndex: 2, length: bytes.length, name: "film.mkv", open: opens(bytes) });
|
|
84
|
+
// And one that was being written when the process died, under a name that
|
|
85
|
+
// says it is whole — the only way to tell is its size.
|
|
86
|
+
await fs.writeFile(path.join(root, INFO_HASH, "5"), Buffer.alloc(3));
|
|
87
|
+
|
|
88
|
+
const second = new CompletedFiles({ root });
|
|
89
|
+
const adopted = await second.adopt((infoHash, fileIndex) =>
|
|
90
|
+
infoHash === INFO_HASH && fileIndex === 2 ? bytes.length : 999
|
|
91
|
+
);
|
|
92
|
+
assert.equal(adopted, 1, "the whole file was not taken up, or the short one was");
|
|
93
|
+
assert.equal(second.find(INFO_HASH, 2)?.name, "film.mkv", "the name the torrent gave it was lost");
|
|
94
|
+
assert.equal(second.find(INFO_HASH, 5), null, "a file of the wrong size was taken up");
|
|
95
|
+
} finally {
|
|
96
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("forgetting a torrent removes its files and nobody else's", async () => {
|
|
101
|
+
const root = await directory();
|
|
102
|
+
const files = new CompletedFiles({ root });
|
|
103
|
+
const other = "0123456789abcdef0123456789abcdef01234567";
|
|
104
|
+
const bytes = Buffer.from("kept", "utf8");
|
|
105
|
+
try {
|
|
106
|
+
await files.keep({ infoHash: INFO_HASH, fileIndex: 1, length: bytes.length, name: "one.mkv", open: opens(bytes) });
|
|
107
|
+
await files.keep({ infoHash: other, fileIndex: 1, length: bytes.length, name: "two.mkv", open: opens(bytes) });
|
|
108
|
+
|
|
109
|
+
await files.forget(INFO_HASH);
|
|
110
|
+
assert.equal(files.find(INFO_HASH, 1), null);
|
|
111
|
+
assert.ok(files.find(other, 1), "another torrent's file went with it");
|
|
112
|
+
} finally {
|
|
113
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import assert from "node:assert/strict";
|
|
18
|
+
import { computeCutGrid } from "../services/output/cut-grid.js";
|
|
18
19
|
import { Timeline } from "../services/output/Timeline.js";
|
|
19
20
|
import test from "node:test";
|
|
20
21
|
|
|
@@ -71,3 +72,37 @@ test("a session that published no grid falls back to the live one", () => {
|
|
|
71
72
|
const session = { id: "no-playlist", timeline: new Timeline({ boundaries: [...CORRECTED], published: [], cutGrid: "uniform" }) };
|
|
72
73
|
assert.deepEqual(manager.publishedGridFor(session), CORRECTED);
|
|
73
74
|
});
|
|
75
|
+
|
|
76
|
+
test("no segment is left shorter than a segment at the end of the film", () => {
|
|
77
|
+
// The field case of 2026-09-11, to the millisecond: a 54-minute film whose
|
|
78
|
+
// last keyframe sits 160 ms before the end. Taking it as a cut leaves a
|
|
79
|
+
// segment of 0.16 s — one the sound has no data in at all, which is how one
|
|
80
|
+
// output came to have 542 segments and the other 541.
|
|
81
|
+
const total = 3246.12;
|
|
82
|
+
const keyframes = [];
|
|
83
|
+
for (let at = 0; at < total - 1; at += 2) {
|
|
84
|
+
keyframes.push(Number(at.toFixed(3)));
|
|
85
|
+
}
|
|
86
|
+
keyframes.push(3245.96);
|
|
87
|
+
|
|
88
|
+
const grid = computeCutGrid({
|
|
89
|
+
useKeyframeGrid: true,
|
|
90
|
+
durationSeconds: total,
|
|
91
|
+
segDur: 6,
|
|
92
|
+
keyframeTimes: keyframes,
|
|
93
|
+
startTime: 0
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const last = grid.boundaries[grid.boundaries.length - 1];
|
|
97
|
+
const before = grid.boundaries[grid.boundaries.length - 2];
|
|
98
|
+
assert.equal(last, total, "the film still ends where it ends");
|
|
99
|
+
assert.ok(
|
|
100
|
+
last - before >= 6,
|
|
101
|
+
`the last segment is ${(last - before).toFixed(3)}s, shorter than the 6s every other cut is held to`
|
|
102
|
+
);
|
|
103
|
+
// And the rule is the one every cut obeys, so no segment anywhere is short.
|
|
104
|
+
for (let index = 1; index < grid.boundaries.length; index += 1) {
|
|
105
|
+
const span = grid.boundaries[index] - grid.boundaries[index - 1];
|
|
106
|
+
assert.ok(span >= 6 - 0.05, `segment ${index - 1} lasts ${span.toFixed(3)}s`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
allowedGap,
|
|
6
|
+
probeWedgeIsCertain,
|
|
7
|
+
readProbeState,
|
|
8
|
+
PROBE_INTERVAL_MS,
|
|
9
|
+
UNRELIABLE_LABEL
|
|
10
|
+
} from "../services/delivery-probe.js";
|
|
5
11
|
|
|
6
12
|
const ORDERED = ["proxy", "proxy-control"];
|
|
7
13
|
const ALL = [...ORDERED, UNRELIABLE_LABEL];
|
|
@@ -278,3 +284,110 @@ test("a peer that reports no loop delay is judged exactly as before", () => {
|
|
|
278
284
|
assert.doesNotMatch(reading.detail, /peerLoopLag=/);
|
|
279
285
|
assert.doesNotMatch(reading.detail, /peerTab=/);
|
|
280
286
|
});
|
|
287
|
+
|
|
288
|
+
test("a quiet stretch shorter than a legitimate report is not a wedge", () => {
|
|
289
|
+
// Field 2026-09-11: two captures of 180 s each, triggered at `wedged 1s` and
|
|
290
|
+
// `wedged 2s`, on a connection with `rtt=5ms`, every queue at 0 B and the
|
|
291
|
+
// viewer watching. In its first minutes a connection has shown no healthy gap
|
|
292
|
+
// at all, and the floor under "longer than anything healthy" was a single
|
|
293
|
+
// probe interval — 500 ms — so any quiet moment beat it.
|
|
294
|
+
// Both cadences plus the crossing: a probe sent just after the peer composed
|
|
295
|
+
// a report shows up only in the next one.
|
|
296
|
+
const legitimateReportMs = 500 + 500 + 12 + 0;
|
|
297
|
+
assert.equal(
|
|
298
|
+
probeWedgeIsCertain({ stuckForMs: 1000, longestHealthySeenGapMs: 0, legitimateReportMs }).isCertain,
|
|
299
|
+
false,
|
|
300
|
+
"a second of quiet is shorter than one legitimate report and says nothing"
|
|
301
|
+
);
|
|
302
|
+
// And what the detector exists for is untouched: a counter frozen for
|
|
303
|
+
// minutes is far past any report this connection could legitimately owe.
|
|
304
|
+
assert.equal(
|
|
305
|
+
probeWedgeIsCertain({ stuckForMs: 60_000, longestHealthySeenGapMs: 0, legitimateReportMs }).isCertain,
|
|
306
|
+
true
|
|
307
|
+
);
|
|
308
|
+
// A peer whose event loop is late is owed that time as well.
|
|
309
|
+
assert.equal(
|
|
310
|
+
probeWedgeIsCertain({
|
|
311
|
+
stuckForMs: 3000,
|
|
312
|
+
longestHealthySeenGapMs: 0,
|
|
313
|
+
legitimateReportMs: 500 + 12 + 5000
|
|
314
|
+
}).isCertain,
|
|
315
|
+
false,
|
|
316
|
+
"a peer frozen for five seconds cannot answer sooner than that"
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("what is behind is judged in time, not in probes", () => {
|
|
321
|
+
// The same probe goes down every channel including the one carrying the film,
|
|
322
|
+
// and SCTP schedules per association — so a probe waits behind queued video
|
|
323
|
+
// exactly as a segment does. Counting outstanding probes therefore measures
|
|
324
|
+
// the queue, not the association, which is why the count is only printed now.
|
|
325
|
+
const behind = { proxy: 800, "proxy-control": 800, "proxy-fast": 800 };
|
|
326
|
+
const mayWait = { proxy: 2000, "proxy-control": 2000, "proxy-fast": 2000 };
|
|
327
|
+
|
|
328
|
+
// Far behind in probes, well within the time its own queue is allowed.
|
|
329
|
+
const healthy = readProbeState(
|
|
330
|
+
state(
|
|
331
|
+
{ proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
|
|
332
|
+
{ behindMs: behind, allowedWaitMs: mayWait }
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
assert.equal(healthy.verdict, "flowing");
|
|
336
|
+
assert.match(healthy.detail, /800ms of 2000ms/, "both readings belong in the line");
|
|
337
|
+
|
|
338
|
+
// The same gaps, the same allowance, and the probe is older than the queue
|
|
339
|
+
// could account for: that is the association and not the burst.
|
|
340
|
+
const wedged = readProbeState(
|
|
341
|
+
state(
|
|
342
|
+
{ proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
|
|
343
|
+
{
|
|
344
|
+
behindMs: { proxy: 9000, "proxy-control": 9000, "proxy-fast": 9000 },
|
|
345
|
+
allowedWaitMs: mayWait
|
|
346
|
+
}
|
|
347
|
+
)
|
|
348
|
+
);
|
|
349
|
+
assert.equal(wedged.verdict, "association-stopped");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("with no send time recorded the count still decides", () => {
|
|
353
|
+
// A probe older than the history kept, or a connection that has just begun:
|
|
354
|
+
// the reading is absent rather than wrong, and the old comparison stands.
|
|
355
|
+
const { verdict } = readProbeState(
|
|
356
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { behindMs: {}, allowedWaitMs: {} })
|
|
357
|
+
);
|
|
358
|
+
assert.equal(verdict, "association-stopped");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("the measured one-way time is preferred over the age of the report", () => {
|
|
362
|
+
// With the clocks reconciled, the proxy knows how long the probe itself took
|
|
363
|
+
// to reach the peer. The age of the newest reported probe is the same thing
|
|
364
|
+
// plus the peer's reporting cadence and the way back — so where both are
|
|
365
|
+
// known, the measurement wins and its allowance carries neither.
|
|
366
|
+
const { verdict } = readProbeState(
|
|
367
|
+
state(
|
|
368
|
+
{ proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
|
|
369
|
+
{
|
|
370
|
+
// The age says far behind against its allowance...
|
|
371
|
+
behindMs: { proxy: 9000, "proxy-control": 9000, "proxy-fast": 9000 },
|
|
372
|
+
allowedWaitMs: { proxy: 2000, "proxy-control": 2000, "proxy-fast": 2000 },
|
|
373
|
+
// ...while the probe itself took 300 ms of the 900 its queue may take.
|
|
374
|
+
oneWayMs: { proxy: 300, "proxy-control": 300, "proxy-fast": 300 },
|
|
375
|
+
allowedOneWayMs: { proxy: 900, "proxy-control": 900, "proxy-fast": 900 }
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
);
|
|
379
|
+
assert.equal(verdict, "flowing");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("a one-way time past what the queue can account for is the association", () => {
|
|
383
|
+
const { verdict } = readProbeState(
|
|
384
|
+
state(
|
|
385
|
+
{ proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
|
|
386
|
+
{
|
|
387
|
+
oneWayMs: { proxy: 12_000, "proxy-control": 12_000, "proxy-fast": 12_000 },
|
|
388
|
+
allowedOneWayMs: { proxy: 900, "proxy-control": 900, "proxy-fast": 900 }
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
);
|
|
392
|
+
assert.equal(verdict, "association-stopped");
|
|
393
|
+
});
|
package/test/encode-exit.test.js
CHANGED
|
@@ -91,3 +91,21 @@ test("every combination answers, and no input produces undefined", () => {
|
|
|
91
91
|
}
|
|
92
92
|
assert.ok(Object.values(ENCODE_EXIT).includes(classifyEncodeExit()));
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
test("a run that made nothing has not finished, whatever its exit code", () => {
|
|
96
|
+
// Field 2026-09-11: a run given #541..#541 was handed a start later than its
|
|
97
|
+
// own end, wrote 190 bytes that are not a fragment, and exited zero. Making
|
|
98
|
+
// no segment and being unable to read the directory were the same `null`, and
|
|
99
|
+
// the second reading — "cannot be contradicted, so it stands" — was applied
|
|
100
|
+
// to the first.
|
|
101
|
+
assert.equal(
|
|
102
|
+
classifyEncodeExit({ code: 0, producedThrough: null, producedCount: 0, lastSegmentIndex: 541 }),
|
|
103
|
+
ENCODE_EXIT.SHORT
|
|
104
|
+
);
|
|
105
|
+
// And the case that reading was written for still stands: nothing readable on
|
|
106
|
+
// disk is not a claim that nothing was made.
|
|
107
|
+
assert.equal(
|
|
108
|
+
classifyEncodeExit({ code: 0, producedThrough: null, producedCount: null, lastSegmentIndex: 623 }),
|
|
109
|
+
ENCODE_EXIT.COMPLETE
|
|
110
|
+
);
|
|
111
|
+
});
|
|
@@ -353,3 +353,29 @@ test("when there is no room, what is behind the readers goes before what is ahea
|
|
|
353
353
|
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
354
354
|
}
|
|
355
355
|
});
|
|
356
|
+
|
|
357
|
+
test("a store takes up the pieces a previous life left in its directory", async () => {
|
|
358
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "adopt-"));
|
|
359
|
+
try {
|
|
360
|
+
const first = new PieceDiskStore({ directory: root, name: "film.pieces", chunkLength: 1024 });
|
|
361
|
+
await first.write(7, Buffer.alloc(1024, 7));
|
|
362
|
+
await first.write(9, Buffer.alloc(1024, 9));
|
|
363
|
+
await first.close();
|
|
364
|
+
|
|
365
|
+
// A torrent torn down and added again gets a NEW store over the same
|
|
366
|
+
// directory. Before 2026-09-11 its index started empty, so every piece it
|
|
367
|
+
// in fact had read as missing and the film was downloaded a second time
|
|
368
|
+
// while the first copy sat beside it.
|
|
369
|
+
const second = new PieceDiskStore({ directory: root, name: "film.pieces", chunkLength: 1024 });
|
|
370
|
+
assert.equal(second.size, 2, "the pieces already on disk were not taken up");
|
|
371
|
+
assert.equal(second.bytes, 2048, "nor were their bytes counted");
|
|
372
|
+
assert.ok(second.has(7) && second.has(9));
|
|
373
|
+
|
|
374
|
+
const target = Buffer.alloc(1024);
|
|
375
|
+
await second.read(7, target);
|
|
376
|
+
assert.ok(target.equals(Buffer.alloc(1024, 7)), "and they read back as themselves");
|
|
377
|
+
await second.destroy();
|
|
378
|
+
} finally {
|
|
379
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A piece read out of the files it was assembled into.
|
|
3
|
+
*
|
|
4
|
+
* Without this a whole file is a second copy of bytes the piece store also
|
|
5
|
+
* holds, and neither copy can be dropped. With it the spilled copy is a
|
|
6
|
+
* duplicate and can go — one episode on the field host of 2026-09-11 was
|
|
7
|
+
* 1417 MB of segments beside 1424 MB of spilled pieces — and a torrent can be
|
|
8
|
+
* destroyed and added again without fetching a byte.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import test from "node:test";
|
|
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";
|
|
16
|
+
import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../services/files/piece-from-whole-file.js";
|
|
17
|
+
|
|
18
|
+
const PIECE = 16;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Two files laid end to end, as a torrent lays them out, with a piece straddling
|
|
22
|
+
* the boundary between them.
|
|
23
|
+
*
|
|
24
|
+
* @returns {Promise<{ root: string, files: object[], length: number, bytes: Buffer }>}
|
|
25
|
+
*/
|
|
26
|
+
async function twoFiles() {
|
|
27
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "piece-of-whole-"));
|
|
28
|
+
const first = Buffer.alloc(20, 1);
|
|
29
|
+
const second = Buffer.alloc(24, 2);
|
|
30
|
+
await fs.writeFile(path.join(root, "0"), first);
|
|
31
|
+
await fs.writeFile(path.join(root, "1"), second);
|
|
32
|
+
return {
|
|
33
|
+
root,
|
|
34
|
+
files: [
|
|
35
|
+
{ offset: 0, length: first.length },
|
|
36
|
+
{ offset: first.length, length: second.length }
|
|
37
|
+
],
|
|
38
|
+
length: first.length + second.length,
|
|
39
|
+
bytes: Buffer.concat([first, second])
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} root
|
|
45
|
+
* @param {number[]} whole - Which file indexes this proxy holds whole.
|
|
46
|
+
* @returns {(fileIndex: number) => { path: string, length: number } | null}
|
|
47
|
+
*/
|
|
48
|
+
const holds = (root, whole) => (fileIndex) =>
|
|
49
|
+
whole.includes(fileIndex) ? { path: path.join(root, String(fileIndex)), length: 0 } : null;
|
|
50
|
+
|
|
51
|
+
test("a piece inside one file comes back byte for byte", async () => {
|
|
52
|
+
const { root, files, length, bytes } = await twoFiles();
|
|
53
|
+
try {
|
|
54
|
+
const piece = await pieceFromWholeFiles({
|
|
55
|
+
index: 0,
|
|
56
|
+
pieceLength: PIECE,
|
|
57
|
+
length,
|
|
58
|
+
files,
|
|
59
|
+
wholeFileAt: holds(root, [0, 1])
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(piece, bytes.subarray(0, PIECE));
|
|
62
|
+
} finally {
|
|
63
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a piece straddling two files is stitched from both", async () => {
|
|
68
|
+
const { root, files, length, bytes } = await twoFiles();
|
|
69
|
+
try {
|
|
70
|
+
// Piece 1 covers bytes 16..31: four from the first file, twelve from the
|
|
71
|
+
// second.
|
|
72
|
+
const piece = await pieceFromWholeFiles({
|
|
73
|
+
index: 1,
|
|
74
|
+
pieceLength: PIECE,
|
|
75
|
+
length,
|
|
76
|
+
files,
|
|
77
|
+
wholeFileAt: holds(root, [0, 1])
|
|
78
|
+
});
|
|
79
|
+
assert.deepEqual(piece, bytes.subarray(PIECE, PIECE * 2));
|
|
80
|
+
} finally {
|
|
81
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the last piece is read to the end of the torrent and no further", async () => {
|
|
86
|
+
const { root, files, length, bytes } = await twoFiles();
|
|
87
|
+
try {
|
|
88
|
+
// 44 bytes in pieces of 16: the last piece is 12 long.
|
|
89
|
+
const piece = await pieceFromWholeFiles({
|
|
90
|
+
index: 2,
|
|
91
|
+
pieceLength: PIECE,
|
|
92
|
+
length,
|
|
93
|
+
files,
|
|
94
|
+
wholeFileAt: holds(root, [0, 1])
|
|
95
|
+
});
|
|
96
|
+
assert.equal(piece.length, 12);
|
|
97
|
+
assert.deepEqual(piece, bytes.subarray(32));
|
|
98
|
+
} finally {
|
|
99
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a piece any part of which is not held whole is refused, not half read", async () => {
|
|
104
|
+
const { root, files, length } = await twoFiles();
|
|
105
|
+
try {
|
|
106
|
+
// Only the first file is here; piece 1 straddles both. Half a piece is
|
|
107
|
+
// worse than none: the layer above would hash it and mark it bad.
|
|
108
|
+
const piece = await pieceFromWholeFiles({
|
|
109
|
+
index: 1,
|
|
110
|
+
pieceLength: PIECE,
|
|
111
|
+
length,
|
|
112
|
+
files,
|
|
113
|
+
wholeFileAt: holds(root, [0])
|
|
114
|
+
});
|
|
115
|
+
assert.equal(piece, null);
|
|
116
|
+
assert.equal(
|
|
117
|
+
pieceIsInWholeFiles({ index: 1, pieceLength: PIECE, length, files, wholeFileAt: holds(root, [0]) }),
|
|
118
|
+
false
|
|
119
|
+
);
|
|
120
|
+
// And one wholly inside the file that IS held is both readable and known to
|
|
121
|
+
// be a duplicate of what is on the spill.
|
|
122
|
+
assert.equal(
|
|
123
|
+
pieceIsInWholeFiles({ index: 0, pieceLength: PIECE, length, files, wholeFileAt: holds(root, [0]) }),
|
|
124
|
+
true
|
|
125
|
+
);
|
|
126
|
+
} finally {
|
|
127
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
@@ -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
|
-
|
|
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
|
|
256
|
-
//
|
|
257
|
-
//
|
|
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.
|
|
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
|
|
379
|
-
// it again writes bytes that are already there, byte for
|
|
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.
|
|
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
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// the
|
|
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.
|
|
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 });
|