@torrent-tv/proxy 2.71.1 → 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 CHANGED
@@ -1,3 +1,13 @@
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
+
1
11
  ## 2.71.1
2
12
 
3
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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.71.1",
3
+ "version": "2.72.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
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
@@ -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 is currently free (0–1).
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 - Free RAM as a fraction of total RAM (0–1).
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 = os.freemem() / os.totalmem();
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.
@@ -384,6 +412,19 @@ export class SharedPieceStore {
384
412
  * @type {number[]}
385
413
  */
386
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 = [];
387
428
  /** Piece index → when it was written out, for the age it comes back at. */
388
429
  #spilledAt = new Map();
389
430
  /**
@@ -460,6 +501,10 @@ export class SharedPieceStore {
460
501
  // grows (roadmap item 2).
461
502
  blocksAllocated: this.#blocksAllocated,
462
503
  blocksFree: this.#freeBlocks.length,
504
+ blocksDisplaced: this.#counters.blocksDisplaced,
505
+ blocksInUse: this.#blocksInUse(),
506
+ blocksInFlight: this.#blocksInFlight(),
507
+ blocksBeyondCeiling: this.#counters.blocksBeyondCeiling,
463
508
  blockBytes: this.#blocksAllocated * this.#chunkLength,
464
509
  reuseGapMs: this.#reuseGapCeilingMs(),
465
510
  revivalAgeMedianMs: median(this.#revivalAges),
@@ -505,7 +550,12 @@ export class SharedPieceStore {
505
550
  if (demand.readers > 0) {
506
551
  this.#everHadReader = true;
507
552
  const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces);
508
- return pieces * this.#chunkLength;
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;
509
559
  }
510
560
  // Readers that have GONE are not the same as readers that have not arrived.
511
561
  // A store whose readers ended has nothing to hold pieces for — its torrent
@@ -515,7 +565,7 @@ export class SharedPieceStore {
515
565
  // reader is being filled for one that is on its way, and asks for what it
516
566
  // was opened with until the first read says what it needs.
517
567
  return this.#everHadReader
518
- ? MIN_RESIDENT_PIECES * this.#chunkLength
568
+ ? (MIN_RESIDENT_PIECES + this.slackPieces()) * this.#chunkLength
519
569
  : this.#growthCeiling * this.#chunkLength;
520
570
  }
521
571
 
@@ -700,7 +750,21 @@ export class SharedPieceStore {
700
750
  }
701
751
 
702
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);
703
763
  this.#buffers.set(index, buffer);
764
+ if (displaced !== undefined && displaced !== buffer) {
765
+ this.#counters.blocksDisplaced += 1;
766
+ this.#returnBlock(displaced);
767
+ }
704
768
  this.#lru.touch(index);
705
769
  this.#noteProgress();
706
770
  }
@@ -730,8 +794,10 @@ export class SharedPieceStore {
730
794
  }
731
795
 
732
796
  const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
797
+ const startedAt = Date.now();
733
798
  const spill = this.#disk.write(index, bytes).then(
734
799
  () => {
800
+ this.#noteWriteDuration(Date.now() - startedAt);
735
801
  this.#counters.spills += 1;
736
802
  this.#spilledAt.set(index, Date.now());
737
803
  this.#evicting.delete(index);
@@ -760,6 +826,84 @@ export class SharedPieceStore {
760
826
  this.#wake();
761
827
  }
762
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
+
763
907
  /**
764
908
  * Whether admitting one more piece would need something evicted first.
765
909
  *
@@ -769,7 +913,7 @@ export class SharedPieceStore {
769
913
  * @returns {boolean}
770
914
  */
771
915
  #isFullNow() {
772
- return this.#buffers.size + this.#outstandingPieces >= this.#growthCeiling;
916
+ return this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling;
773
917
  }
774
918
 
775
919
  /**
@@ -859,12 +1003,39 @@ export class SharedPieceStore {
859
1003
 
860
1004
  async #claimSlotOnce() {
861
1005
  // Reserve before suspension so concurrent callers see the reservation.
862
- if (this.#buffers.size + this.#outstandingPieces < this.#growthCeiling) {
1006
+ if (this.#blocksInUse() + this.#outstandingPieces < this.#growthCeiling) {
863
1007
  this.#outstandingPieces += 1;
864
1008
  this.#pinnedWaitStartedAt = 0;
865
1009
  return true;
866
1010
  }
867
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
+
868
1039
  const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
869
1040
  if (victim === null) {
870
1041
  // Nothing may leave. Wait while the store is still MOVING — a spill
@@ -984,6 +1155,19 @@ export class SharedPieceStore {
984
1155
  this.#noteReuseGap(Date.now() - spare.freedAt);
985
1156
  return spare.buffer;
986
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
+ }
987
1171
  this.#blocksAllocated += 1;
988
1172
  return this.#watchForCollection(new SharedArrayBuffer(this.#chunkLength));
989
1173
  }
@@ -1162,6 +1346,7 @@ export class SharedPieceStore {
1162
1346
  return;
1163
1347
  }
1164
1348
 
1349
+ this.#noteArrival();
1165
1350
  const declared = this.#lru.wants(index);
1166
1351
  if (declared) {
1167
1352
  this.#counters.admittedInsideWindow += 1;
@@ -1169,12 +1354,30 @@ export class SharedPieceStore {
1169
1354
  this.#counters.admittedOutsideWindow += 1;
1170
1355
  }
1171
1356
 
1172
- // A piece no reader has declared, arriving at a store with no room, goes
1173
- // straight to disk. It costs the same one write it would have cost when
1174
- // the next arrival evicted it, and it saves pushing out a piece a reader
1175
- // is about to read. Only when SOMETHING is declared: before the first
1176
- // read there is no basis for calling a piece unwanted.
1177
- if (!declared && this.#lru.protectedCount > 0 && this.#isFullNow()) {
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())) {
1178
1381
  this.#counters.admittedToDisk += 1;
1179
1382
  await this.#writeThrough(index, bytes);
1180
1383
  this.#noteProgress();
@@ -502,6 +502,13 @@ export function createPlaybackPlanner({
502
502
  if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
503
503
  withOffer.cannotServe =
504
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;
505
512
  }
506
513
  return withOffer;
507
514
  }
@@ -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
- assert.ok(
158
- asked.evictedProtected > 0,
159
- "every eviction here had to take a piece the reader had declared"
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
- assert.ok(after.revivalAgeSamples > 0, "pieces came back and their age was recorded");
171
- assert.equal(typeof after.revivalAgeMedianMs, "number");
172
- assert.ok(
173
- after.revivedWithinFiveSeconds > 0,
174
- "a piece wanted again seconds after it left should not have left"
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
+ });