@torrent-tv/proxy 2.71.0 → 2.71.1

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,9 @@
1
+ ## 2.71.1
2
+
3
+ - **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.
4
+ - **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.
5
+ - **Chore**: `SwarmSelection` takes its store lookup as a parameter, so the memory projection is driven by a test without constructing a real piece store.
6
+
1
7
  ## 2.71.0
2
8
 
3
9
  - **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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.71.0",
3
+ "version": "2.71.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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)) {
@@ -373,6 +373,8 @@ export class SharedPieceStore {
373
373
  #blocksAllocated = 0;
374
374
  /** Whether a reader has ever declared a window here. See `wantedBytes`. */
375
375
  #everHadReader = false;
376
+ /** Whether the last revision had to exceed the machine's share. */
377
+ #beyondTheMachine = false;
376
378
  /**
377
379
  * How long a block sat free before it was taken again, in milliseconds.
378
380
  * Bounded, because what is wanted is the longest gap of RECENT work: an
@@ -467,6 +469,19 @@ export class SharedPieceStore {
467
469
  };
468
470
  }
469
471
 
472
+ /**
473
+ * Whether the machine's share of memory is smaller than one reader's window.
474
+ *
475
+ * The store holds the window anyway — refusing would leave the read it is
476
+ * serving unable to finish, which is worse — but it is the honest measure of
477
+ * "this machine cannot take any more", and it is measured rather than
478
+ * guessed: it is the last revision's own comparison of what the machine
479
+ * allowed against what the widest reader declared.
480
+ */
481
+ get isBeyondTheMachine() {
482
+ return this.#beyondTheMachine;
483
+ }
484
+
470
485
  /** What this store holds right now, in bytes. */
471
486
  get residentBytes() {
472
487
  return this.#buffers.size * this.#chunkLength;
@@ -527,6 +542,7 @@ export class SharedPieceStore {
527
542
  const belowAWindow = demand.readers > 0
528
543
  && Number.isFinite(wanted)
529
544
  && wanted < demand.widestPieces;
545
+ this.#beyondTheMachine = belowAWindow;
530
546
  // The LRU is told too. It was constructed with the store's original
531
547
  // capacity and never revised, so `isFull()` answered against a number that
532
548
  // had not been the limit for some time — dormant only because nothing calls
@@ -475,7 +475,7 @@ export function createPlaybackPlanner({
475
475
  }
476
476
 
477
477
  function withHostTimings(plan) {
478
- return {
478
+ const withOffer = {
479
479
  ...plan,
480
480
  expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
481
481
  expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
@@ -490,6 +490,20 @@ 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
+ }
506
+ return withOffer;
493
507
  }
494
508
 
495
509
  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
- store.releaseProtection?.(readerId);
1204
- // The swarm is told once, after everything has been withdrawn, so it is not
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
  }
@@ -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
+ });