@torrent-tv/proxy 2.9.46 → 2.9.48

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.48
2
+
3
+ - **Fix**: The "bytes still needed to resume" figure shown while buffering could jump UP mid-poll even though nothing regressed, which read as confusing/broken. Root cause: the resume-window progress (`resumeNeededBytes`/`resumeDownloadedBytes`) was always computed against the LIVE read position, which slides forward as the file is read/transcoded further — so when the window moved past an already-downloaded piece into a fresh, never-touched one, "bytes needed" jumped up (a moving reference frame, not a real setback). `getFileStats` now accepts an optional `resumeAnchorByteStart` and always returns the byte offset the window was computed against; the browser client captures that offset on the FIRST poll of a buffering episode and sends it back on every subsequent poll of the SAME episode, so the window stays pinned to a fixed target and the figure only ever decreases as real download progress happens. Verified: with the anchor pinned, repeated polls report the same "needed" while the live read position moves with no new data, and a real download of a piece inside the frozen window correctly decreases it.
4
+ - **Fix**: A rapid sequence of seeks could leave playback permanently stuck — field-diagnosed from a live session (5 seek-restarts in 16 seconds), showing `failed to rename file segment-NNNNN.m4s.tmp` and a zombie ffmpeg still writing a `.tmp` file ~30 seconds after being "killed" by two later restarts, even after the session had already been released. Root cause: `#startEncodeRun` sent `SIGTERM` to the previous ffmpeg process and immediately spawned the replacement into the SAME session directory without waiting for it to actually exit. `ChildProcess.killed` only means a signal was sent, not that the process died — ffmpeg's own blocking read of our torrent-backed `/stream` input can defer signal handling for a long time while starved, so on a rapid sequence of seeks multiple ffmpeg processes ended up alive concurrently, fighting over CPU and racing each other's file writes in the same directory; none of them would finish a segment in time, which is what "stuck at seek" looks like to the viewer. Fixed by awaiting the previous process's exit (escalating from `SIGTERM` to `SIGKILL` if it does not exit within a grace period, reusing the `waitForChildExit` helper `disposeSession` already used correctly) before spawning the replacement. A new per-session generation counter (`encodeRunGeneration`) lets a restart that was superseded by an even newer seek while it was waiting abort instead of also spawning a process — verified with a standalone race simulation: 5 overlapping restarts against a slow-to-die previous process spawn exactly 1 process, matching the LATEST requested target.
5
+
6
+ ## 2.9.47
7
+
8
+ - **Fix**: Playback could get permanently stuck (hls.js endlessly re-fetching the manifest and the first segment, buffer never advancing) even though the transcode itself was encoding fine, running ahead of realtime. Root cause: ffmpeg creates the fMP4 `init.mp4` file before it finishes writing the codec-header boxes into it (unlike segments, its write is not gated behind an atomic rename), so a request could race a moment where the file exists but is still empty. That empty read was then cached forever as the session's init segment — a zero-length `Buffer` is still a truthy object, so the `if (session.initBytes)` cache guard treated it as "already resolved" and kept serving the empty file for the rest of the session, which hls.js can never initialize a SourceBuffer from. Fixed by treating a zero-byte read as not-yet-ready (keeps the caller's existing long-poll retrying) instead of caching it as final.
9
+
1
10
  ## 2.9.46
2
11
 
3
12
  - **New**: `getFileStats` now reports `resumeNeededBytes` / `resumeDownloadedBytes` — the bytes still to download in the 16 MB window ahead of the file's current read position (tracked per file by `prioritizeByteRange`, cleared on torrent removal), counted byte-accurately including partial pieces. Lets the browser show how much is left to download and the time to resume while buffering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.46",
3
+ "version": "2.9.48",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -40,7 +40,14 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
40
40
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
41
  const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
42
 
