@torrent-tv/proxy 2.9.112 → 2.9.114

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,12 @@
1
+ ## 2.9.114
2
+
3
+ - **Fix**: A seek leaked a pinned piece, and enough of them destroyed the torrent. A piece is pinned before its fragment is handed to the reader and released by whoever received it — but a consumer that ABANDONS the read never gets the chance, and a seek abandons it every time: the encoder is killed, the response is torn down, the loop is left between two fragments. Field 2026-08-06, one seek was enough: the store answered `Every resident piece is pinned; no slot can be freed`, and it answered it to the WebTorrent client, which closed the store and destroyed the torrent — after which every read failed with `File 0 not found`, the session went terminal, and the segment the viewer was waiting for returned an instant error. The pin of a fragment still in the consumer's hands is now dropped by the reader itself on every exit, abandonment included. Covered by a test that abandons a read mid-fragment and checks the store has nothing pinned.
4
+ - **Chore**: The look-ahead's report that ffmpeg's position and the segments on disk disagree is limited to once a minute. It is a diagnostic, not an event, and it printed three hundred times a minute after a seek — for as long as a fresh run had not caught up with the segments an older one left behind.
5
+
6
+ ## 2.9.113
7
+
8
+ - **New**: The transport's own counters are written to the log for as long as a channel is open, not only when its send queue backs up. The queue was the wrong thing to watch: field 2026-08-06, a 9.26 MB segment was accepted by the transport with `maxBuffered=0 bufferedAtEnd=0` and reported as sent at 274 Mbit/s, and it never arrived — after which everything the proxy sent vanished the same way while requests kept arriving in the other direction. With nothing ever queued the existing watcher never woke, so the one question that decides the cause — did those bytes leave the machine — had no answer in the log. Every five seconds it now records bytes sent and received by the transport itself, the queue depth, the round-trip time, and the path in use. The browser records the matching figures on the same cadence (server 0.8.110), so a recurrence is settled by subtracting one line from the other rather than by reasoning.
9
+
1
10
  ## 2.9.112
2
11
 
3
12
  - **New**: A session whose data went away now waits for it to come back instead of dying. Losing the input is not the session failing — the torrent can be added again and the pieces downloaded again — but a run that died that way marked the session terminal, and every request for the playlist answered 500 from then on, although the swarm was right there and the data would have returned in seconds. Such a run is now retried at the position the viewer is waiting at, backing off from 2 s to at most 15 s so a source that is genuinely unavailable costs a process every few seconds rather than continuously, and the requests being held are simply held: nothing is broken and there is nothing for the viewer to retry. The circuit breaker stays for what it was built for — a target that truly cannot be encoded — and no longer condemns a session that merely lost its data. Which of the two happened is decided by the message, tested against the exact ones the field produced.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.112",
