@torrent-tv/proxy 2.64.5 → 2.64.7

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.
@@ -1,7 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import { describeMemory } from "../services/memory-report.js";
4
+ import { describeMemory, readingIsWorthWriting } from "../services/memory-report.js";
5
5
  import {
6
6
  budgetForNewStore,
7
7
  SharedPieceStore,
@@ -154,3 +154,65 @@ test("a store's allowance follows the machine, and never passes its reservation"
154
154
  await rm(directory, { recursive: true, force: true });
155
155
  }
156
156
  });
157
+
158
+ test("the line says how far the heap is from the ceiling it is killed for reaching", () => {
159
+ // The worker died three times for reaching 2240 MB while its own line said
160
+ // "heap=30MB" and nothing said what 30 MB was 30 MB of.
161
+ const line = describeMemory({
162
+ scope: "thread",
163
+ label: "torrent worker",
164
+ process: {
165
+ rss: 2549 * MEGABYTE,
166
+ heapUsed: 1800 * MEGABYTE,
167
+ heapTotal: 1904 * MEGABYTE,
168
+ external: 40 * MEGABYTE,
169
+ arrayBuffers: 20 * MEGABYTE,
170
+ heapLimit: 2240 * MEGABYTE
171
+ },
172
+ stores: []
173
+ });
174
+ assert.match(line, /heap=1800MB\/1904MB of 2240MB allowed/);
175
+
176
+ const withoutLimit = describeMemory({
177
+ scope: "thread",
178
+ label: "torrent worker",
179
+ process: { rss: 0, heapUsed: 12 * MEGABYTE, heapTotal: 20 * MEGABYTE, external: 0, arrayBuffers: 0 },
180
+ stores: []
181
+ });
182
+ assert.match(withoutLimit, /heap=12MB\/20MB external=/, "an unknown ceiling is left out, not printed as zero");
183
+ });
184
+
185
+ test("a reading is written when it moved, or when the silence has gone on long enough", () => {
186
+ const changeBytes = 25 * MEGABYTE;
187
+ const quietMs = 60_000;
188
+ const still = {
189
+ watchedBytes: 100 * MEGABYTE,
190
+ lastWrittenBytes: 100 * MEGABYTE,
191
+ sinceWrittenMs: 1_000,
192
+ changeBytes,
193
+ quietMs
194
+ };
195
+
196
+ assert.equal(readingIsWorthWriting(still), false, "a second of nothing is not worth a line");
197
+ assert.equal(
198
+ readingIsWorthWriting({ ...still, sinceWrittenMs: 60_000 }),
199
+ true,
200
+ "a quiet minute is still written, so a healthy session reads as it always did"
201
+ );
202
+ // The rise that killed the worker: 818 MB to 2203 MB inside one old sample.
203
+ assert.equal(
204
+ readingIsWorthWriting({ ...still, watchedBytes: 130 * MEGABYTE }),
205
+ true,
206
+ "growth of a quarter of a gigabyte cannot wait for the minute to be up"
207
+ );
208
+ assert.equal(
209
+ readingIsWorthWriting({ ...still, watchedBytes: 70 * MEGABYTE }),
210
+ true,
211
+ "memory given back is as interesting as memory taken"
212
+ );
213
+ assert.equal(
214
+ readingIsWorthWriting({ ...still, quietMs: 0 }),
215
+ true,
216
+ "no quiet interval means every reading is written, which is the process scope"
217
+ );
218
+ });
@@ -171,3 +171,21 @@ test("a reader moving its window replaces it instead of accumulating", () => {
171
171
  assert.equal(lru.evictionCandidate(), 30, "the pieces already read are free again");
172
172
  assert.equal(lru.protectedCount, 1);
173
173
  });