43
- const stats = torrentPool.getFileStats(torrent, fileIndex);
43
+ // Optional: pin the resume window to a FIXED byte offset for the duration of
44
+ // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
+ // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
+ // slides forward with playback/encoding progress.
47
+ const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
+ const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
+
50
+ const stats = torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
44
51
 
45
52
  // Diagnostic: surface the real swarm state per poll so a cold-start download
46
53
  // stall (0 peers / header not advancing → playback-plan blocks on the codec
@@ -48,6 +48,15 @@ const MAX_LOOKAHEAD_SEGMENTS = 8;
48
48
  // succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
49
49
  // between positions, restarting endlessly and producing nothing.
50
50
  const RESTART_COOLDOWN_MS = 4_000;
51
+ // Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
52
+ // continuously while it encodes; when it hangs mid-file (alive, but producing
53
+ // no output and no stderr — a deadlock, e.g. a stalled input read), that output
54
+ // stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
55
+ // window is being demanded but progress has not advanced for this long, the
56
+ // encoder is wedged (observed: the segment 503s forever). Treat it like a seek
57
+ // and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
58
+ // encode keeps advancing `updatedAt`, so this only fires on a true freeze.
59
+ const ENCODER_STALL_MS = 12_000;
51
60
  // Seek debounce. A far (out-of-window) segment request is a server-side seek.
52
61
  // Rather than restart ffmpeg on the first one, wait a short quiet period:
53
62
  // further far requests re-arm it and update the target to the latest index, so
@@ -59,6 +68,10 @@ const SEEK_SETTLE_MS = 1_200;
59
68
  // Hard cap on the total settle wait, measured from the first far request of a
60
69
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
61
70
  const SEEK_SETTLE_MAX_MS = 2_500;
71
+ // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
72
+ // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
73
+ // the same session directory. See #startEncodeRun.
74
+ const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
62
75
  // Idle TTL: a session is disposed this long after the last segment/playlist
63
76
  // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
64
77
  // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
@@ -152,6 +165,20 @@ function waitForChildExit(child, timeoutMs = 2_000) {
152
165
  });
153
166
  }
154
167
 
168
+ /**
169
+ * Whether a child process has genuinely exited. `ChildProcess.killed` only
170
+ * means `.kill()` was called — the process can stay alive well after that
171
+ * (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
172
+ * only set once the `exit` event has actually fired, so this is the reliable
173
+ * check before treating a directory/file as free for a new process to use.
174
+ *
175
+ * @param {import("node:child_process").ChildProcess} child
176
+ * @returns {boolean}
177
+ */
178
+ function hasChildExited(child) {
179
+ return child.exitCode !== null || child.signalCode !== null;
180
+ }
181
+
155
182
  /**
156
183
  * Convert a bind-all host address to the loopback address so that
157
184
  * the HLS input URL is always reachable from the same machine.
@@ -591,6 +618,9 @@ function normalizeLogFileName(fileName, fileIndex) {
591
618
  * @property {string} lastError
592
619
  * @property {Set<string>} consumers - Consumer IDs currently using this session.
593
620
  * @property {object} progress - Live progress metrics updated from ffmpeg stdout.
621
+ * @property {number} encodeRunGeneration - Bumped on every #startEncodeRun call;
622
+ * lets a call that awaited the previous ffmpeg's exit detect it was superseded
623
+ * by a newer restart request and abort instead of spawning a second process.
594
624
  */
595
625
 
