@torrent-tv/proxy 2.9.47 → 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,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 2.9.47
|
|
2
7
|
|
|
3
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.
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -68,6 +68,10 @@ const SEEK_SETTLE_MS = 1_200;
|
|
|
68
68
|
// Hard cap on the total settle wait, measured from the first far request of a
|
|
69
69
|
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
70
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;
|
|
71
75
|
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
72
76
|
// access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
|
|
73
77
|
// turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
|
|
@@ -161,6 +165,20 @@ function waitForChildExit(child, timeoutMs = 2_000) {
|
|
|
161
165
|
});
|
|
162
166
|
}
|
|
163
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
|
+
|
|
164
182
|
/**
|
|
165
183
|
* Convert a bind-all host address to the loopback address so that
|
|
166
184
|
* the HLS input URL is always reachable from the same machine.
|
|
@@ -600,6 +618,9 @@ function normalizeLogFileName(fileName, fileIndex) {
|
|
|
600
618
|
* @property {string} lastError
|
|
601
619
|
* @property {Set<string>} consumers - Consumer IDs currently using this session.
|
|
602
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.
|
|
603
624
|
*/
|
|
604
625
|
|
|
605
626
|
/**
|
|
@@ -878,6 +899,7 @@ export class HlsSessionManager {
|
|
|
878
899
|
startedAt: Date.now(),
|
|
879
900
|
lastAccessedAt: Date.now(),
|
|
880
901
|
ffmpeg: null,
|
|
902
|
+
encodeRunGeneration: 0,
|
|
881
903
|
lastError: "",
|
|
882
904
|
// Cold-start timing: entry timestamp + a once-guard so the first servable
|
|
883
905
|
// segment logs its latency exactly once.
|
|
@@ -979,7 +1001,7 @@ export class HlsSessionManager {
|
|
|
979
1001
|
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
980
1002
|
);
|
|
981
1003
|
|
|
982
|
-
this.#startEncodeRun(session, 0);
|
|
1004
|
+
await this.#startEncodeRun(session, 0);
|
|
983
1005
|
|
|
984
1006
|
try {
|
|
985
1007
|
await this.waitUntilReady(session);
|
|
@@ -1229,7 +1251,7 @@ export class HlsSessionManager {
|
|
|
1229
1251
|
if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
|
|
1230
1252
|
return false; // let the previous action settle
|
|
1231
1253
|
}
|
|
1232
|
-
this.#applyBudgetDownshift(
|
|
1254
|
+
await this.#applyBudgetDownshift(
|
|
1233
1255
|
session,
|
|
1234
1256
|
`link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
|
|
1235
1257
|
"link"
|
|
@@ -1301,7 +1323,7 @@ export class HlsSessionManager {
|
|
|
1301
1323
|
session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
|
|
1302
1324
|
continue;
|
|
1303
1325
|
}
|
|
1304
|
-
this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
|
|
1326
|
+
await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
|
|
1305
1327
|
}
|
|
1306
1328
|
}
|
|
1307
1329
|
|
|
@@ -1349,9 +1371,9 @@ export class HlsSessionManager {
|
|
|
1349
1371
|
* @param {HlsSession} session
|
|
1350
1372
|
* @param {string} reasonText - Measurement summary for the log line.
|
|
1351
1373
|
* @param {"cpu" | "unknown" | "link"} bound
|
|
1352
|
-
* @returns {void}
|
|
1374
|
+
* @returns {Promise<void>}
|
|
1353
1375
|
*/
|
|
1354
|
-
#applyBudgetDownshift(session, reasonText, bound) {
|
|
1376
|
+
async #applyBudgetDownshift(session, reasonText, bound) {
|
|
1355
1377
|
const nextIndex = session.budgetRungIndex + 1;
|
|
1356
1378
|
const rung = session.budgetLadder[nextIndex];
|
|
1357
1379
|
if (!rung) {
|
|
@@ -1380,22 +1402,66 @@ export class HlsSessionManager {
|
|
|
1380
1402
|
`(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
|
|
1381
1403
|
`restart at segment #${currentSeg} "${session.fileName}"`
|
|
1382
1404
|
);
|
|
1383
|
-
this.#startEncodeRun(session, currentSeg);
|
|
1405
|
+
await this.#startEncodeRun(session, currentSeg);
|
|
1384
1406
|
}
|
|
1385
1407
|
|
|
1386
1408
|
/**
|
|
1387
1409
|
* (Re)start the ffmpeg encode run beginning at segment `startIndex`.
|
|
1388
1410
|
*
|
|
1389
|
-
* Any ffmpeg process currently running for this session is terminated
|
|
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
|
+
*
|
|
1390
1431
|
* Segment files are named with a global index (`-start_number`) so they
|
|
1391
1432
|
* always line up with the synthetic VOD playlist regardless of where
|
|
1392
1433
|
* encoding started — this is what makes server-side seeking work.
|
|
1393
1434
|
*
|
|
1394
1435
|
* @param {HlsSession} session
|
|
1395
1436
|
* @param {number} startIndex
|
|
1396
|
-
* @returns {void}
|
|
1437
|
+
* @returns {Promise<void>}
|
|
1397
1438
|
*/
|
|
1398
|
-
#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
|
+
|
|
1399
1465
|
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
1400
1466
|
// 0-based output time of this segment, from the boundary table (uniform for
|
|
1401
1467
|
// re-encode, real keyframe for copy).
|
|
@@ -1640,7 +1706,7 @@ export class HlsSessionManager {
|
|
|
1640
1706
|
`transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
|
|
1641
1707
|
`(${session.lastError}); falling back to software libx264 and restarting`
|
|
1642
1708
|
);
|
|
1643
|
-
this.#startEncodeRun(session, session.encodeStartIndex);
|
|
1709
|
+
void this.#startEncodeRun(session, session.encodeStartIndex);
|
|
1644
1710
|
return;
|
|
1645
1711
|
}
|
|
1646
1712
|
session.state = "failed";
|
|
@@ -1722,7 +1788,7 @@ export class HlsSessionManager {
|
|
|
1722
1788
|
session.seekTarget = null;
|
|
1723
1789
|
session.seekFirstFarAt = 0;
|
|
1724
1790
|
logger.info(`transcode ${session.id} seek settle → restart at segment #${target}`);
|
|
1725
|
-
this.#startEncodeRun(session, target);
|
|
1791
|
+
void this.#startEncodeRun(session, target);
|
|
1726
1792
|
}
|
|
1727
1793
|
|
|
1728
1794
|
/**
|
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
|
758
|
-
//
|
|
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
|
|
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
|
|
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
|