@torrent-tv/proxy 2.64.4 → 2.64.6

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
+ });