596
626
  /**
@@ -869,6 +899,7 @@ export class HlsSessionManager {
869
899
  startedAt: Date.now(),
870
900
  lastAccessedAt: Date.now(),
871
901
  ffmpeg: null,
902
+ encodeRunGeneration: 0,
872
903
  lastError: "",
873
904
  // Cold-start timing: entry timestamp + a once-guard so the first servable
874
905
  // segment logs its latency exactly once.
@@ -970,7 +1001,7 @@ export class HlsSessionManager {
970
1001
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
971
1002
  );
972
1003
 
973
- this.#startEncodeRun(session, 0);
1004
+ await this.#startEncodeRun(session, 0);
974
1005
 
975
1006
  try {
976
1007
  await this.waitUntilReady(session);
@@ -1220,7 +1251,7 @@ export class HlsSessionManager {
1220
1251
  if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1221
1252
  return false; // let the previous action settle
1222
1253
  }
1223
- this.#applyBudgetDownshift(
1254
+ await this.#applyBudgetDownshift(
1224
1255
  session,
1225
1256
  `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
1226
1257
  "link"
@@ -1292,7 +1323,7 @@ export class HlsSessionManager {
1292
1323
  session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
1293
1324
  continue;
1294
1325
  }
1295
- this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
1326
+ await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
1296
1327
  }
1297
1328
  }
1298
1329
 
@@ -1340,9 +1371,9 @@ export class HlsSessionManager {
1340
1371
  * @param {HlsSession} session
1341
1372
  * @param {string} reasonText - Measurement summary for the log line.
1342
1373
  * @param {"cpu" | "unknown" | "link"} bound
1343
- * @returns {void}
1374
+ * @returns {Promise<void>}
1344
1375
  */
1345
- #applyBudgetDownshift(session, reasonText, bound) {
1376
+ async #applyBudgetDownshift(session, reasonText, bound) {
1346
1377
  const nextIndex = session.budgetRungIndex + 1;
1347
1378
  const rung = session.budgetLadder[nextIndex];
1348
1379
  if (!rung) {
@@ -1371,22 +1402,66 @@ export class HlsSessionManager {
1371
1402
  `(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
1372
1403
  `restart at segment #${currentSeg} "${session.fileName}"`
1373
1404
  );
1374
- this.#startEncodeRun(session, currentSeg);
1405
+ await this.#startEncodeRun(session, currentSeg);
1375
1406
  }
1376
1407
 
1377
1408
  /**
1378
1409
  * (Re)start the ffmpeg encode run beginning at segment `startIndex`.
1379
1410
  *
1380
- * Any ffmpeg process currently running for this session is terminated first.
1411
+ * Any ffmpeg process currently running for this session is terminated FIRST
1412
+ * AND ITS EXIT IS AWAITED before the replacement is spawned into the same
1413
+ * directory. This closes a real incident: a fire-and-forget SIGTERM does not
1414
+ * mean the process is dead — `ChildProcess.killed` reflects only that a
1415
+ * signal was sent, not that the process exited (ffmpeg's own blocking read of
1416
+ * our torrent-backed `/stream` input can defer signal handling for a long
1417
+ * time while starved). On a rapid sequence of seeks this left multiple
1418
+ * ffmpeg processes alive concurrently, all writing into the SAME session
1419
+ * directory — observed as `failed to rename file segment-NNNNN.m4s.tmp`
1420
+ * (a dying process racing a fresh one) and a zombie process still writing a
1421
+ * `.tmp` file ~30s after being "killed" by two LATER restarts, even after the
1422
+ * session had already been released. Multiple ffmpeg processes fighting over
1423
+ * CPU and the same files on a weak host is what a seek could get "stuck" on.
1424
+ *
1425
+ * Because this now awaits, a NEWER restart request can arrive while an OLDER
1426
+ * one is still waiting for the previous process to die. `encodeRunGeneration`
1427
+ * resolves that: each call captures its own generation number, and after the
1428
+ * await, a call whose generation was superseded aborts without spawning —
1429
+ * only the LATEST requested target ever actually starts a process.
1430
+ *
1381
1431
  * Segment files are named with a global index (`-start_number`) so they
1382
1432
  * always line up with the synthetic VOD playlist regardless of where
1383
1433
  * encoding started — this is what makes server-side seeking work.
1384
1434
  *
1385
1435
  * @param {HlsSession} session
1386
1436
  * @param {number} startIndex
1387
- * @returns {void}
1437
+ * @returns {Promise<void>}
1388
1438
  */
