@torrent-tv/proxy 2.9.113 → 2.9.115
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,8 @@
|
|
|
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 reports that ffmpeg's position and the segments on disk disagree on the EDGES of that state — once when they part company, once when they meet again, with how long it lasted. It was printed per call of a function that runs on every segment request, which after a seek meant three hundred times a minute; a rate limit would only have hidden that the line was in the wrong place, and it could not have said how long the disagreement went on.
|
|
5
|
+
|
|
1
6
|
## 2.9.113
|
|
2
7
|
|
|
3
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.
|
package/package.json
CHANGED
|
@@ -1844,12 +1844,29 @@ export class HlsSessionManager {
|
|
|
1844
1844
|
}
|
|
1845
1845
|
// Worth knowing when the two disagree wildly — it is the only trace of
|
|
1846
1846
|
// whatever made ffmpeg report a position it had not reached.
|
|
1847
|
+
// Reported on its EDGES, because it is a state and not a stream: it starts
|
|
1848
|
+
// when the two figures part company and ends when they meet again, however
|
|
1849
|
+
// often this function happens to run. Printing it per call meant printing
|
|
1850
|
+
// it at the rate of segment requests — three hundred times a minute after
|
|
1851
|
+
// one seek — and a limit of one a minute would only have hidden that the
|
|
1852
|
+
// line was in the wrong place. Two lines per occurrence also say how long
|
|
1853
|
+
// it lasted, which no sampled version could.
|
|
1847
1854
|
const claimed = Number(session.progress?.processedSeconds);
|
|
1848
|
-
|
|
1855
|
+
const disagrees =
|
|
1856
|
+
Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS;
|
|
1857
|
+
if (disagrees && !session.lookAheadDisagreementSince) {
|
|
1858
|
+
session.lookAheadDisagreementSince = Date.now();
|
|
1849
1859
|
logger.info(
|
|
1850
1860
|
`transcode ${session.id} ffmpeg claims ${Math.round(claimed)}s processed ` +
|
|
1851
1861
|
`but has produced through ${Math.round(encodedTo)}s (segment #${producedThrough})`
|
|
1852
1862
|
);
|
|
1863
|
+
} else if (!disagrees && session.lookAheadDisagreementSince) {
|
|
1864
|
+
const lastedMs = Date.now() - session.lookAheadDisagreementSince;
|
|
1865
|
+
session.lookAheadDisagreementSince = 0;
|
|
1866
|
+
logger.info(
|
|
1867
|
+
`transcode ${session.id} ffmpeg's position and the segments on disk agree again ` +
|
|
1868
|
+
`after ${(lastedMs / 1000).toFixed(1)}s (produced through ${Math.round(encodedTo)}s)`
|
|
1869
|
+
);
|
|
1853
1870
|
}
|
|
1854
1871
|
// Where the viewer is. Before the first segment request, the position the
|
|
1855
1872
|
// run started at — so a session nobody has read from yet is bounded too.
|
|
@@ -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.
|
package/test/read-window.test.js
CHANGED
|
@@ -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
|
+
});
|