174
+
175
+ test("the capacity follows the store's live allowance", () => {
176
+ const lru = new PieceLru(4);
177
+ for (const index of [40, 41]) {
178
+ lru.touch(index);
179
+ }
180
+ assert.equal(lru.isFull(), false, "two of four is not full");
181
+
182
+ // The store's allowance moves with the machine's free memory, and the LRU is
183
+ // told. Before this it kept the capacity it was built with for ever, so
184
+ // `isFull` answered against a number that had stopped being the limit.
185
+ lru.setCapacity(2);
186
+ assert.equal(lru.capacity, 2);
187
+ assert.equal(lru.isFull(), true, "two of two is full");
188
+
189
+ lru.setCapacity(0);
190
+ assert.equal(lru.capacity, 2, "a capacity below one is refused, not obeyed");
191
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @file Room for a piece is an owned reservation, and it comes back every time.
3
+ *
4
+ * Each case here is a defect read out of the field failure of 2026-08-31
5
+ * (`research/worker-heap-oom-2026-08-31.md`, §5), where the torrent worker was
6
+ * found holding reservations nobody could return. They all need the disk tier
7
+ * to be slow, or to fail, at a moment the caller chooses — which is what
8
+ * `options.disk` is for.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
14
+
15
+ const CHUNK = 1024;
16
+
17
+ /**
18
+ * A disk tier the test drives: writes can be held open and either side can be
19
+ * made to fail.
20
+ *
21
+ * @param {{ failWrite?: boolean, failRead?: boolean, holdWrites?: boolean }} [behaviour]
22
+ */
23
+ function makeDisk({ failWrite = false, failRead = false, holdWrites = false } = {}) {
24
+ const stored = new Map();
25
+ /** @type {Array<() => void>} */
26
+ const held = [];
27
+ return {
28
+ stored,
29
+ /** Let every held write finish. */
30
+ releaseWrites() {
31
+ const waiting = held.splice(0, held.length);
32
+ for (const resume of waiting) {
33
+ resume();
34
+ }
35
+ },
36
+ get heldCount() {
37
+ return held.length;
38
+ },
39
+ get size() {
40
+ return stored.size;
41
+ },
42
+ has(index) {
43
+ return stored.has(index);
44
+ },
45
+ async write(index, bytes) {
46
+ if (holdWrites) {
47
+ await new Promise((resolve) => held.push(resolve));
48
+ }
49
+ if (failWrite) {
50
+ throw new Error("the disk refused the write");
51
+ }
52
+ stored.set(index, Buffer.from(bytes));
53
+ },
54
+ async read(index, target) {
55
+ if (failRead) {
56
+ throw new Error("the disk refused the read");
57
+ }
58
+ const bytes = stored.get(index);
59
+ if (!bytes) {
60
+ throw new Error(`Piece ${index} is not on disk.`);
61
+ }
62
+ bytes.copy(target);
63
+ return bytes.length;
64
+ },
65
+ forget(index) {
66
+ stored.delete(index);
67
+ },
68
+ async close() {},
69
+ async destroy() {
70
+ stored.clear();
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * @param {object} [options]
77
+ * @returns {{ store: SharedPieceStore, disk: ReturnType<typeof makeDisk> }}
78
+ */
79
+ function makeStore({ pieces = 2, totalPieces = 16, disk = makeDisk() } = {}) {
80
+ const store = new SharedPieceStore(CHUNK, {
81
+ length: CHUNK * totalPieces,
82
+ memoryBytes: CHUNK * pieces,
83
+ disk,
84
+ name: "test"
85
+ });
86
+ return { store, disk };
87
+ }
88
+
89
+ /**
90
+ * @param {number} index
91
+ * @returns {Buffer}
92
+ */
93
+ function piece(index) {
94
+ const bytes = Buffer.allocUnsafeSlow(CHUNK);
95
+ bytes.fill(index % 256);
96
+ bytes.writeUInt32BE(index, 0);
97
+ return bytes;
98
+ }
99
+
100
+ const put = (store, index) =>
101
+ new Promise((resolve, reject) => store.put(index, piece(index), (error) => (error ? reject(error) : resolve())));
102
+ const get = (store, index) =>
103
+ new Promise((resolve, reject) =>
104
+ store.get(index, undefined, (error, bytes) => (error ? reject(error) : resolve(bytes)))
105
+ );
106
+
107
+ /**
108
+ * Wait until a condition holds, rather than for a chosen interval — a test that
109
+ * sleeps samples, it does not check (roadmap item 54).
110
+ *
111
+ * @param {() => boolean} holds
112
+ * @param {string} what
113
+ * @returns {Promise<void>}
114
+ */
115
+ async function until(holds, what) {
116
+ const deadline = Date.now() + 5_000;
117
+ while (!holds()) {
118
+ if (Date.now() > deadline) {
119
+ throw new Error(`timed out waiting until ${what}`);
120
+ }
121
+ await new Promise((resolve) => setImmediate(resolve));
122
+ }
123
+ }
124
+
125
+ test("a revival that fails on the disk gives its slot back", async () => {
126
+ const disk = makeDisk({ failRead: true });
127
+ const { store } = makeStore({ pieces: 2, disk });
128
+ try {
129
+ await put(store, 0);
130
+ await put(store, 1);
131
+ await put(store, 2); // piece 0 is the least recently used, so it spills
132
+
133
+ await assert.rejects(() => get(store, 0), /refused the read/);
134
+
135
+ assert.equal(
136
+ store.stats().outstanding,
137
+ 0,
138
+ "the slot claimed for the revival was never returned"
139
+ );
140
+ } finally {
141
+ await new Promise((resolve) => store.destroy(resolve));
142
+ }
143
+ });
144
+
145
+ test("a spill that fails gives back the slot the eviction claimed", async () => {
146
+ const disk = makeDisk({ failWrite: true });
147
+ const { store } = makeStore({ pieces: 2, disk });
148
+ try {
149
+ await put(store, 0);
150
+ await put(store, 1);
151
+ await assert.rejects(() => put(store, 2), /refused the write/);
152
+
153
+ assert.equal(store.stats().outstanding, 0, "the eviction kept its reservation");
154
+ } finally {
155
+ await new Promise((resolve) => store.destroy(resolve));
156
+ }
157
+ });
158
+
159
+ test("a claim that cannot be met ends in an error, not in waiting for ever", async () => {
160
+ const disk = makeDisk({ holdWrites: true });
161
+ const { store } = makeStore({ pieces: 2, disk });
162
+ try {
163
+ await put(store, 0);
164
+ await put(store, 1);
165
+
166
+ // Evicts one piece; its write is held, so the store has a spill in flight
167
+ // for as long as this test wants.
168
+ const spilling = put(store, 2);
169
+ await until(() => disk.heldCount > 0, "a write is in flight");
170
+
171
+ // Nothing left that may be evicted: one piece is being written out, the
172
+ // other is pinned. The old rule waited while anything was nominally in
173
+ // flight, which here is for ever.
174
+ store.pin(1);
175
+ await assert.rejects(() => put(store, 3), /nothing moved/);
176
+
177
+ store.unpin(1);
178
+ disk.releaseWrites();
179
+ await spilling;
180
+ } finally {
181
+ disk.releaseWrites();
182
+ await new Promise((resolve) => store.destroy(resolve));
183
+ }
184
+ });
185
+
186
+ test("closing the store fails whoever is waiting for room", async () => {
187
+ const disk = makeDisk({ holdWrites: true });
188
+ const { store } = makeStore({ pieces: 2, disk });
189
+ try {
190
+ await put(store, 0);
191
+ await put(store, 1);
192
+ const spilling = put(store, 2);
193
+ await until(() => disk.heldCount > 0, "a write is in flight");
194
+ store.pin(1);
195
+
196
+ const waiting = put(store, 3);
197
+ await until(() => store.stats().waitedForPins > 0, "the claim is waiting");
198
+
199
+ await new Promise((resolve) => store.close(resolve));
200
+ await assert.rejects(() => waiting, /closed/);
201
+
202
+ store.unpin(1);
203
+ disk.releaseWrites();
204
+ await spilling.catch(() => undefined);
205
+ } finally {
206
+ disk.releaseWrites();
207
+ }
208
+ });
209
+
210
+ test("a piece written back to memory is not resurrected on disk by its own spill", async () => {
211
+ const disk = makeDisk({ holdWrites: true });
212
+ const { store } = makeStore({ pieces: 3, disk });
213
+ try {
214
+ await put(store, 0);
215
+ await put(store, 1);
216
+ await put(store, 2);
217
+
218
+ // Piece 0 leaves memory because the machine's allowance fell; its write is
219
+ // still in flight. The allowance then recovers, so there is room again
220
+ // without waiting for that write.
221
+ store.reviseGrowthCeiling(CHUNK * 2);
222
+ await until(() => disk.heldCount > 0, "the spill of piece 0 is in flight");
223
+ store.reviseGrowthCeiling(CHUNK * 3);
224
+
225
+ // The swarm hands piece 0 back while that write is still going. The store
226
+ // must drop the disk copy AFTER the write has recorded it, not before —
227
+ // `DiskTier.write` adds the index on completion, so an early forget is
228
+ // undone and the next read of piece 0 comes back from before the rewrite.
229
+ let settled = false;
230
+ const rewritten = put(store, 0);
231
+ void rewritten.then(() => {
232
+ settled = true;
233
+ });
234
+ await until(() => store.stats().resident === 3, "piece 0 is back in memory");
235
+ assert.equal(settled, false, "the write finished without waiting for the piece's own spill");
236
+
237
+ disk.releaseWrites();
238
+ await rewritten;
239
+
240
+ assert.equal(disk.has(0), false, "the completing spill put the stale copy back");
241
+ } finally {
242
+ disk.releaseWrites();
243
+ await new Promise((resolve) => store.destroy(resolve));
244
+ }
245
+ });
246
+
247
+ test("a spill that fails while the allowance is lowered is counted, not thrown", async () => {
248
+ const disk = makeDisk({ failWrite: true });
249
+ const { store } = makeStore({ pieces: 4, disk });
250
+ try {
251
+ await put(store, 0);
252
+ await put(store, 1);
253
+ await put(store, 2);
254
+ await put(store, 3);
255
+
256
+ store.reviseGrowthCeiling(CHUNK * 2);
257
+ await until(() => store.stats().spillFailures > 0, "the failed spills are counted");
258
+
259
+ assert.equal(store.stats().outstanding, 0, "lowering the allowance held a reservation");
260
+ } finally {
261
+ await new Promise((resolve) => store.destroy(resolve));
262
+ }
263
+ });
@@ -1071,3 +1071,42 @@ test("a rung is never served from the COPY, whatever height the copy happens to
1071
1071
 
1072
1072
  assert.equal(asked.id, VARIANT_ID, "the re-encoded rung is its own session, not the copy");
1073
1073
  });
1074
+
1075
+ test("a file opened at a position starts its sound THERE, not a look-ahead earlier", async (t) => {
1076
+ const { manager, base, dirPath } = await managerWithBase();
1077
+ t.after(async () => {
1078
+ await manager.disposeAll();
1079
+ await rm(dirPath, { recursive: true, force: true });
1080
+ });
1081
+ base.audioSeparate = true;
1082
+ // The state at the instant a page is opened at a position: nothing seeked,
1083
+ // no segment served, no report from anybody. The read head is then not a
1084
+ // request edge — it is where the session was made — and a browser that has
1085
+ // just opened holds no buffer at all.
1086
+ base.viewerPositionSeconds = null;
1087
+ base.lastRequestedSegment = null;
1088
+ base.netReports.clear();
1089
+ base.progress.startPositionSeconds = 588;
1090
+ manager.getCachedAudioTracks = () => [
1091
+ { index: 0, language: "rus", title: "", isDefault: true },
1092
+ { index: 1, language: "eng", title: "", isDefault: false }
1093
+ ];
1094
+ const created = [];
1095
+ manager.createOrGetSession = async (params) => {
1096
+ created.push(params);
1097
+ const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
1098
+ rendition.audioOnly = true;
1099
+ return { sessionId: VARIANT_ID, session: rendition };
1100
+ };
1101
+
1102
+ await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
1103
+
1104
+ // Field 2026-08-31: this answered 460 for a page opened at 588 — the whole
1105
+ // 120 s look-ahead subtracted from a buffer that did not exist — and the
1106
+ // segment the viewer needed took 38.8 s to appear against the picture's 8.4 s.
1107
+ assert.equal(
1108
+ created[0].startPositionSeconds,
1109
+ 584,
1110
+ "where the viewer opened, less one segment of margin, and nothing else"
1111
+ );
1112
+ });
@@ -22,7 +22,7 @@
22
22
  import assert from "node:assert/strict";
23
23
  import test from "node:test";
24
24
 
25
- import { resolveViewerPosition } from "../services/hls-session-manager.js";
25
+ import { resolveViewerPosition, viewerPositionSource } from "../services/hls-session-manager.js";
26
26
 
27
27
  test("a file opened at a position has its viewer at that position", () => {
28
28
  // Nothing has been seeked and nothing served yet — the state at the instant
@@ -51,3 +51,42 @@ test("with nothing to go on the answer is the beginning", () => {
51
51
  assert.equal(resolveViewerPosition({ seeked: -5, openedAt: -5 }), 0);
52
52
  assert.equal(resolveViewerPosition({ lastRequestedStart: null, openedAt: undefined }), 0);
53
53
  });
54
+
55
+ /**
56
+ * Which of the three answered is a separate question, and the audio start needs
57
+ * it. A seek and a served segment are request edges — the picture is behind
58
+ * them by however deep the viewer's buffer is, which is what the subtraction in
59
+ * `#audioStartSecondsFor` converts. The opening position is not an edge: it is
60
+ * where the session was made, nothing has been asked for since, and a browser
61
+ * that has just opened holds nothing.
62
+ *
63
+ * Field 2026-08-31: a page opened at 588s, no report yet, and the whole 120 s
64
+ * look-ahead was subtracted — the sound started at 460s and its first segment
65
+ * took 38.8 s to appear against the picture's 8.4 s.
66
+ */
67
+ test("the reading says which of the three it came from", () => {
68
+ assert.equal(viewerPositionSource({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }), "seeked");
69
+ assert.equal(viewerPositionSource({ lastRequestedStart: 400, openedAt: 3130 }), "requested");
70
+ assert.equal(viewerPositionSource({ openedAt: 3130 }), "opened");
71
+ assert.equal(viewerPositionSource({}), "none");
72
+ });
73
+
74
+ test("the source agrees with the position, reading for reading", () => {
75
+ const readings = [
76
+ { seeked: 900, lastRequestedStart: 400, openedAt: 3130 },
77
+ { lastRequestedStart: 400, openedAt: 3130 },
78
+ { openedAt: 3130 },
79
+ { seeked: Number.NaN, openedAt: Number.NaN },
80
+ { seeked: -5, openedAt: -5 },
81
+ {}
82
+ ];
83
+ for (const reading of readings) {
84
+ const position = resolveViewerPosition(reading);
85
+ const source = viewerPositionSource(reading);
86
+ assert.equal(
87
+ source === "none",
88
+ position === 0,
89
+ `no source must mean no position, and the other way round: ${JSON.stringify(reading)}`
90
+ );
91
+ }
92
+ });