1389
- #startEncodeRun(session, startIndex) {
1439
+ async #startEncodeRun(session, startIndex) {
1440
+ const generation = ++session.encodeRunGeneration;
1441
+ const previousFfmpeg = session.ffmpeg;
1442
+ if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
1443
+ try {
1444
+ previousFfmpeg.kill("SIGTERM");
1445
+ } catch {
1446
+ // Best effort.
1447
+ }
1448
+ await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1449
+ if (!hasChildExited(previousFfmpeg)) {
1450
+ try {
1451
+ previousFfmpeg.kill("SIGKILL");
1452
+ } catch {
1453
+ // Best effort.
1454
+ }
1455
+ await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1456
+ }
1457
+ }
1458
+ // A newer restart (or disposal) won the race while we were waiting for the
1459
+ // old process to die — it either already spawned its own replacement or
1460
+ // there is nothing left to start. Do not also spawn from this stale call.
1461
+ if (session.encodeRunGeneration !== generation || session.state === "disposed") {
1462
+ return;
1463
+ }
1464
+
1390
1465
  const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
1391
1466
  // 0-based output time of this segment, from the boundary table (uniform for
1392
1467
  // re-encode, real keyframe for copy).
@@ -1631,7 +1706,7 @@ export class HlsSessionManager {
1631
1706
  `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
1632
1707
  `(${session.lastError}); falling back to software libx264 and restarting`
1633
1708
  );
1634
- this.#startEncodeRun(session, session.encodeStartIndex);
1709
+ void this.#startEncodeRun(session, session.encodeStartIndex);
1635
1710
  return;
1636
1711
  }
1637
1712
  session.state = "failed";
@@ -1713,7 +1788,7 @@ export class HlsSessionManager {
1713
1788
  session.seekTarget = null;
1714
1789
  session.seekFirstFarAt = 0;
1715
1790
  logger.info(`transcode ${session.id} seek settle → restart at segment #${target}`);
1716
- this.#startEncodeRun(session, target);
1791
+ void this.#startEncodeRun(session, target);
1717
1792
  }
1718
1793
 
1719
1794
  /**
@@ -1804,12 +1879,28 @@ export class HlsSessionManager {
1804
1879
  // and position-independent, but each seek-restart run REWRITES init.mp4, so
1805
1880
  // cache the FIRST one and always serve that — otherwise the init the player
1806
1881
  // fetched could differ from a later run's, breaking playback after a seek.
1882
+ //
1883
+ // ffmpeg creates init.mp4 before it has finished writing the fMP4 header
1884
+ // boxes into it (unlike segments, its write is not gated behind an atomic
1885
+ // rename), so a read can race a moment where the file EXISTS but is still
1886
+ // EMPTY. Root cause of a real incident: that empty read used to be cached
1887
+ // as `session.initBytes` — a zero-length Buffer is still a truthy object,
1888
+ // so `if (session.initBytes)` treated it as "already resolved" and served
1889
+ // the empty file for the rest of the session's life, permanently breaking
1890
+ // playback (hls.js can never initialize its SourceBuffer from an empty
1891
+ // init segment) while the transcode itself kept encoding normally. Guard
1892
+ // on non-empty content on both the cache check and the fresh read, so an
1893
+ // empty read is treated as not-yet-ready and the caller's long-poll keeps
1894
+ // retrying until ffmpeg has actually written the header.
1807
1895
  if (fileName === SEGMENT_INIT_FILE_NAME) {
1808
- if (session.initBytes) {
1896
+ if (session.initBytes && session.initBytes.length > 0) {
1809
1897
  return { kind: "file", stream: Readable.from([session.initBytes]), contentType: "video/mp4", isPlaylist: false };
1810
1898
  }
1811
1899
  try {
1812
1900
  const bytes = await readFile(path.join(session.dirPath, SEGMENT_INIT_FILE_NAME));
1901
+ if (bytes.length === 0) {
1902
+ return { kind: "warming-up" };
1903
+ }
1813
1904
  session.initBytes = bytes;
1814
1905
  return { kind: "file", stream: Readable.from([bytes]), contentType: "video/mp4", isPlaylist: false };
1815
1906
  } catch {
@@ -727,6 +727,16 @@ export class TorrentPool {
727
727
  *
728
728
  * @param {import("webtorrent").Torrent} torrent
729
729
  * @param {number | null} [fileIndex] - Zero-based file index, or null for torrent-level only.
730
+ * @param {{ resumeAnchorByteStart?: number | null }} [options] - `resumeAnchorByteStart`
731
+ * pins the resume window to a FIXED byte offset instead of the live (moving)
732
+ * read position. Without it, the window is anchored to wherever the file is
733
+ * CURRENTLY being read from — which slides forward as playback/encoding
734
+ * advances, so "bytes still needed" can jump up mid-poll even though nothing
735
+ * regressed (the window just moved past already-downloaded pieces into
736
+ * fresh ones). The caller should capture the returned `resumeAnchorByteStart`
737
+ * on the FIRST poll of a buffering episode and pass it back on subsequent
738
+ * polls of that SAME episode, so "bytes needed" counts down monotonically
739
+ * against a fixed target instead of chasing a moving one.
730
740
  * @returns {{
731
741
  * numPeers: number,
732
742
  * downloadSpeed: number,
@@ -736,7 +746,7 @@ export class TorrentPool {
736
746
  * fileLength: number | null
737
747
  * }}
738
748
  */
