@torrent-tv/proxy 2.71.0 → 2.72.0
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 +16 -0
- package/bin/cli.js +8 -0
- package/package.json +1 -1
- package/server.js +4 -0
- package/services/download/SwarmSelection.js +55 -1
- package/services/health-collector.js +37 -3
- package/services/piece-store/piece-lru.js +43 -0
- package/services/piece-store/shared-piece-store.js +230 -11
- package/services/playback-planner.js +22 -1
- package/services/torrent-worker/piece-reader.js +6 -8
- package/services/tunnel-client.js +27 -0
- package/test/health-metrics.test.js +37 -0
- package/test/piece-store-eviction.test.js +98 -10
- package/test/piece-store-slow-disk.test.js +114 -0
- package/test/swarm-selection.test.js +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 2.72.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: The proxy was killed by the machine's out-of-memory killer at 4.37 GB, twenty minutes after 2.71.0 went out, and the cause was 2.71.0's own budget. A piece being written out to disk leaves the store's count of what it holds the moment the eviction begins, while its memory stays held until the write — which reads from that very block — has finished. The allowance counted resident pieces, so a block held by a pending write was counted nowhere: every admission turned one resident block into one held by the disk and took a fresh block for the arrival, and memory in use rose by one block per admission for as long as the disk was behind. It was behind by a factor of two: 233 evictions started against about 119 writes completed in the same minute. The store reported 203 blocks held with THREE pieces resident against 68 MB allowed. The allowance now bounds blocks in use — resident, reserved, and held by writes that have not finished — so a full store waits for the disk instead of evicting another piece, using the wait and the wake that were already there.
|
|
4
|
+
- **Fix**: And the reason there were so many evictions: 2.71.0 made the allowance equal to what the readers ask for, exactly. `6 reader(s) want 23 piece(s) of 23 the store may hold` — no free place ever exists, so every arriving piece must evict a wanted one. The allowance now includes room for what arrives while one write is finishing, measured from the store's own median write duration and its own arrival rate, and zero until both have been seen rather than invented in advance.
|
|
5
|
+
- **Chore**: `test/piece-store-slow-disk.test.js` drives a disk that answers only when the test says so, which is the field condition — writes slower than arrivals — and it fails without the fix. The check that shipped with the first attempt at this did not: it passed with the defect in place, which is worth recording, because a test that cannot fail proves nothing.
|
|
6
|
+
|
|
7
|
+
- **Fix**: A proxy reported how much memory it had free with `os.freemem()`, and on Linux that counts only the pages free at this instant — the kernel keeps that number low on purpose and fills the rest with cache, which it hands back the moment anything asks. A host with 4 GB of cache and 200 MB genuinely free called itself nearly full while it had 4.2 GB to give. That figure weighs 0.4 of every proxy's score, so every Linux proxy in the pool understated itself, each by a different amount according to how much cache it happened to hold. It reads `MemAvailable` now — the same fix the piece store's budget got on 2026-08-27, which had stayed in this file until today.
|
|
8
|
+
- **New**: A proxy can answer whether it could sustain a file it is only told ABOUT. The expensive half of that question is finding out what the file IS — add the torrent, wait for metadata, fetch the header, run ffmpeg — and it has already been paid by whichever proxy probed it. Its answer is a handful of numbers; every other proxy answers by arithmetic against its own startup benchmarks in milliseconds, without adding the torrent or fetching a byte. Asked over the tunnel as `can-serve-request`.
|
|
9
|
+
- **New**: The refusal added in 2.71.1 now carries that description, so a viewer whose proxy cannot keep up is moved to one that can instead of being shown an error. A viewer is given a proxy BEFORE the file is known, by a score that reads processor load, free memory and round-trip time — none of which can answer a question about a particular source — and this is where that ordering is repaired, after the fact and only when it went wrong.
|
|
10
|
+
|
|
11
|
+
## 2.71.1
|
|
12
|
+
|
|
13
|
+
- **Fix**: A reader stated the same thing twice — `protectRange` to the piece store for memory, and a window to the torrent for download — and the two were separate lists that could drift. There is one statement now: `SwarmSelection.reconcile` derives both views from the register, so the swarm and the store are told what to do from the same words. Only the urgent levels reach memory: it holds what will be READ soon, and protecting the speculative tail would push out a piece the decoder is about to want.
|
|
14
|
+
- **New**: A file this machine cannot sustain at ANY height is refused rather than served badly. Both offered lists empty means not even copying the picture — which costs no encoder at all — can keep up, so a session made there produces a slideshow and takes the swarm and the processor from whoever is already watching. Field 2026-08-28: five sessions on one file put every rung at 0.04x of realtime. The plan now carries `cannotServe` with the reason, which is a different thing from a spinner that never ends.
|
|
15
|
+
- **Chore**: `SwarmSelection` takes its store lookup as a parameter, so the memory projection is driven by a test without constructing a real piece store.
|
|
16
|
+
|
|
1
17
|
## 2.71.0
|
|
2
18
|
|
|
3
19
|
- **Fix**: The torrent thread's log lines never reached the log file. A worker thread loads its own instance of every module, so the logger's file handle — set once, on the main thread — was null there for the life of the process. Measured over a whole 49 938-line file: zero lines from the piece reader and zero from the torrent pool, against 52 and 36 of them in the container's output, which every release destroys. That is why the comparison of the two claim strategies could never be read: it was being printed into a place we wipe ourselves. The worker now sends its lines to the main thread, which is the only writer — two threads appending to one file would race on the rotation and could interleave mid-line.
|
package/bin/cli.js
CHANGED
|
@@ -476,6 +476,14 @@ try {
|
|
|
476
476
|
onHealthRequest() {
|
|
477
477
|
return collectHealthMetrics();
|
|
478
478
|
},
|
|
479
|
+
// Whether this host could sustain a file it has only been told about. The
|
|
480
|
+
// same arithmetic the first offer uses, against this host's own startup
|
|
481
|
+
// benchmarks — no torrent, no bytes, no ffmpeg — so the browser can ask
|
|
482
|
+
// every proxy in the pool and be sent to one that will work instead of
|
|
483
|
+
// being shown an error on the one it happened to land on.
|
|
484
|
+
onCanServeRequest(mediaInfo) {
|
|
485
|
+
return started?.hlsSessionManager?.predictOfferedHeights?.(mediaInfo) ?? null;
|
|
486
|
+
},
|
|
479
487
|
onConnect() {
|
|
480
488
|
// Re-register on every tunnel connect/reconnect so the server's
|
|
481
489
|
// in-memory store stays consistent after server restarts.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -318,6 +318,10 @@ export async function startProxyServer({
|
|
|
318
318
|
return {
|
|
319
319
|
app,
|
|
320
320
|
port: selectedPort,
|
|
321
|
+
// Asked over the tunnel when the proxy a viewer landed on has refused their
|
|
322
|
+
// file: could THIS host sustain it? Answered from the startup benchmarks
|
|
323
|
+
// and a description, so it needs no torrent and costs milliseconds.
|
|
324
|
+
hlsSessionManager,
|
|
321
325
|
// The browser only ever knows a source by its REGISTRY key (a hash of the
|
|
322
326
|
// raw request bytes, scoped to one API session) — never the torrent
|
|
323
327
|
// pool's own key (the content's infohash, shared across a magnet and a
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
Urgency,
|
|
30
30
|
urgencyName
|
|
31
31
|
} from "../demand/index.js";
|
|
32
|
+
import { findSharedStore } from "../piece-store/shared-piece-store.js";
|
|
32
33
|
|
|
33
34
|
export class SwarmSelection {
|
|
34
35
|
#torrent;
|
|
@@ -37,15 +38,22 @@ export class SwarmSelection {
|
|
|
37
38
|
#stated = new Map();
|
|
38
39
|
/** Pieces this instance marked for displacement, so it clears only its own. */
|
|
39
40
|
#displacing = null;
|
|
41
|
+
/** Claimants whose windows are currently protected in memory. */
|
|
42
|
+
#protectedInMemory = new Set();
|
|
43
|
+
#findStore;
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
46
|
* @param {object} params
|
|
43
47
|
* @param {import("webtorrent").Torrent} params.torrent
|
|
44
48
|
* @param {import("../demand/index.js").DemandRegister} params.register
|
|
49
|
+
* @param {(torrent: object) => object | null} [params.findStore] - How the
|
|
50
|
+
* piece store is reached. Injectable so a test can drive the memory
|
|
51
|
+
* projection without constructing a real store.
|
|
45
52
|
*/
|
|
46
|
-
constructor({ torrent, register }) {
|
|
53
|
+
constructor({ torrent, register, findStore = findSharedStore }) {
|
|
47
54
|
this.#torrent = torrent;
|
|
48
55
|
this.#register = register;
|
|
56
|
+
this.#findStore = findStore;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -107,6 +115,7 @@ export class SwarmSelection {
|
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
this.#markDisplacement();
|
|
118
|
+
this.#projectIntoMemory();
|
|
110
119
|
return { stated, withdrawn };
|
|
111
120
|
}
|
|
112
121
|
|
|
@@ -117,6 +126,11 @@ export class SwarmSelection {
|
|
|
117
126
|
}
|
|
118
127
|
this.#stated.clear();
|
|
119
128
|
this.#clearDisplacement();
|
|
129
|
+
const store = this.#findStore(this.#torrent);
|
|
130
|
+
for (const claimant of this.#protectedInMemory) {
|
|
131
|
+
store?.releaseProtection?.(claimant);
|
|
132
|
+
}
|
|
133
|
+
this.#protectedInMemory.clear();
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
/**
|
|
@@ -182,6 +196,46 @@ export class SwarmSelection {
|
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Tell the piece store which bytes will be read soon, from the same stated
|
|
201
|
+
* needs the swarm is told about.
|
|
202
|
+
*
|
|
203
|
+
* The second half of stating a need once. Until 2026-09-02 a reader said the
|
|
204
|
+
* same thing twice — `protectRange` to the store for memory and a selection
|
|
205
|
+
* to the torrent for download — and a third piece of code read the first to
|
|
206
|
+
* rebuild the second. Now there is one statement and two views of it, both
|
|
207
|
+
* computed here.
|
|
208
|
+
*
|
|
209
|
+
* Only the urgent levels. Memory holds what will be READ soon; the tail and
|
|
210
|
+
* the gap behind the playhead are fetched speculatively and must not push a
|
|
211
|
+
* piece the decoder is about to want out of memory.
|
|
212
|
+
*
|
|
213
|
+
* @returns {void}
|
|
214
|
+
*/
|
|
215
|
+
#projectIntoMemory() {
|
|
216
|
+
const store = this.#findStore(this.#torrent);
|
|
217
|
+
if (!store || typeof store.protectRange !== "function") {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const holding = new Set();
|
|
221
|
+
for (const urgency of [Urgency.BLOCKED, Urgency.NEAR, Urgency.AHEAD]) {
|
|
222
|
+
for (const window of this.#register.at(urgency)) {
|
|
223
|
+
const range = this.#piecesFor(window);
|
|
224
|
+
if (!range) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
store.protectRange(window.claimant, range.from, range.to);
|
|
228
|
+
holding.add(window.claimant);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const claimant of this.#protectedInMemory) {
|
|
232
|
+
if (!holding.has(claimant)) {
|
|
233
|
+
store.releaseProtection?.(claimant);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
this.#protectedInMemory = holding;
|
|
237
|
+
}
|
|
238
|
+
|
|
185
239
|
/** @returns {void} */
|
|
186
240
|
#clearDisplacement() {
|
|
187
241
|
if (!this.#displacing || !Array.isArray(this.#torrent._critical)) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* All values are cheap to read and require no background work.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
9
10
|
import os from "node:os";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -16,17 +17,50 @@ import os from "node:os";
|
|
|
16
17
|
* Suitable as input to `Math.max(0, 1 - Math.min(1, cpuLoad))` for a
|
|
17
18
|
* normalised "CPU availability" score.
|
|
18
19
|
*
|
|
19
|
-
* `memFree` — fraction of total system RAM that
|
|
20
|
+
* `memFree` — fraction of total system RAM that could still be given out
|
|
21
|
+
* (0–1). See {@link availableMemoryBytes} for why that is not the same as
|
|
22
|
+
* free memory.
|
|
20
23
|
*
|
|
21
24
|
* `uptime` — process uptime in whole seconds (useful for preferring
|
|
22
25
|
* already-warmed proxies over freshly started ones).
|
|
23
26
|
*
|
|
24
27
|
* @typedef {Object} HealthMetrics
|
|
25
28
|
* @property {number} cpuLoad - 1-min load avg / cpu-count. 0 = idle, 1 = saturated, >1 = overloaded.
|
|
26
|
-
* @property {number} memFree -
|
|
29
|
+
* @property {number} memFree - Memory an allocation could obtain, as a fraction of total RAM (0–1).
|
|
27
30
|
* @property {number} uptime - Process uptime in seconds.
|
|
28
31
|
*/
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How much memory the machine could still give out, in bytes.
|
|
35
|
+
*
|
|
36
|
+
* NOT `os.freemem()`. On Linux that counts only the pages free at this
|
|
37
|
+
* instant, and the kernel keeps that number low on purpose: what is not in use
|
|
38
|
+
* is filled with cache, which is handed back the moment anything asks. A host
|
|
39
|
+
* with 4 GB of cache and 200 MB genuinely free reports 200 MB and looks full
|
|
40
|
+
* while it has 4.2 GB to give.
|
|
41
|
+
*
|
|
42
|
+
* The kernel publishes its own estimate as `MemAvailable`, and that is what is
|
|
43
|
+
* read here. The same mistake was fixed in the piece store's budget on
|
|
44
|
+
* 2026-08-27 and stayed in this file until 2026-09-02, where it weighed 0.4 of
|
|
45
|
+
* every proxy's score — so every Linux proxy in the pool understated itself,
|
|
46
|
+
* and by a different amount each, according to how much cache it happened to
|
|
47
|
+
* hold.
|
|
48
|
+
*
|
|
49
|
+
* @returns {number}
|
|
50
|
+
*/
|
|
51
|
+
export function availableMemoryBytes() {
|
|
52
|
+
try {
|
|
53
|
+
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(readFileSync("/proc/meminfo", "utf8"));
|
|
54
|
+
if (match) {
|
|
55
|
+
return Number(match[1]) * 1024;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// silent-ok: not Linux, or /proc is not readable. `os.freemem()` is then
|
|
59
|
+
// the best available answer and on those systems it is not misleading.
|
|
60
|
+
}
|
|
61
|
+
return os.freemem();
|
|
62
|
+
}
|
|
63
|
+
|
|
30
64
|
/**
|
|
31
65
|
* Collect current system health metrics.
|
|
32
66
|
*
|
|
@@ -38,7 +72,7 @@ import os from "node:os";
|
|
|
38
72
|
export function collectHealthMetrics() {
|
|
39
73
|
const cpuCount = os.cpus().length || 1;
|
|
40
74
|
const cpuLoad = os.loadavg()[0] / cpuCount;
|
|
41
|
-
const memFree =
|
|
75
|
+
const memFree = availableMemoryBytes() / os.totalmem();
|
|
42
76
|
|
|
43
77
|
return {
|
|
44
78
|
cpuLoad: Math.round(cpuLoad * 1000) / 1000,
|
|
@@ -248,6 +248,49 @@ export class PieceLru {
|
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* How long this piece will be waited for, in pieces.
|
|
253
|
+
*
|
|
254
|
+
* A window starts at what its reader needs NEXT and runs forward, so a piece
|
|
255
|
+
* near the start of a window is wanted sooner than one at its far end, and
|
|
256
|
+
* one beyond every window is wanted later still. That is the comparison the
|
|
257
|
+
* store needs when a piece arrives at a full store: is the arrival wanted
|
|
258
|
+
* sooner or later than the piece it would displace?
|
|
259
|
+
*
|
|
260
|
+
* `#distanceToWindow` cannot answer it — everything inside any window is zero
|
|
261
|
+
* there, so a piece at the front of the reader's own window and one at the
|
|
262
|
+
* far end of somebody else's look identical.
|
|
263
|
+
*
|
|
264
|
+
* A piece BEHIND every window is counted by how far behind, because a reader
|
|
265
|
+
* walking forward will not come back to it: behind is late, not early.
|
|
266
|
+
*
|
|
267
|
+
* @param {number} index
|
|
268
|
+
* @returns {number} -1 when nobody has declared anything.
|
|
269
|
+
*/
|
|
270
|
+
waitFor(index) {
|
|
271
|
+
let soonest = -1;
|
|
272
|
+
for (const range of this.#protected.values()) {
|
|
273
|
+
const wait = index < range.from ? range.from - index : index - range.from;
|
|
274
|
+
if (soonest === -1 || wait < soonest) {
|
|
275
|
+
soonest = wait;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return soonest;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The piece that would be evicted next, and how long it will be waited for.
|
|
283
|
+
*
|
|
284
|
+
* @returns {{ index: number | null, wait: number }}
|
|
285
|
+
*/
|
|
286
|
+
nextVictim() {
|
|
287
|
+
const choice = this.evictionChoice();
|
|
288
|
+
return {
|
|
289
|
+
index: choice.index,
|
|
290
|
+
wait: choice.index === null ? -1 : this.waitFor(choice.index)
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
251
294
|
/**
|
|
252
295
|
* Whether a reader is holding this piece right now.
|
|
253
296
|
*
|
|
@@ -272,6 +272,12 @@ const REVIVAL_AGE_SAMPLES = 200;
|
|
|
272
272
|
*/
|
|
273
273
|
const REUSE_GAP_SAMPLES = 200;
|
|
274
274
|
|
|
275
|
+
/** How many write durations are kept for the median. */
|
|
276
|
+
const WRITE_DURATION_SAMPLES = 50;
|
|
277
|
+
/** How many admission times are kept, and how far back they are counted. */
|
|
278
|
+
const ARRIVAL_SAMPLES = 100;
|
|
279
|
+
const ARRIVAL_WINDOW_MS = 10_000;
|
|
280
|
+
|
|
275
281
|
/**
|
|
276
282
|
* The middle value of a sample, or null when there is nothing to take a middle
|
|
277
283
|
* of. Null rather than zero: no revivals and instant revivals are different
|
|
@@ -355,7 +361,29 @@ export class SharedPieceStore {
|
|
|
355
361
|
*/
|
|
356
362
|
returnedWhilePinned: 0,
|
|
357
363
|
/** Spills that found the disk already holding identical bytes. */
|
|
358
|
-
spillsSkipped: 0
|
|
364
|
+
spillsSkipped: 0,
|
|
365
|
+
/**
|
|
366
|
+
* Blocks a second registration of the same piece displaced. Expected to be
|
|
367
|
+
* small and non-zero: two callers racing for one piece is ordinary. What is
|
|
368
|
+
* NOT ordinary is the block going missing when it happens, which is what
|
|
369
|
+
* killed the process on 2026-09-02.
|
|
370
|
+
*/
|
|
371
|
+
blocksDisplaced: 0,
|
|
372
|
+
/** Admissions that waited for the disk instead of evicting another piece. */
|
|
373
|
+
waitedForDisk: 0,
|
|
374
|
+
/**
|
|
375
|
+
* Admissions that grew memory because the disk had stopped answering.
|
|
376
|
+
* Non-zero means the store exceeded its allowance on purpose, and by how
|
|
377
|
+
* many pieces.
|
|
378
|
+
*/
|
|
379
|
+
grewWaitingForDisk: 0,
|
|
380
|
+
/**
|
|
381
|
+
* Times a block was wanted, none was free, and the pool was already at its
|
|
382
|
+
* ceiling. Zero by construction — a block is only taken after a slot has
|
|
383
|
+
* been claimed, and slots are what the ceiling counts — so a number here
|
|
384
|
+
* means the two have come apart and the pool is growing past its allowance.
|
|
385
|
+
*/
|
|
386
|
+
blocksBeyondCeiling: 0
|
|
359
387
|
};
|
|
360
388
|
/**
|
|
361
389
|
* Blocks that hold no piece, most recently freed last.
|
|
@@ -373,6 +401,8 @@ export class SharedPieceStore {
|
|
|
373
401
|
#blocksAllocated = 0;
|
|
374
402
|
/** Whether a reader has ever declared a window here. See `wantedBytes`. */
|
|
375
403
|
#everHadReader = false;
|
|
404
|
+
/** Whether the last revision had to exceed the machine's share. */
|
|
405
|
+
#beyondTheMachine = false;
|
|
376
406
|
/**
|
|
377
407
|
* How long a block sat free before it was taken again, in milliseconds.
|
|
378
408
|
* Bounded, because what is wanted is the longest gap of RECENT work: an
|
|
@@ -382,6 +412,19 @@ export class SharedPieceStore {
|
|
|
382
412
|
* @type {number[]}
|
|
383
413
|
*/
|
|
384
414
|
#reuseGaps = [];
|
|
415
|
+
/**
|
|
416
|
+
* How long recent writes to disk took, in milliseconds, and when pieces were
|
|
417
|
+
* admitted. Together they say how much room the store needs beyond what the
|
|
418
|
+
* readers ask for: while one write is finishing, more pieces arrive, and each
|
|
419
|
+
* needs somewhere to go. Without that room every arrival evicts something,
|
|
420
|
+
* which is what produced 233 evictions against 119 completed writes in a
|
|
421
|
+
* minute on 2026-09-02.
|
|
422
|
+
*
|
|
423
|
+
* @type {number[]}
|
|
424
|
+
*/
|
|
425
|
+
#writeDurations = [];
|
|
426
|
+
/** When the last few pieces were admitted, for the arrival rate. */
|
|
427
|
+
#admittedAt = [];
|
|
385
428
|
/** Piece index → when it was written out, for the age it comes back at. */
|
|
386
429
|
#spilledAt = new Map();
|
|
387
430
|
/**
|
|
@@ -458,6 +501,10 @@ export class SharedPieceStore {
|
|
|
458
501
|
// grows (roadmap item 2).
|
|
459
502
|
blocksAllocated: this.#blocksAllocated,
|
|
460
503
|
blocksFree: this.#freeBlocks.length,
|
|
504
|
+
blocksDisplaced: this.#counters.blocksDisplaced,
|
|
505
|
+
blocksInUse: this.#blocksInUse(),
|
|
506
|
+
blocksInFlight: this.#blocksInFlight(),
|
|
507
|
+
blocksBeyondCeiling: this.#counters.blocksBeyondCeiling,
|
|
461
508
|
blockBytes: this.#blocksAllocated * this.#chunkLength,
|
|
462
509
|
reuseGapMs: this.#reuseGapCeilingMs(),
|
|
463
510
|
revivalAgeMedianMs: median(this.#revivalAges),
|
|
@@ -467,6 +514,19 @@ export class SharedPieceStore {
|
|
|
467
514
|
};
|
|
468
515
|
}
|
|
469
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Whether the machine's share of memory is smaller than one reader's window.
|
|
519
|
+
*
|
|
520
|
+
* The store holds the window anyway — refusing would leave the read it is
|
|
521
|
+
* serving unable to finish, which is worse — but it is the honest measure of
|
|
522
|
+
* "this machine cannot take any more", and it is measured rather than
|
|
523
|
+
* guessed: it is the last revision's own comparison of what the machine
|
|
524
|
+
* allowed against what the widest reader declared.
|
|
525
|
+
*/
|
|
526
|
+
get isBeyondTheMachine() {
|
|
527
|
+
return this.#beyondTheMachine;
|
|
528
|
+
}
|
|
529
|
+
|
|
470
530
|
/** What this store holds right now, in bytes. */
|
|
471
531
|
get residentBytes() {
|
|
472
532
|
return this.#buffers.size * this.#chunkLength;
|
|
@@ -490,7 +550,12 @@ export class SharedPieceStore {
|
|
|
490
550
|
if (demand.readers > 0) {
|
|
491
551
|
this.#everHadReader = true;
|
|
492
552
|
const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces);
|
|
493
|
-
|
|
553
|
+
// Plus room to absorb what arrives while a write is finishing. Asking for
|
|
554
|
+
// exactly what the readers want leaves no free place ever, so every
|
|
555
|
+
// arrival evicts one of them — measured 2026-09-02: `6 reader(s) want 23
|
|
556
|
+
// piece(s) of 23 the store may hold`, and 233 evictions in the minute
|
|
557
|
+
// that followed.
|
|
558
|
+
return (pieces + this.slackPieces()) * this.#chunkLength;
|
|
494
559
|
}
|
|
495
560
|
// Readers that have GONE are not the same as readers that have not arrived.
|
|
496
561
|
// A store whose readers ended has nothing to hold pieces for — its torrent
|
|
@@ -500,7 +565,7 @@ export class SharedPieceStore {
|
|
|
500
565
|
// reader is being filled for one that is on its way, and asks for what it
|
|
501
566
|
// was opened with until the first read says what it needs.
|
|
502
567
|
return this.#everHadReader
|
|
503
|
-
? MIN_RESIDENT_PIECES * this.#chunkLength
|
|
568
|
+
? (MIN_RESIDENT_PIECES + this.slackPieces()) * this.#chunkLength
|
|
504
569
|
: this.#growthCeiling * this.#chunkLength;
|
|
505
570
|
}
|
|
506
571
|
|
|
@@ -527,6 +592,7 @@ export class SharedPieceStore {
|
|
|
527
592
|
const belowAWindow = demand.readers > 0
|
|
528
593
|
&& Number.isFinite(wanted)
|
|
529
594
|
&& wanted < demand.widestPieces;
|
|
595
|
+
this.#beyondTheMachine = belowAWindow;
|
|
530
596
|
// The LRU is told too. It was constructed with the store's original
|
|
531
597
|
// capacity and never revised, so `isFull()` answered against a number that
|
|
532
598
|
// had not been the limit for some time — dormant only because nothing calls
|
|
@@ -684,7 +750,21 @@ export class SharedPieceStore {
|
|
|
684
750
|
}
|
|
685
751
|
|
|
686
752
|
#registerPiece(index, buffer) {
|
|
753
|
+
// A piece may already be here. Both paths that register one look first and
|
|
754
|
+
// then await — `put` waits for a slot, `#revive` waits for the disk — and
|
|
755
|
+
// in that gap another caller can register the same index. Overwriting the
|
|
756
|
+
// entry used to drop the previous block on the floor: its memory was not
|
|
757
|
+
// returned to the pool and the pool's own count was never decremented, so
|
|
758
|
+
// the count climbed past the ceiling for ever and the pool degenerated into
|
|
759
|
+
// allocating a fresh block per piece. Field 2026-09-02: a store holding
|
|
760
|
+
// THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
|
|
761
|
+
// allocated and 676 still alive, and the process was killed at 4.37 GB.
|
|
762
|
+
const displaced = this.#buffers.get(index);
|
|
687
763
|
this.#buffers.set(index, buffer);
|
|
764
|
+
if (displaced !== undefined && displaced !== buffer) {
|
|
765
|
+
this.#counters.blocksDisplaced += 1;
|
|
766
|
+
this.#returnBlock(displaced);
|
|
767
|
+
}
|
|
688
768
|
this.#lru.touch(index);
|
|
689
769
|
this.#noteProgress();
|
|
690
770
|
}
|
|
@@ -714,8 +794,10 @@ export class SharedPieceStore {
|
|
|
714
794
|
}
|
|
715
795
|
|
|
716
796
|
const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
|
|
797
|
+
const startedAt = Date.now();
|
|
717
798
|
const spill = this.#disk.write(index, bytes).then(
|
|
718
799
|
() => {
|
|
800
|
+
this.#noteWriteDuration(Date.now() - startedAt);
|
|
719
801
|
this.#counters.spills += 1;
|
|
720
802
|
this.#spilledAt.set(index, Date.now());
|
|
721
803
|
this.#evicting.delete(index);
|
|
@@ -744,6 +826,84 @@ export class SharedPieceStore {
|
|
|
744
826
|
this.#wake();
|
|
745
827
|
}
|
|
746
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Record how long one write to disk took.
|
|
831
|
+
*
|
|
832
|
+
* @param {number} durationMs
|
|
833
|
+
* @returns {void}
|
|
834
|
+
*/
|
|
835
|
+
#noteWriteDuration(durationMs) {
|
|
836
|
+
this.#writeDurations.push(Math.max(0, durationMs));
|
|
837
|
+
if (this.#writeDurations.length > WRITE_DURATION_SAMPLES) {
|
|
838
|
+
this.#writeDurations.shift();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Record that a piece arrived, for the arrival rate. */
|
|
843
|
+
#noteArrival() {
|
|
844
|
+
const now = Date.now();
|
|
845
|
+
this.#admittedAt.push(now);
|
|
846
|
+
while (this.#admittedAt.length > ARRIVAL_SAMPLES
|
|
847
|
+
|| (this.#admittedAt.length > 0 && now - this.#admittedAt[0] > ARRIVAL_WINDOW_MS)) {
|
|
848
|
+
this.#admittedAt.shift();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* How many pieces the store needs room for beyond what the readers ask for.
|
|
854
|
+
*
|
|
855
|
+
* Measured, not chosen: the pieces that arrive while one write to disk is
|
|
856
|
+
* finishing. Without this room every arrival must evict something, and each
|
|
857
|
+
* eviction holds its block until its write completes — so a disk slower than
|
|
858
|
+
* the swarm turns every admission into one more block held. On 2026-09-02
|
|
859
|
+
* that was 233 evictions against 119 completed writes in a minute, and 203
|
|
860
|
+
* blocks held with three pieces resident.
|
|
861
|
+
*
|
|
862
|
+
* Zero until both quantities have been seen, because a slack invented before
|
|
863
|
+
* anything is measured is a chosen number, and this store has been bitten by
|
|
864
|
+
* those.
|
|
865
|
+
*
|
|
866
|
+
* @returns {number} Pieces.
|
|
867
|
+
*/
|
|
868
|
+
slackPieces() {
|
|
869
|
+
if (this.#writeDurations.length === 0 || this.#admittedAt.length < 2) {
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
const sorted = [...this.#writeDurations].sort((left, right) => left - right);
|
|
873
|
+
const writeMs = sorted[Math.floor(sorted.length / 2)];
|
|
874
|
+
const spanMs = this.#admittedAt[this.#admittedAt.length - 1] - this.#admittedAt[0];
|
|
875
|
+
if (!(spanMs > 0)) {
|
|
876
|
+
return 0;
|
|
877
|
+
}
|
|
878
|
+
const perMs = (this.#admittedAt.length - 1) / spanMs;
|
|
879
|
+
return Math.ceil(perMs * writeMs);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Blocks that are not free: resident pieces, blocks being written out, and
|
|
884
|
+
* blocks taken but not yet registered.
|
|
885
|
+
*
|
|
886
|
+
* This is what the ceiling has to bound, and until 2026-09-02 it bounded
|
|
887
|
+
* resident pieces instead. The difference is exactly the memory that goes
|
|
888
|
+
* missing from the count: a piece being spilled leaves `#buffers` the moment
|
|
889
|
+
* the eviction begins, while its block stays held until the write it feeds
|
|
890
|
+
* has finished.
|
|
891
|
+
*
|
|
892
|
+
* @returns {number}
|
|
893
|
+
*/
|
|
894
|
+
#blocksInUse() {
|
|
895
|
+
return this.#blocksAllocated - this.#freeBlocks.length;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Blocks held by writes that have not finished.
|
|
900
|
+
*
|
|
901
|
+
* @returns {number}
|
|
902
|
+
*/
|
|
903
|
+
#blocksInFlight() {
|
|
904
|
+
return Math.max(0, this.#blocksInUse() - this.#buffers.size);
|
|
905
|
+
}
|
|
906
|
+
|
|
747
907
|
/**
|
|
748
908
|
* Whether admitting one more piece would need something evicted first.
|
|
749
909
|
*
|
|
@@ -753,7 +913,7 @@ export class SharedPieceStore {
|
|
|
753
913
|
* @returns {boolean}
|
|
754
914
|
*/
|
|
755
915
|
#isFullNow() {
|
|
756
|
-
return this.#
|
|
916
|
+
return this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling;
|
|
757
917
|
}
|
|
758
918
|
|
|
759
919
|
/**
|
|
@@ -843,12 +1003,39 @@ export class SharedPieceStore {
|
|
|
843
1003
|
|
|
844
1004
|
async #claimSlotOnce() {
|
|
845
1005
|
// Reserve before suspension so concurrent callers see the reservation.
|
|
846
|
-
if (this.#
|
|
1006
|
+
if (this.#blocksInUse() + this.#outstandingPieces < this.#growthCeiling) {
|
|
847
1007
|
this.#outstandingPieces += 1;
|
|
848
1008
|
this.#pinnedWaitStartedAt = 0;
|
|
849
1009
|
return true;
|
|
850
1010
|
}
|
|
851
1011
|
|
|
1012
|
+
// Full, and evicting would make it worse rather than better. A piece being
|
|
1013
|
+
// written out has already left `#buffers` while its block is still held —
|
|
1014
|
+
// the write reads from that block — so evicting another one converts a
|
|
1015
|
+
// resident block into an in-flight block and takes a fresh block for the
|
|
1016
|
+
// arrival: the memory in use goes UP by one per admission for as long as
|
|
1017
|
+
// the disk is behind. Field 2026-09-02: 233 evictions against 119 completed
|
|
1018
|
+
// writes in a minute, 203 blocks held with three pieces resident, and the
|
|
1019
|
+
// process killed at 4.37 GB.
|
|
1020
|
+
//
|
|
1021
|
+
// So when the disk is what the store is waiting for, it waits. A completing
|
|
1022
|
+
// write calls `#noteProgress`, which wakes whoever is here.
|
|
1023
|
+
if (this.#blocksInFlight() > 0 && this.#blocksInUse() >= this.#growthCeiling) {
|
|
1024
|
+
if (this.#pinnedWaitStartedAt === 0) {
|
|
1025
|
+
this.#pinnedWaitStartedAt = Date.now();
|
|
1026
|
+
}
|
|
1027
|
+
const stillFor = Date.now() - Math.max(this.#pinnedWaitStartedAt, this.#lastProgressAt);
|
|
1028
|
+
if (stillFor < PINNED_WAIT_MS) {
|
|
1029
|
+
this.#counters.waitedForDisk += 1;
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
// The disk has stopped answering. Falling through to eviction is the
|
|
1033
|
+
// lesser failure: it grows memory, and the line above says by how much,
|
|
1034
|
+
// where refusing would fail the read outright.
|
|
1035
|
+
this.#pinnedWaitStartedAt = 0;
|
|
1036
|
+
this.#counters.grewWaitingForDisk += 1;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
852
1039
|
const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
|
|
853
1040
|
if (victim === null) {
|
|
854
1041
|
// Nothing may leave. Wait while the store is still MOVING — a spill
|
|
@@ -968,6 +1155,19 @@ export class SharedPieceStore {
|
|
|
968
1155
|
this.#noteReuseGap(Date.now() - spare.freedAt);
|
|
969
1156
|
return spare.buffer;
|
|
970
1157
|
}
|
|
1158
|
+
// Nothing spare and the pool is already as large as it is allowed to be.
|
|
1159
|
+
// This cannot happen while the accounting is sound: a block is taken only
|
|
1160
|
+
// after a slot has been claimed, and slots are exactly what the ceiling
|
|
1161
|
+
// counts. It is recorded rather than hidden because when it does happen the
|
|
1162
|
+
// pool grows without bound, and every block it then allocates is used once
|
|
1163
|
+
// and thrown to a collector that has no reason to run — the heap stays at
|
|
1164
|
+
// 50 MB of 2240 while the process reaches four gigabytes.
|
|
1165
|
+
// Strictly greater: reaching the ceiling exactly is what a full store looks
|
|
1166
|
+
// like, and a slot has just been claimed for this block. Being ALREADY past
|
|
1167
|
+
// it and allocating anyway is the state that runs away.
|
|
1168
|
+
if (this.#blocksAllocated > this.#growthCeiling) {
|
|
1169
|
+
this.#counters.blocksBeyondCeiling += 1;
|
|
1170
|
+
}
|
|
971
1171
|
this.#blocksAllocated += 1;
|
|
972
1172
|
return this.#watchForCollection(new SharedArrayBuffer(this.#chunkLength));
|
|
973
1173
|
}
|
|
@@ -1146,6 +1346,7 @@ export class SharedPieceStore {
|
|
|
1146
1346
|
return;
|
|
1147
1347
|
}
|
|
1148
1348
|
|
|
1349
|
+
this.#noteArrival();
|
|
1149
1350
|
const declared = this.#lru.wants(index);
|
|
1150
1351
|
if (declared) {
|
|
1151
1352
|
this.#counters.admittedInsideWindow += 1;
|
|
@@ -1153,12 +1354,30 @@ export class SharedPieceStore {
|
|
|
1153
1354
|
this.#counters.admittedOutsideWindow += 1;
|
|
1154
1355
|
}
|
|
1155
1356
|
|
|
1156
|
-
// A piece
|
|
1157
|
-
// straight to disk
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
|
|
1357
|
+
// A piece that will be wanted LATER than the one it would displace goes
|
|
1358
|
+
// straight to disk instead of being admitted.
|
|
1359
|
+
//
|
|
1360
|
+
// Two cases, and the second was missing until 2026-09-02. The first: the
|
|
1361
|
+
// arrival is in nobody's window at all. The second: it IS in somebody's
|
|
1362
|
+
// window, but further from every read head than the piece the store would
|
|
1363
|
+
// have to evict to make room for it — so admitting it would write out the
|
|
1364
|
+
// nearer piece and read it back sooner. The field session had six readers
|
|
1365
|
+
// whose windows covered the whole file, so the first case never applied
|
|
1366
|
+
// and `0 of those went straight to disk` while the store spilled 233
|
|
1367
|
+
// pieces in a minute.
|
|
1368
|
+
//
|
|
1369
|
+
// Only when SOMETHING is declared: before the first read there is no
|
|
1370
|
+
// basis for calling one piece more wanted than another.
|
|
1371
|
+
const worseThanTheVictim = () => {
|
|
1372
|
+
const arriving = this.#lru.waitFor(index);
|
|
1373
|
+
if (arriving < 0) {
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
const victim = this.#lru.nextVictim();
|
|
1377
|
+
return victim.index !== null && victim.wait >= 0 && arriving > victim.wait;
|
|
1378
|
+
};
|
|
1379
|
+
if (this.#lru.protectedCount > 0 && this.#isFullNow()
|
|
1380
|
+
&& (!declared || worseThanTheVictim())) {
|
|
1162
1381
|
this.#counters.admittedToDisk += 1;
|
|
1163
1382
|
await this.#writeThrough(index, bytes);
|
|
1164
1383
|
this.#noteProgress();
|
|
@@ -475,7 +475,7 @@ export function createPlaybackPlanner({
|
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
function withHostTimings(plan) {
|
|
478
|
-
|
|
478
|
+
const withOffer = {
|
|
479
479
|
...plan,
|
|
480
480
|
expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
|
|
481
481
|
expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
|
|
@@ -490,6 +490,27 @@ export function createPlaybackPlanner({
|
|
|
490
490
|
: null,
|
|
491
491
|
mediaInfoForOffer: undefined
|
|
492
492
|
};
|
|
493
|
+
// Refused rather than served badly. Both lists empty means this machine
|
|
494
|
+
// cannot sustain this file at ANY height — not even by copying the picture,
|
|
495
|
+
// which costs no encoder at all — so a session made here would produce a
|
|
496
|
+
// slideshow and take the swarm and the processor from whoever is already
|
|
497
|
+
// watching. Field 2026-08-28: five sessions on one file put every rung at
|
|
498
|
+
// 0.04x of realtime and the viewer watched one before the process was
|
|
499
|
+
// killed. The viewer is told why, which is a different thing from a spinner
|
|
500
|
+
// that never ends.
|
|
501
|
+
const offer = withOffer.offeredHeights;
|
|
502
|
+
if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
|
|
503
|
+
withOffer.cannotServe =
|
|
504
|
+
"This proxy cannot keep up with this file at any quality right now.";
|
|
505
|
+
// The description travels with the refusal, and only with it. It is what
|
|
506
|
+
// lets the browser ask the rest of the pool the same question without
|
|
507
|
+
// anybody else adding the torrent, fetching a byte or running ffmpeg —
|
|
508
|
+
// the expensive half of finding out what this file IS has been paid here,
|
|
509
|
+
// once. Everyone else answers by arithmetic against their own startup
|
|
510
|
+
// benchmarks.
|
|
511
|
+
withOffer.mediaInfoForOffer = plan.mediaInfoForOffer;
|
|
512
|
+
}
|
|
513
|
+
return withOffer;
|
|
493
514
|
}
|
|
494
515
|
|
|
495
516
|
return {
|
|
@@ -861,14 +861,13 @@ export async function* readFragments({
|
|
|
861
861
|
for (const band of wanted) {
|
|
862
862
|
stateBand(band);
|
|
863
863
|
}
|
|
864
|
+
// One call, and it does both: the swarm is told what to fetch and the store
|
|
865
|
+
// is told what will be read soon, from the same stated needs. The store used
|
|
866
|
+
// to be told separately here, which made the same intent two lists that
|
|
867
|
+
// could drift.
|
|
864
868
|
selection.reconcile();
|
|
865
869
|
claimed = wanted;
|
|
866
870
|
window = next;
|
|
867
|
-
// Tell the store these pieces are wanted, so it evicts something else.
|
|
868
|
-
// Without it the piece the decoder reads next looks exactly as stale as one
|
|
869
|
-
// the encoder fetched forty minutes ahead, and the second kind is what
|
|
870
|
-
// fills the store while the encoder runs ahead of the viewer.
|
|
871
|
-
store.protectRange?.(readerId, next.from, next.to);
|
|
872
871
|
if (isJump) {
|
|
873
872
|
waitBelongsToJump = true;
|
|
874
873
|
// A jump — a seek, not the window sliding along — can land on pieces that
|
|
@@ -1200,9 +1199,8 @@ export async function* readFragments({
|
|
|
1200
1199
|
if (blockedStated) {
|
|
1201
1200
|
register.withdraw(`${readerId}:${urgencyName(Urgency.BLOCKED)}`);
|
|
1202
1201
|
}
|
|
1203
|
-
|
|
1204
|
-
//
|
|
1205
|
-
// asked to reconcile four times on the way out.
|
|
1202
|
+
// Once, after everything has been withdrawn — and it releases this reader's
|
|
1203
|
+
// hold on memory as well, because both views come from the same statement.
|
|
1206
1204
|
selection.reconcile();
|
|
1207
1205
|
}
|
|
1208
1206
|
}
|
|
@@ -34,6 +34,12 @@ import { WebSocket } from "ws";
|
|
|
34
34
|
* Called each time the WebSocket connection becomes open (including reconnects).
|
|
35
35
|
* Use to re-register the proxy so the server's in-memory store stays consistent
|
|
36
36
|
* after server restarts.
|
|
37
|
+
* @property {(mediaInfo: object) => { copy: number[], transcode: number[] } | null} [onCanServeRequest]
|
|
38
|
+
* Called when the server asks whether this host could sustain a file it has
|
|
39
|
+
* been DESCRIBED — height, rate, bitrate, codec — rather than one it holds.
|
|
40
|
+
* Answered from this host's startup benchmarks alone: no torrent is added, no
|
|
41
|
+
* bytes are fetched and ffmpeg is not run, so it costs milliseconds and can
|
|
42
|
+
* be asked of every proxy in the pool at once.
|
|
37
43
|
* @property {() => HealthMetrics} [onHealthRequest]
|
|
38
44
|
* Called when the server sends a `health-request` message. The return value is
|
|
39
45
|
* sent back as `health-response` and used by the server to score this proxy.
|
|
@@ -117,6 +123,7 @@ export function createTunnelClient({
|
|
|
117
123
|
onSignal,
|
|
118
124
|
onConnect,
|
|
119
125
|
onHealthRequest,
|
|
126
|
+
onCanServeRequest,
|
|
120
127
|
onLog,
|
|
121
128
|
connectionLifetimeMs = CONNECTION_LIFETIME_MS
|
|
122
129
|
}) {
|
|
@@ -221,6 +228,26 @@ export function createTunnelClient({
|
|
|
221
228
|
return;
|
|
222
229
|
}
|
|
223
230
|
|
|
231
|
+
// Could this host serve a file it is only told ABOUT? Asked when the
|
|
232
|
+
// proxy a viewer landed on has refused the file, so the browser can be
|
|
233
|
+
// sent somewhere that will work instead of being shown an error. The
|
|
234
|
+
// description travels because the refusing proxy has already probed the
|
|
235
|
+
// file: the expensive half is done once, and every other proxy answers
|
|
236
|
+
// by arithmetic.
|
|
237
|
+
if (message.type === "can-serve-request") {
|
|
238
|
+
let offer = null;
|
|
239
|
+
try {
|
|
240
|
+
offer = typeof onCanServeRequest === "function"
|
|
241
|
+
? onCanServeRequest(message.mediaInfo ?? {})
|
|
242
|
+
: null;
|
|
243
|
+
} catch {
|
|
244
|
+
// silent-ok: an unanswerable question is answered "no", which is what
|
|
245
|
+
// a null offer means to the caller.
|
|
246
|
+
}
|
|
247
|
+
send({ type: "can-serve-response", requestId: message.requestId, offer });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
224
251
|
// Health check: server requests current metrics for proxy scoring.
|
|
225
252
|
if (message.type === "health-request") {
|
|
226
253
|
const metrics = typeof onHealthRequest === "function" ? onHealthRequest() : {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a proxy says about itself, and what the pool scores it on.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
|
|
9
|
+
import { availableMemoryBytes, collectHealthMetrics } from "../services/health-collector.js";
|
|
10
|
+
|
|
11
|
+
test("free memory is what could be given out, not what is idle this instant", () => {
|
|
12
|
+
const available = availableMemoryBytes();
|
|
13
|
+
assert.ok(Number.isFinite(available) && available > 0);
|
|
14
|
+
|
|
15
|
+
// On Linux this reads the kernel's own `MemAvailable`, and it is at least
|
|
16
|
+
// `os.freemem()` by construction: the kernel keeps free memory low on purpose
|
|
17
|
+
// and fills the rest with cache, which it hands back the moment anything
|
|
18
|
+
// asks. The old reading was `os.freemem()`, so a host with 4 GB of cache and
|
|
19
|
+
// 200 MB genuinely free reported itself nearly full while it had 4.2 GB to
|
|
20
|
+
// give — and that figure weighs 0.4 of every proxy's score.
|
|
21
|
+
if (os.platform() === "linux") {
|
|
22
|
+
assert.ok(
|
|
23
|
+
available >= os.freemem(),
|
|
24
|
+
`MemAvailable ${available} is below freemem ${os.freemem()}, which cannot be`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the health report is three bounded numbers", () => {
|
|
30
|
+
const metrics = collectHealthMetrics();
|
|
31
|
+
assert.ok(metrics.cpuLoad >= 0);
|
|
32
|
+
assert.ok(metrics.memFree > 0 && metrics.memFree <= 1);
|
|
33
|
+
assert.ok(Number.isInteger(metrics.uptime) && metrics.uptime >= 0);
|
|
34
|
+
// Three decimals, so a value that has not really moved does not produce a
|
|
35
|
+
// different message on every poll.
|
|
36
|
+
assert.equal(metrics.memFree, Math.round(metrics.memFree * 1000) / 1000);
|
|
37
|
+
});
|
|
@@ -154,10 +154,15 @@ test("the store says why it spills: what is asked of it, what it had to take, ho
|
|
|
154
154
|
asked.demand.unionPieces > asked.demand.capacity,
|
|
155
155
|
"a reader asking for more than the store holds is arithmetic, not a policy fault"
|
|
156
156
|
);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
// And it no longer has to take a declared piece to make room. The reader
|
|
158
|
+
// asked for ten pieces of a store that holds four, and the arrivals that
|
|
159
|
+
// will be wanted LATER than what is already resident are written straight
|
|
160
|
+
// to disk instead of displacing what will be wanted sooner. Before
|
|
161
|
+
// 2026-09-02 every one of them displaced something and was read back
|
|
162
|
+
// moments later: 233 evictions against 119 completed writes in a minute.
|
|
163
|
+
assert.equal(asked.evictedProtected, 0, "a nearer piece was pushed out for a further one");
|
|
164
|
+
assert.equal(asked.spills, 0, "nothing had to be written out to make room on admission");
|
|
165
|
+
assert.ok(asked.admittedToDisk > 0, "the further arrivals were supposed to go to disk");
|
|
161
166
|
|
|
162
167
|
// Read back the pieces that were spilled: each one comes home, and the
|
|
163
168
|
// store says how long it had been away.
|
|
@@ -167,12 +172,17 @@ test("the store says why it spills: what is asked of it, what it had to take, ho
|
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
const after = store.stats();
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
);
|
|
175
|
+
// Reading them back does evict, and that is ordinary: a piece brought into
|
|
176
|
+
// memory has to displace one. What the change of 2026-09-02 removed is the
|
|
177
|
+
// eviction on ADMISSION — `asked.spills` above is zero, where before it all
|
|
178
|
+
// six arrivals displaced a nearer piece and were read back moments later.
|
|
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");
|
|
181
|
+
// No age to report, and that is right rather than missing: an age measures
|
|
182
|
+
// how long an EVICTED piece stayed away, and these were never resident —
|
|
183
|
+
// they were written on arrival and read back once.
|
|
184
|
+
assert.equal(after.revivalAgeSamples, 0);
|
|
185
|
+
assert.equal(after.revivalAgeMedianMs, null);
|
|
176
186
|
|
|
177
187
|
store.releaseProtection("video");
|
|
178
188
|
assert.equal(store.stats().demand.readers, 0, "a reader that ends stops being counted");
|
|
@@ -401,3 +411,81 @@ test("the allowance is never cut below one reader's whole window", async () => {
|
|
|
401
411
|
await fs.rm(directory, { recursive: true, force: true });
|
|
402
412
|
}
|
|
403
413
|
});
|
|
414
|
+
|
|
415
|
+
test("the pool stays the size it is allowed to be, however many pieces pass through it", async () => {
|
|
416
|
+
// The field failure of 2026-09-02, and the case none of the earlier checks
|
|
417
|
+
// covered: an allowance far smaller than the number of pieces in flight.
|
|
418
|
+
// Both paths that register a piece look first and then await — `put` for a
|
|
419
|
+
// slot, a revival for the disk — and in that gap another caller can register
|
|
420
|
+
// the same index. Overwriting the entry dropped the previous block without
|
|
421
|
+
// returning it, so the pool's count climbed past its ceiling for ever and it
|
|
422
|
+
// degenerated into a fresh block per piece. A store holding THREE pieces
|
|
423
|
+
// reported 812 MB committed against 68 MB allowed, 739 blocks allocated and
|
|
424
|
+
// 676 still alive, and the process was killed at 4.37 GB.
|
|
425
|
+
const capacity = 4;
|
|
426
|
+
const { store, directory } = await makeStore(capacity);
|
|
427
|
+
try {
|
|
428
|
+
// Concurrent, because that is what produces the race: several peers deliver
|
|
429
|
+
// pieces of one torrent at the same time.
|
|
430
|
+
for (let round = 0; round < 6; round += 1) {
|
|
431
|
+
await Promise.all(
|
|
432
|
+
Array.from({ length: 16 }, (unused, index) => put(store, index, pieceOf(index)))
|
|
433
|
+
);
|
|
434
|
+
// And read them back, which revives from disk and races registration the
|
|
435
|
+
// other way about.
|
|
436
|
+
await Promise.all([0, 3, 7, 11, 15].map((index) => get(store, index)));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const stats = store.stats();
|
|
440
|
+
assert.equal(stats.blocksBeyondCeiling, 0, "the pool grew past what it is allowed to hold");
|
|
441
|
+
assert.ok(
|
|
442
|
+
stats.blocksAllocated <= capacity + stats.blocksFree,
|
|
443
|
+
`pool of ${stats.blocksAllocated} blocks for ${capacity} slots and ${stats.blocksFree} spare`
|
|
444
|
+
);
|
|
445
|
+
assert.equal(stats.committedBytes, stats.blocksAllocated * PIECE);
|
|
446
|
+
|
|
447
|
+
// Every piece still reads back as itself: a block returned to the pool
|
|
448
|
+
// twice, or reused while somebody held it, shows up here as wrong bytes.
|
|
449
|
+
for (let index = 0; index < 16; index += 1) {
|
|
450
|
+
const bytes = await get(store, index);
|
|
451
|
+
assert.ok(bytes.equals(pieceOf(index)), `piece ${index} came back changed`);
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
store.destroy(() => undefined);
|
|
455
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("a piece wanted later than the one it would displace goes to disk instead", async () => {
|
|
460
|
+
const capacity = 4;
|
|
461
|
+
const { store, directory } = await makeStore(capacity);
|
|
462
|
+
try {
|
|
463
|
+
// A reader is at the start of the file and the store is full of what it
|
|
464
|
+
// wants. Its window is pieces 0-3.
|
|
465
|
+
store.protectRange("video", 0, 3);
|
|
466
|
+
for (let index = 0; index < capacity; index += 1) {
|
|
467
|
+
await put(store, index, pieceOf(index));
|
|
468
|
+
}
|
|
469
|
+
const before = store.stats().admittedToDisk;
|
|
470
|
+
|
|
471
|
+
// A piece arrives from far ahead. It IS inside a window in the field case —
|
|
472
|
+
// six readers covered the whole file — but it lies further from every read
|
|
473
|
+
// head than the piece the store would have to evict for it. Admitting it
|
|
474
|
+
// would write out the nearer piece and read that one back sooner.
|
|
475
|
+
store.protectRange("far", 90, 99);
|
|
476
|
+
await put(store, 95, pieceOf(95));
|
|
477
|
+
|
|
478
|
+
assert.ok(
|
|
479
|
+
store.stats().admittedToDisk > before,
|
|
480
|
+
"the further piece was admitted to memory and the nearer one written out"
|
|
481
|
+
);
|
|
482
|
+
// And it still reads back as itself, from the disk it was put on.
|
|
483
|
+
assert.ok((await get(store, 95)).equals(pieceOf(95)));
|
|
484
|
+
for (let index = 0; index < capacity; index += 1) {
|
|
485
|
+
assert.ok((await get(store, index)).equals(pieceOf(index)), `piece ${index} was lost`);
|
|
486
|
+
}
|
|
487
|
+
} finally {
|
|
488
|
+
store.destroy(() => undefined);
|
|
489
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the allowance has to bound, and why it is not the resident pieces.
|
|
3
|
+
*
|
|
4
|
+
* The field failure of 2026-09-02, in one sentence: a piece being written out
|
|
5
|
+
* leaves the store's own count the moment the eviction begins, while its memory
|
|
6
|
+
* stays held until the write that reads from it has finished. So every
|
|
7
|
+
* admission turned one resident block into one block held by the disk and took
|
|
8
|
+
* a fresh block for the arrival, and the memory in use went UP by one per
|
|
9
|
+
* admission for as long as the disk was behind.
|
|
10
|
+
*
|
|
11
|
+
* It was behind by a factor of two: 233 evictions started against about 119
|
|
12
|
+
* writes completed in the same minute. The store reported 203 blocks held with
|
|
13
|
+
* THREE pieces resident and 68 MB allowed, and the kernel killed the process at
|
|
14
|
+
* 4.37 GB.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import fs from "node:fs/promises";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
|
|
23
|
+
|
|
24
|
+
const PIECE = 1024;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A disk that answers only when the test lets it, so writes can be made slower
|
|
28
|
+
* than arrivals on purpose — which is the whole of the field condition.
|
|
29
|
+
*/
|
|
30
|
+
function heldDisk() {
|
|
31
|
+
const stored = new Set();
|
|
32
|
+
/** @type {Array<() => void>} */
|
|
33
|
+
const waiting = [];
|
|
34
|
+
return {
|
|
35
|
+
pending: waiting,
|
|
36
|
+
get size() {
|
|
37
|
+
return stored.size;
|
|
38
|
+
},
|
|
39
|
+
has: (index) => stored.has(index),
|
|
40
|
+
forget: (index) => stored.delete(index),
|
|
41
|
+
write(index) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
waiting.push(() => {
|
|
44
|
+
stored.add(index);
|
|
45
|
+
resolve();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
async read(index, target) {
|
|
50
|
+
target.fill(index % 251);
|
|
51
|
+
return target.length;
|
|
52
|
+
},
|
|
53
|
+
async close() { waiting.length = 0; },
|
|
54
|
+
async destroy() { stored.clear(); waiting.length = 0; }
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {SharedPieceStore} store
|
|
60
|
+
* @param {number} index
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
const put = (store, index) =>
|
|
64
|
+
new Promise((resolve, reject) => {
|
|
65
|
+
store.put(index, Buffer.alloc(PIECE, index % 251), (error) => (error ? reject(error) : resolve()));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("memory in use never runs past the allowance while the disk is behind", async () => {
|
|
69
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "slow-disk-test-"));
|
|
70
|
+
const disk = heldDisk();
|
|
71
|
+
const capacity = 4;
|
|
72
|
+
const store = new SharedPieceStore(PIECE, {
|
|
73
|
+
length: PIECE * 200,
|
|
74
|
+
memoryBytes: PIECE * capacity,
|
|
75
|
+
path: directory,
|
|
76
|
+
name: "slow-disk",
|
|
77
|
+
disk
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
// Fill it, then keep pieces coming while every write stays unfinished.
|
|
81
|
+
for (let index = 0; index < capacity; index += 1) {
|
|
82
|
+
await put(store, index);
|
|
83
|
+
}
|
|
84
|
+
const arrivals = [];
|
|
85
|
+
for (let index = capacity; index < capacity + 40; index += 1) {
|
|
86
|
+
arrivals.push(put(store, index).catch(() => undefined));
|
|
87
|
+
}
|
|
88
|
+
// Let them get as far as they can with the disk answering nothing.
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
90
|
+
|
|
91
|
+
const held = store.stats();
|
|
92
|
+
// The check the field failure would fail: a store allowed four pieces held
|
|
93
|
+
// two hundred and three. Blocks held by a pending write count here, because
|
|
94
|
+
// they are memory whatever the piece count says.
|
|
95
|
+
assert.ok(
|
|
96
|
+
held.blocksInUse <= capacity,
|
|
97
|
+
`${held.blocksInUse} blocks in use with ${capacity} allowed `
|
|
98
|
+
+ `(${held.blocksInFlight} of them held by writes that have not finished)`
|
|
99
|
+
);
|
|
100
|
+
assert.ok(held.blocksInFlight > 0, "the disk was supposed to be holding some");
|
|
101
|
+
assert.ok(held.waitedForDisk > 0, "admission was supposed to wait for the disk, not evict");
|
|
102
|
+
|
|
103
|
+
// Let the disk answer: the blocks come back and everything settles.
|
|
104
|
+
while (disk.pending.length > 0) {
|
|
105
|
+
disk.pending.shift()?.();
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
107
|
+
}
|
|
108
|
+
await Promise.all(arrivals);
|
|
109
|
+
assert.ok(store.stats().blocksInUse <= capacity + 1, "the pool did not settle");
|
|
110
|
+
} finally {
|
|
111
|
+
store.destroy(() => undefined);
|
|
112
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
@@ -26,6 +26,7 @@ function stubTorrent({ have = [], files = 1 } = {}) {
|
|
|
26
26
|
const items = [];
|
|
27
27
|
return {
|
|
28
28
|
pieceLength: PIECE,
|
|
29
|
+
store: null,
|
|
29
30
|
files: Array.from({ length: files }, (unused, index) => ({
|
|
30
31
|
offset: index * 10 * PIECE,
|
|
31
32
|
length: 10 * PIECE
|
|
@@ -189,3 +190,31 @@ test("releasing everything leaves the library holding nothing of ours", () => {
|
|
|
189
190
|
assert.equal(selection.statedRanges().length, 0);
|
|
190
191
|
assert.equal(torrent._critical.some((marked) => marked === true), false);
|
|
191
192
|
});
|
|
193
|
+
|
|
194
|
+
test("the store is told what will be read soon, from the same stated needs", () => {
|
|
195
|
+
const torrent = stubTorrent();
|
|
196
|
+
const protectedBy = new Map();
|
|
197
|
+
torrent.store = {
|
|
198
|
+
protectRange: (claimant, from, to) => protectedBy.set(claimant, `${from}-${to}`),
|
|
199
|
+
releaseProtection: (claimant) => protectedBy.delete(claimant)
|
|
200
|
+
};
|
|
201
|
+
const register = new DemandRegister();
|
|
202
|
+
const selection = new SwarmSelection({ torrent, register, findStore: () => torrent.store });
|
|
203
|
+
|
|
204
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.NEAR });
|
|
205
|
+
register.state({ claimant: "fill", fileIndex: 0, byteStart: 5 * PIECE, byteEnd: 9 * PIECE - 1, urgency: Urgency.TAIL });
|
|
206
|
+
selection.reconcile();
|
|
207
|
+
|
|
208
|
+
// One statement, two views of it. Until 2026-09-02 a reader said the same
|
|
209
|
+
// thing twice — once to the store for memory, once to the torrent for
|
|
210
|
+
// download — and a third piece of code read the first to rebuild the second.
|
|
211
|
+
assert.equal(protectedBy.get("video"), "0-0");
|
|
212
|
+
// But only the urgent levels: memory holds what will be READ soon, and the
|
|
213
|
+
// tail is fetched speculatively. Protecting it would push out a piece the
|
|
214
|
+
// decoder is about to want.
|
|
215
|
+
assert.equal(protectedBy.has("fill"), false);
|
|
216
|
+
|
|
217
|
+
register.withdraw("video");
|
|
218
|
+
selection.reconcile();
|
|
219
|
+
assert.equal(protectedBy.size, 0, "a reader that withdrew still held memory");
|
|
220
|
+
});
|