@torrent-tv/proxy 2.9.88 → 2.9.90

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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @file What a read asks the torrent to download, and what it gives back.
3
+ *
4
+ * A read used to select its whole requested range and never deselect it.
5
+ * ffmpeg opens its input as `bytes <position>-<EOF>`, so the first read of a
6
+ * session claimed the entire file and marked every piece of it critical — and
7
+ * the claim outlived the read, which is abandoned a second later when ffmpeg
8
+ * seeks. Nothing after that could outrank it: measured on a 4.7 GB film, a seek
9
+ * to 89.1% waited 93 s while the swarm fetched 2.47 GB in file order.
10
+ *
11
+ * Now a read holds a moving window and returns it when it ends. These tests pin
12
+ * the three properties that matter: the claim is bounded, it is given back, and
13
+ * several readers add up instead of overwriting each other.
14
+ */
15
+
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { EventEmitter } from "node:events";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import fs from "node:fs/promises";
22
+ import { readFragments, readWindowFor } from "../services/torrent-worker/piece-reader.js";
23
+ import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
24
+
25
+ const PIECE = 1024;
26
+ // The production window is 32 MB against 8 MiB pieces — four of them. Sized
27
+ // here in pieces so the test does not depend on either constant.
28
+ const WINDOW_PIECES = 4;
29
+
30
+ /**
31
+ * A torrent that records every selection call instead of downloading anything.
32
+ *
33
+ * @param {{ pieceCount: number, present?: (index: number) => boolean }} shape
34
+ */
35
+ async function recordingTorrent({ pieceCount, present = () => true }) {
36
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "read-window-test-"));
37
+ const totalLength = pieceCount * PIECE;
38
+ const store = new SharedPieceStore(PIECE, {
39
+ length: totalLength,
40
+ memoryBytes: 64 * PIECE,
41
+ path: directory,
42
+ name: "test"
43
+ });
44
+ for (let index = 0; index < pieceCount; index += 1) {
45
+ await new Promise((resolve, reject) => {
46
+ store.put(index, Buffer.alloc(PIECE, index % 251), (error) => (error ? reject(error) : resolve()));
47
+ });
48
+ }
49
+
50
+ /** @type {Array<{ call: string, from: number, to: number, stream?: boolean }>} */
51
+ const calls = [];
52
+ /** Live stream selections, as WebTorrent counts them: exact bounds, duplicates allowed. */
53
+ const held = [];
54
+
55
+ const torrent = Object.assign(new EventEmitter(), {
56
+ pieceLength: PIECE,
57
+ store,
58
+ bitfield: { get: (index) => present(index) },
59
+ files: [{ offset: 0, length: totalLength, name: "file.bin" }],
60
+ _critical: [],
61
+ calls,
62
+ held,
63
+ _select(from, to, _priority, _notify, isStreamSelection) {
64
+ calls.push({ call: "select", from, to, stream: isStreamSelection === true });
65
+ held.push(`${from}-${to}`);
66
+ },
67
+ _deselect(from, to, isStreamSelection) {
68
+ calls.push({ call: "deselect", from, to, stream: isStreamSelection === true });
69
+ const at = held.indexOf(`${from}-${to}`);
70
+ if (at >= 0) {
71
+ held.splice(at, 1);
72
+ }
73
+ },
74
+ critical(from, to) {
75
+ calls.push({ call: "critical", from, to });
76
+ for (let index = from; index <= to; index += 1) {
77
+ this._critical[index] = true;
78
+ }
79
+ }
80
+ });
81
+
82
+ return { torrent, store, directory };
83
+ }
84
+
85
+ /** Read a range to the end, releasing every fragment. */
86
+ async function drain(torrent, start, end) {
87
+ for await (const fragment of readFragments({
88
+ torrent,
89
+ fileIndex: 0,
90
+ start,
91
+ end,
92
+ cancellation: { isCancelled: () => false }
93
+ })) {
94
+ fragment.release();
95
+ }
96
+ }
97
+
98
+ test("the window is bounded and clamped to the end of the read", () => {
99
+ assert.deepEqual(readWindowFor({ pieceIndex: 10, lastPiece: 999, windowPieces: 4 }), { from: 10, to: 13 });
100
+ assert.deepEqual(
101
+ readWindowFor({ pieceIndex: 997, lastPiece: 999, windowPieces: 4 }),
102
+ { from: 997, to: 999 },
103
+ "the window never reaches past the range the reader was given"
104
+ );
105
+ assert.deepEqual(
106
+ readWindowFor({ pieceIndex: 5, lastPiece: 999, windowPieces: 0 }),
107
+ { from: 5, to: 5 },
108
+ "a degenerate size still asks for the piece under the head"
109
+ );
110
+ });
111
+
112
+ test("an open-ended read does not claim the whole file at once", async () => {
113
+ // 8000 pieces of 1 KB — far more than the 32 MB window, so a read to the end
114
+ // of the file is exactly the ffmpeg case.
115
+ const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
116
+ try {
117
+ // Read only the first two pieces, but ask as ffmpeg does: to the last byte.
118
+ const iterator = readFragments({
119
+ torrent,
120
+ fileIndex: 0,
121
+ start: 0,
122
+ end: 8000 * PIECE - 1,
123
+ cancellation: { isCancelled: () => false },
124
+ windowBytes: WINDOW_PIECES * PIECE
125
+ });
126
+ const first = await iterator.next();
127
+ first.value.release();
128
+
129
+ const selects = torrent.calls.filter((entry) => entry.call === "select");
130
+ assert.ok(selects.length >= 1, "the reader claimed nothing");
131
+ const claimed = selects[0].to - selects[0].from + 1;
132
+ assert.equal(
133
+ claimed,
134
+ WINDOW_PIECES,
135
+ `the reader claimed ${claimed} pieces of the file instead of its window`
136
+ );
137
+ assert.equal(selects[0].stream, true, "the claim must be a stream selection, so it can be counted");
138
+
139
+ await iterator.return();
140
+ } finally {
141
+ store.destroy(() => undefined);
142
+ await fs.rm(directory, { recursive: true, force: true });
143
+ }
144
+ });
145
+
146
+ test("a finished read leaves nothing selected", async () => {
147
+ const { torrent, store, directory } = await recordingTorrent({ pieceCount: 40 });
148
+ try {
149
+ await drain(torrent, 0, 40 * PIECE - 1);
150
+ assert.deepEqual(torrent.held, [], "the read kept its claim after finishing");
151
+ } finally {
152
+ store.destroy(() => undefined);
153
+ await fs.rm(directory, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("an abandoned read leaves nothing selected", async () => {
158
+ const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
159
+ try {
160
+ const iterator = readFragments({
161
+ torrent,
162
+ fileIndex: 0,
163
+ start: 0,
164
+ end: 8000 * PIECE - 1,
165
+ cancellation: { isCancelled: () => false },
166
+ windowBytes: WINDOW_PIECES * PIECE
167
+ });
168
+ const first = await iterator.next();
169
+ first.value.release();
170
+ // What ffmpeg does to its opening read the moment it seeks.
171
+ await iterator.return();
172
+
173
+ assert.deepEqual(torrent.held, [], "an abandoned read kept its claim forever");
174
+ } finally {
175
+ store.destroy(() => undefined);
176
+ await fs.rm(directory, { recursive: true, force: true });
177
+ }
178
+ });
179
+
180
+ test("two readers add up, and one leaving takes only its own window", async () => {
181
+ const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
182
+ try {
183
+ const head = readFragments({
184
+ torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
185
+ cancellation: { isCancelled: () => false }
186
+ });
187
+ const tail = readFragments({
188
+ torrent, fileIndex: 0, start: 4000 * PIECE, end: 8000 * PIECE - 1,
189
+ cancellation: { isCancelled: () => false }
190
+ });
191
+ (await head.next()).value.release();
192
+ (await tail.next()).value.release();
193
+
194
+ assert.equal(torrent.held.length, 2, "the two readers did not both hold a window");
195
+ const [headWindow, tailWindow] = torrent.held;
196
+
197
+ await tail.return();
198
+ assert.deepEqual(
199
+ torrent.held,
200
+ [headWindow],
201
+ `leaving reader took the wrong window (expected to remove ${tailWindow})`
202
+ );
203
+
204
+ await head.return();
205
+ assert.deepEqual(torrent.held, []);
206
+ } finally {
207
+ store.destroy(() => undefined);
208
+ await fs.rm(directory, { recursive: true, force: true });
209
+ }
210
+ });
211
+
212
+ test("criticality marks the piece being waited for, not the whole range", async () => {
213
+ // Nothing is present, so the reader blocks on its first piece and marks it.
214
+ let arrived = false;
215
+ const { torrent, store, directory } = await recordingTorrent({
216
+ pieceCount: 8000,
217
+ present: () => arrived
218
+ });
219
+ try {
220
+ const iterator = readFragments({
221
+ torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
222
+ cancellation: { isCancelled: () => false }
223
+ });
224
+ const pending = iterator.next();
225
+ await new Promise((resolve) => setImmediate(resolve));
226
+
227
+ const criticals = torrent.calls.filter((entry) => entry.call === "critical");
228
+ assert.equal(criticals.length, 1);
229
+ assert.ok(
230
+ criticals[0].to - criticals[0].from + 1 <= 3,
231
+ `marked ${criticals[0].to - criticals[0].from + 1} pieces critical; the signal means "blocked here now"`
232
+ );
233
+
234
+ arrived = true;
235
+ torrent.emit("verified", 0);
236
+ (await pending).value.release();
237
+ await iterator.return();
238
+ } finally {
239
+ store.destroy(() => undefined);
240
+ await fs.rm(directory, { recursive: true, force: true });
241
+ }
242
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @file A held segment request must not outlive the position it was made for.
3
+ *
4
+ * hls.js keeps ONE fragment load outstanding. So a request being held for a
5
+ * segment blocks the request for wherever the viewer has just moved to, and our
6
+ * route held each one for 60 s. Measured 2026-08-04: a backward seek into fully
7
+ * downloaded data waited 57 s for a held request for `#609` to run out its
8
+ * timer, and the segment the viewer actually wanted was then served in 15 ms.
9
+ *
10
+ * `research/hls-seek-prior-art-2026-08-02.md` prescribed this guard from
11
+ * `hls-media-server` — one outstanding wait per session — and it was never
12
+ * built.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { handleTranscodeSessionFileGet } from "../routes/transcode/session-file/get.js";
18
+
19
+ /**
20
+ * A reply that records what the route answered.
21
+ *
22
+ * @returns {{ reply: object, sent: { code: number, headers: Record<string, string>, body: unknown } }}
23
+ */
24
+ function recordingReply() {
25
+ const sent = { code: 200, headers: {}, body: undefined };
26
+ const reply = {
27
+ code(value) {
28
+ sent.code = value;
29
+ return reply;
30
+ },
31
+ header(name, value) {
32
+ sent.headers[name.toLowerCase()] = String(value);
33
+ return reply;
34
+ },
35
+ send(body) {
36
+ sent.body = body;
37
+ return reply;
38
+ }
39
+ };
40
+ return { reply, sent };
41
+ }
42
+
43
+ const request = (fileName) => ({
44
+ params: { sessionId: "11111111-2222-3333-4444-555555555555", fileName },
45
+ raw: { on() {}, off() {} }
46
+ });
47
+
48
+ test("a seek releases a held segment request instead of running out the hold", async () => {
49
+ let epoch = 0;
50
+ let polls = 0;
51
+ const hlsSessionManager = {
52
+ nextRequestSeq: () => 1,
53
+ seekEpoch: () => epoch,
54
+ async getFileStream() {
55
+ polls += 1;
56
+ // The viewer moves while this request is being held.
57
+ if (polls === 2) {
58
+ epoch += 1;
59
+ }
60
+ return { kind: "warming-up" };
61
+ }
62
+ };
63
+
64
+ const { reply, sent } = recordingReply();
65
+ const startedAt = Date.now();
66
+ await handleTranscodeSessionFileGet(request("segment-00609.mp4"), reply, { hlsSessionManager });
67
+ const heldMs = Date.now() - startedAt;
68
+
69
+ assert.equal(sent.code, 503, "the player must get a retryable answer, not a stream");
70
+ assert.equal(sent.headers["retry-after"], "0", "nothing to wait for — this segment is not being watched");
71
+ assert.ok(heldMs < 5_000, `the request was held ${heldMs}ms after the seek`);
72
+ });
73
+
74
+ test("without a seek the request is still held until the segment appears", async () => {
75
+ let polls = 0;
76
+ const hlsSessionManager = {
77
+ nextRequestSeq: () => 1,
78
+ seekEpoch: () => 7,
79
+ async getFileStream() {
80
+ polls += 1;
81
+ if (polls < 3) {
82
+ return { kind: "warming-up" };
83
+ }
84
+ return { kind: "ok", contentType: "video/mp4", stream: "bytes", isPlaylist: false };
85
+ }
86
+ };
87
+
88
+ const { reply, sent } = recordingReply();
89
+ await handleTranscodeSessionFileGet(request("segment-00610.mp4"), reply, { hlsSessionManager });
90
+
91
+ assert.equal(sent.body, "bytes", "a segment that arrives late must still be served");
92
+ assert.equal(sent.headers["content-type"], "video/mp4");
93
+ });