739
- getFileStats(torrent, fileIndex = null) {
749
+ getFileStats(torrent, fileIndex = null, options = {}) {
740
750
  const numPeers = typeof torrent?.numPeers === "number" ? torrent.numPeers : 0;
741
751
  const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
742
752
  const uploadSpeed = typeof torrent?.uploadSpeed === "number" ? torrent.uploadSpeed : 0;
@@ -754,10 +764,14 @@ export class TorrentPool {
754
764
 
755
765
  const header = this.#getHeaderRangeProgress(torrent, file);
756
766
 
757
- // Bytes still to download in the window ahead of where this file is being
758
- // read — "how much left to resume". Null until a read position is known.
767
+ // Bytes still to download in the window ahead of the anchor point "how
768
+ // much left to resume". Null until a read position is known. The anchor is
769
+ // the caller-supplied FROZEN offset when given (see JSDoc above), otherwise
770
+ // the live (moving) read position tracked from /stream range requests.
759
771
  const readPositions = this.#readPositionByTorrent.get(torrent);
760
- const readByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
772
+ const liveReadByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
773
+ const requestedAnchor = options?.resumeAnchorByteStart;
774
+ const readByteStart = Number.isFinite(requestedAnchor) ? requestedAnchor : liveReadByteStart;
761
775
  const resume = typeof readByteStart === "number"
762
776
  ? this.#getResumeWindowProgress(torrent, file, readByteStart)
763
777
  : null;
@@ -772,9 +786,11 @@ export class TorrentPool {
772
786
  fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
773
787
  fileDownloaded,
774
788
  fileLength,
775
- // Resume window (ahead of the read head): bytes needed vs downloaded.
789
+ // Resume window (ahead of the anchor): bytes needed vs downloaded, plus
790
+ // the anchor itself so the caller can pin it for the rest of one episode.
776
791
  resumeNeededBytes: resume ? resume.totalBytes : null,
777
792
  resumeDownloadedBytes: resume ? resume.downloadedBytes : null,
793
+ resumeAnchorByteStart: typeof readByteStart === "number" ? readByteStart : null,
778
794
  // Phase-1 progress: how much of the header/index region (the bytes the
779
795
  // codec probe needs before transcoding can start) is downloaded. Counted
780
796
  // by whole pieces from the torrent bitfield, so it advances coarsely