3
+ "version": "2.9.114",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -107,8 +107,42 @@ function makeSendQueueWatcher({ log, getTransportSnapshot }) {
107
107
  let lowestSinceDrain = Number.POSITIVE_INFINITY;
108
108
  let stuckSince = 0;
109
109
  let previous = null;
110
+ // Independent of the queue: the transport's own counters, sampled for as
111
+ // long as the channel is open. The queue was the wrong thing to watch —
112
+ // field 2026-08-06, a 9.26 MB segment was accepted by the transport with
113
+ // `maxBuffered=0 bufferedAtEnd=0`, reported as sent at 274 Mbit/s, and
114
+ // never arrived; everything the proxy sent from that moment on was lost the
115
+ // same way while requests kept coming the other direction. With nothing
116
+ // queued this watcher never woke, so the one question that matters — did
117
+ // those bytes leave the machine — has no answer in the log. It does now.
118
+ let heartbeatPrevious = null;
119
+ let heartbeatAt = 0;
110
120
 
111
121
  const timer = setInterval(() => {
122
+ const sampledAt = Date.now();
123
+ if (sampledAt - heartbeatAt >= TRANSPORT_HEARTBEAT_MS) {
124
+ heartbeatAt = sampledAt;
125
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
126
+ if (snapshot) {
127
+ const sent = heartbeatPrevious ? snapshot.bytesSent - heartbeatPrevious.bytesSent : null;
128
+ const received = heartbeatPrevious
129
+ ? snapshot.bytesReceived - heartbeatPrevious.bytesReceived
130
+ : null;
131
+ heartbeatPrevious = snapshot;
132
+ let depth = 0;
133
+ try {
134
+ depth = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
135
+ } catch {
136
+ depth = -1;
137
+ }
138
+ log(
139
+ `[dc-transport] ${tag} "${label}" sent=${snapshot.bytesSent}` +
140
+ `${sent === null ? "" : ` (+${sent})`} received=${snapshot.bytesReceived}` +
141
+ `${received === null ? "" : ` (+${received})`} queued=${depth}B ` +
142
+ `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
143
+ );
144
+ }
145
+ }
112
146
  let queued = 0;
113
147
  try {
114
148
  queued = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
@@ -630,6 +664,10 @@ const PARTIAL_REQUEST_TTL_MS = 60_000;
630
664
  // well under a second on the LAN — and short enough that a stuck channel is
631
665
  // named while the viewer is still looking at it.
632
666
  const SEND_QUEUE_SAMPLE_MS = 1_000;
667
+ // How often the transport's own counters are written to the log, whatever the
668
+ // send queue is doing. Frequent enough to place a loss within a few seconds,
669
+ // sparse enough that a two-hour film costs a few hundred lines.
670
+ const TRANSPORT_HEARTBEAT_MS = 5_000;
633
671
  const SEND_QUEUE_STUCK_MS = 5_000;
634
672
  const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
635
673
  /** Resume sending once the channel buffer drains to this many bytes. */
@@ -162,6 +162,9 @@ const MAX_SEEK_FAILURES = 3;
162
162
  // wait, not a verdict. Backed off so a source that is truly unavailable costs a
163
163
  // process every few seconds rather than continuously, and never given up on —
164
164
  // the session's own idle TTL is what ends it if the viewer leaves.
165
+ // How often the look-ahead may report that ffmpeg's position and the segments
166
+ // on disk disagree. It is a diagnostic, not an event.
167
+ const LOOKAHEAD_DISAGREEMENT_LOG_MS = 60_000;
165
168
  const INPUT_RETRY_BASE_MS = 2_000;
166
169
  const INPUT_RETRY_MAX_MS = 15_000;
167
170
  // Idle TTL: a session is disposed this long after the last segment/playlist
@@ -1845,7 +1848,17 @@ export class HlsSessionManager {
1845
1848
  // Worth knowing when the two disagree wildly — it is the only trace of
1846
1849
  // whatever made ffmpeg report a position it had not reached.
1847
1850
  const claimed = Number(session.progress?.processedSeconds);
1848
- if (Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS) {
1851
+ // Once a minute at most. The check runs on every segment request, and the
1852
+ // two figures disagree for as long as a fresh run has not caught up with
1853
+ // the segments an older one left on disk — which printed this line three
1854
+ // hundred times a minute after one seek.
1855
+ const now = Date.now();
1856
+ if (
1857
+ Number.isFinite(claimed) &&
1858
+ Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS &&
1859
+ now - (session.lookAheadDisagreementLoggedAt ?? 0) > LOOKAHEAD_DISAGREEMENT_LOG_MS
1860
+ ) {
1861
+ session.lookAheadDisagreementLoggedAt = now;
1849
1862
  logger.info(
1850
1863
  `transcode ${session.id} ffmpeg claims ${Math.round(claimed)}s processed ` +
1851
1864
  `but has produced through ${Math.round(encodedTo)}s (segment #${producedThrough})`
@@ -315,6 +315,13 @@ export async function* readFragments({
315
315
  let window = null;
316
316
  /** @type {{ from: number, to: number } | null} */
317
317
  let criticalMark = null;
318
+ /**
319
+ * Drops the pin of the fragment currently in the consumer's hands, if it
320
+ * still holds one. See where it is assigned.
321
+ *
322
+ * @type {(() => void) | null}
323
+ */
324
+ let releaseHeldPin = null;
318
325
  /**
319
326
  * The rest of the file, claimed at the lowest priority so it is fetched with
320
327
  * whatever capacity the near window does not need. Null whenever it must not
@@ -507,6 +514,21 @@ export async function* readFragments({
507
514
  }
508
515
 
509
516
  let releasedThisPiece = false;
517
+ // Remembered so the generator can drop it itself. The pin is taken here
518
+ // and the consumer is expected to release it — but a consumer that
519
+ // ABANDONS the iterator never gets the chance, and a seek abandons it
520
+ // every time: the encoder is killed, the response is torn down, and the
521
+ // loop is left between two fragments. Field 2026-08-06: after one seek
522
+ // every slot in the store was pinned, the store answered
523
+ // `Every resident piece is pinned; no slot can be freed` — to the
524
+ // WebTorrent client, which closed the store and destroyed the torrent —
525
+ // and the session died with `File 0 not found`.
526
+ releaseHeldPin = () => {
527
+ if (!releasedThisPiece) {
528
+ releasedThisPiece = true;
529
+ store.unpin(pieceIndex);
530
+ }
531
+ };
510
532
  yield {
511
533
  pieceIndex,
512
534
  offset: located.offset + fromWithinPiece,
@@ -519,8 +541,17 @@ export async function* readFragments({
519
541
  store.unpin(pieceIndex);
520
542
  }
521
543
  };
544
+ // Handed back, and released by the consumer or not at all — either way
545
+ // this reader no longer owes anything for it.
546
+ releaseHeldPin = null;
522
547
  }
523
548
  } finally {
549
+ // A fragment handed out and never released is a slot lost for the life of
550
+ // the process. Reached on every exit, including the consumer walking away.
551
+ if (releaseHeldPin) {
552
+ releaseHeldPin();
553
+ releaseHeldPin = null;
554
+ }
524
555
  // Reached on completion, on cancellation, on a throw, and when the consumer
525
556
  // stops iterating — a window left behind would keep the swarm fetching for
526
557
  // a reader that no longer exists.
@@ -245,3 +245,34 @@ test("criticality marks the window being waited for, not the whole range", async
245
245
  await fs.rm(directory, { recursive: true, force: true });
246
246
  }
247
247
  });
248
+
249
+ test("a reader that is abandoned mid-fragment does not keep the piece pinned", async () => {
250
+ // The pin is taken before the fragment is handed out and released by the
251
+ // consumer — but a seek abandons the iterator between two fragments, and the
252
+ // consumer never gets the chance. Field 2026-08-06: after one seek every slot
253
+ // in the store was pinned, the store answered `Every resident piece is
254
+ // pinned; no slot can be freed` to the WebTorrent client, which closed the
255
+ // store and destroyed the torrent.
256
+ const { torrent, store, directory } = await recordingTorrent({ pieceCount: 40 });
257
+ try {
258
+ const iterator = readFragments({
259
+ torrent,
260
+ fileIndex: 0,
261
+ start: 0,
262
+ end: 40 * PIECE - 1,
263
+ cancellation: { isCancelled: () => false },
264
+ windowBytes: WINDOW_PIECES * PIECE
265
+ });
266
+ await iterator.next(); // held, deliberately NOT released
267
+ await iterator.return(); // what a seek does
268
+
269
+ assert.equal(
270
+ store.stats().pinned,
271
+ 0,
272
+ "the abandoned fragment's piece is still pinned; slots leak one per seek"
273
+ );
274
+ } finally {
275
+ store.destroy(() => undefined);
276
+ await fs.rm(directory, { recursive: true, force: true });
277
+ }
278
+ });