@torrent-tv/proxy 2.9.131 → 2.9.133
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 +9 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +113 -11
- package/test/segment-serve-wiring.test.js +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.133
|
|
2
|
+
|
|
3
|
+
- **Fix**: A restarted encoder no longer waits for its predecessor to die — every seek is about a second shorter. Runs shared one output directory, so two of them writing `segment-00042.mp4` at once would produce a file that is neither; the only defence was to kill the old run and block until it was gone. Measured 2.9.132 across four seeks: 712, 852, 882 and 1297 ms, against 11-15 ms of everything else a restart does. So that wait WAS the restart. Each run now writes into a directory of its own, which makes the collision impossible, so the new run starts at once and the old one is left to die in the background. Serving a segment searches the run directories newest-first, because a later run's answer supersedes an earlier one's — the older file may be the truncated output of a run that was killed mid-write, which is precisely what sharing a directory used to hide. Covered by a test that lays out two runs and insists the newer one wins.
|
|
4
|
+
- **New**: How long a segment waited before anyone decided to restart for it. The restart costs about a second; a seek costs five to eight, so most of the wait happens before the decision is even taken, and nothing measured that gap.
|
|
5
|
+
|
|
6
|
+
## 2.9.132
|
|
7
|
+
|
|
8
|
+
- **New**: A restarted encoder run says what its restart cost, and how much of that was waiting for the previous run to die. A seek costs 5-8 s in the field, and the reason on record — waiting for the previous ffmpeg to exit, measured once at 0.54-1.47 s — does not account for it. The remedy under consideration is a separate output directory per run, which removes the wait entirely but makes serving a segment a search across runs: the hottest path in the proxy, rebuilt on a guess about where the seconds go. So each stage states its own cost first. Two lines: how long SIGTERM took to be obeyed, and the total from the restart being asked for to the new run being announced.
|
|
9
|
+
|
|
1
10
|
## 2.9.131
|
|
2
11
|
|
|
3
12
|
- **Fix**: The first segment of an encoder run is served once the encoder has passed it, instead of waiting for a successor nobody is producing. A segment counted as finished only when the NEXT one had been started — sound while a run moves forward through a file, and meaningless for the segment a run BEGINS at, because the run has only just arrived there. That is precisely the segment a resume or a seek depends on. Measured 2026-08-09 with the hold instrument: `#807 exists, but the next segment (#808) has not been started yet`, held while it lay complete on disk; in August the same shape held `#317` for 46 s and then answered 404 to a browser that had already given up, three releases in a row. A run whose reported output position is past a segment's end has necessarily closed that segment, so that is what decides it now. Covered by a test that builds exactly the resume shape — a run start with no successor on disk — and insists on the bytes.
|
package/package.json
CHANGED
|
@@ -1530,7 +1530,9 @@ export class HlsSessionManager {
|
|
|
1530
1530
|
}
|
|
1531
1531
|
let names;
|
|
1532
1532
|
try {
|
|
1533
|
-
names = (
|
|
1533
|
+
names = (this.#runDirs(session).flatMap((dir) => {
|
|
1534
|
+
try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
|
|
1535
|
+
}))
|
|
1534
1536
|
.filter((name) => session.segmentFormat.isSegmentFileName(name))
|
|
1535
1537
|
.sort();
|
|
1536
1538
|
} catch {
|
|
@@ -1538,7 +1540,11 @@ export class HlsSessionManager {
|
|
|
1538
1540
|
}
|
|
1539
1541
|
for (const name of names) {
|
|
1540
1542
|
try {
|
|
1541
|
-
const
|
|
1543
|
+
const found = await this.#findProducedFile(session, name);
|
|
1544
|
+
if (!found) {
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
const init = session.segmentFormat.extractInit(await readFile(found));
|
|
1542
1548
|
if (init && init.length > 0) {
|
|
1543
1549
|
return init;
|
|
1544
1550
|
}
|
|
@@ -1764,7 +1770,9 @@ export class HlsSessionManager {
|
|
|
1764
1770
|
async #observedStreamMbps(session) {
|
|
1765
1771
|
let names;
|
|
1766
1772
|
try {
|
|
1767
|
-
names =
|
|
1773
|
+
names = this.#runDirs(session).flatMap((dir) => {
|
|
1774
|
+
try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
|
|
1775
|
+
});
|
|
1768
1776
|
} catch {
|
|
1769
1777
|
return null;
|
|
1770
1778
|
}
|
|
@@ -1783,7 +1791,11 @@ export class HlsSessionManager {
|
|
|
1783
1791
|
let bytes = 0;
|
|
1784
1792
|
try {
|
|
1785
1793
|
for (const index of completed) {
|
|
1786
|
-
const
|
|
1794
|
+
const segmentPath = await this.#findProducedFile(session, session.segmentFormat.segmentFileName(index));
|
|
1795
|
+
if (!segmentPath) {
|
|
1796
|
+
break;
|
|
1797
|
+
}
|
|
1798
|
+
const st = await stat(segmentPath);
|
|
1787
1799
|
bytes += st.size;
|
|
1788
1800
|
}
|
|
1789
1801
|
} catch {
|
|
@@ -1999,7 +2011,9 @@ export class HlsSessionManager {
|
|
|
1999
2011
|
let present;
|
|
2000
2012
|
try {
|
|
2001
2013
|
present = new Set();
|
|
2002
|
-
for (const name of
|
|
2014
|
+
for (const name of this.#runDirs(session).flatMap((dir) => {
|
|
2015
|
+
try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
|
|
2016
|
+
})) {
|
|
2003
2017
|
if (!this.segmentFormat.isSegmentFileName(name)) {
|
|
2004
2018
|
continue;
|
|
2005
2019
|
}
|
|
@@ -2248,6 +2262,24 @@ export class HlsSessionManager {
|
|
|
2248
2262
|
* @returns {Promise<void>}
|
|
2249
2263
|
*/
|
|
2250
2264
|
async #startEncodeRun(session, startIndex) {
|
|
2265
|
+
// Where a restart's seconds go. A seek costs 5-8 s in the field and the
|
|
2266
|
+
// recorded reason — waiting for the previous ffmpeg to exit, measured at
|
|
2267
|
+
// 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
|
|
2268
|
+
// in the proxy on a guess, make each stage state its own cost.
|
|
2269
|
+
const restartEnteredAt = Date.now();
|
|
2270
|
+
// One directory per run. Two runs writing the same segment name at once
|
|
2271
|
+
// produce a file that is neither, which is the only reason a restart ever
|
|
2272
|
+
// had to wait for its predecessor to die.
|
|
2273
|
+
session.runSerial = (session.runSerial ?? 0) + 1;
|
|
2274
|
+
session.runDirPath = path.join(session.dirPath, `run-${session.runSerial}`);
|
|
2275
|
+
await mkdir(session.runDirPath, { recursive: true });
|
|
2276
|
+
const wantedAt = session.firstWantedAt?.get(startIndex);
|
|
2277
|
+
if (typeof wantedAt === "number") {
|
|
2278
|
+
logger.info(
|
|
2279
|
+
`transcode ${session.id} restart for #${startIndex} decided ` +
|
|
2280
|
+
`${restartEnteredAt - wantedAt}ms after it was first asked for`
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2251
2283
|
const generation = ++session.encodeRunGeneration;
|
|
2252
2284
|
const previousFfmpeg = session.ffmpeg;
|
|
2253
2285
|
// A suspended process does not act on SIGTERM until it is continued, so the
|
|
@@ -2259,7 +2291,16 @@ export class HlsSessionManager {
|
|
|
2259
2291
|
} catch {
|
|
2260
2292
|
// Best effort.
|
|
2261
2293
|
}
|
|
2262
|
-
|
|
2294
|
+
// NOT awaited. The previous run has its own directory, so it cannot
|
|
2295
|
+
// corrupt this one's output by still writing; letting it die in the
|
|
2296
|
+
// background removes 0.7-1.3 s from every seek (measured 2.9.132, where
|
|
2297
|
+
// that wait was essentially the whole cost of a restart).
|
|
2298
|
+
const termSentAt = Date.now();
|
|
2299
|
+
void waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS).then(() => {
|
|
2300
|
+
logger.info(
|
|
2301
|
+
`transcode ${session.id} restart: previous run took ${Date.now() - termSentAt}ms to exit after SIGTERM`
|
|
2302
|
+
);
|
|
2303
|
+
});
|
|
2263
2304
|
if (!hasChildExited(previousFfmpeg)) {
|
|
2264
2305
|
try {
|
|
2265
2306
|
previousFfmpeg.kill("SIGKILL");
|
|
@@ -2468,7 +2509,7 @@ export class HlsSessionManager {
|
|
|
2468
2509
|
logger.info(`transcode ${session.id} ${runLabel} ffmpeg ${describeFfmpegArgs(args)}`);
|
|
2469
2510
|
|
|
2470
2511
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
2471
|
-
cwd: session.dirPath,
|
|
2512
|
+
cwd: session.runDirPath ?? session.dirPath,
|
|
2472
2513
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2473
2514
|
});
|
|
2474
2515
|
session.ffmpeg = ffmpeg;
|
|
@@ -2492,6 +2533,7 @@ export class HlsSessionManager {
|
|
|
2492
2533
|
|
|
2493
2534
|
logger.info(
|
|
2494
2535
|
`transcode ${session.id} ${session.runLabel} encode-run from segment #${safeIndex} ` +
|
|
2536
|
+
`(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
|
|
2495
2537
|
`(${formatSeconds(startSeconds)}) "${session.fileName}"`
|
|
2496
2538
|
);
|
|
2497
2539
|
|
|
@@ -2758,6 +2800,14 @@ export class HlsSessionManager {
|
|
|
2758
2800
|
* @returns {void}
|
|
2759
2801
|
*/
|
|
2760
2802
|
#ensureEncodingFor(session, index, requestSeq = Number.MAX_SAFE_INTEGER) {
|
|
2803
|
+
// When this segment was FIRST asked for and nobody was producing it. The
|
|
2804
|
+
// restart itself costs 0.7-1.3 s (measured 2.9.132), while a seek costs
|
|
2805
|
+
// 5-8 s end to end — so most of the wait happens before a restart is even
|
|
2806
|
+
// decided on, and that is what this records.
|
|
2807
|
+
session.firstWantedAt ??= new Map();
|
|
2808
|
+
if (!session.firstWantedAt.has(index)) {
|
|
2809
|
+
session.firstWantedAt.set(index, Date.now());
|
|
2810
|
+
}
|
|
2761
2811
|
if (!session || session.state === "disposed" || index < 0) {
|
|
2762
2812
|
return;
|
|
2763
2813
|
}
|
|
@@ -3130,7 +3180,9 @@ export class HlsSessionManager {
|
|
|
3130
3180
|
*/
|
|
3131
3181
|
#latestProducedSegment(session) {
|
|
3132
3182
|
let highest = null;
|
|
3133
|
-
for (const name of
|
|
3183
|
+
for (const name of this.#runDirs(session).flatMap((dir) => {
|
|
3184
|
+
try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
|
|
3185
|
+
})) {
|
|
3134
3186
|
if (!this.segmentFormat.isSegmentFileName(name)) {
|
|
3135
3187
|
continue;
|
|
3136
3188
|
}
|
|
@@ -3333,7 +3385,7 @@ export class HlsSessionManager {
|
|
|
3333
3385
|
// piece, so the first one to exist supplies it.
|
|
3334
3386
|
const bytes = session.usesExplicitCuts
|
|
3335
3387
|
? await this.#initFromFirstSegment(session)
|
|
3336
|
-
: await readFile(path.join(session.dirPath, initFileName));
|
|
3388
|
+
: await readFile((await this.#findProducedFile(session, initFileName)) ?? path.join(session.dirPath, initFileName));
|
|
3337
3389
|
if (!bytes || bytes.length === 0) {
|
|
3338
3390
|
return { kind: "warming-up" };
|
|
3339
3391
|
}
|
|
@@ -3361,7 +3413,7 @@ export class HlsSessionManager {
|
|
|
3361
3413
|
}
|
|
3362
3414
|
}
|
|
3363
3415
|
|
|
3364
|
-
const filePath = path.join(session.dirPath, fileName);
|
|
3416
|
+
const filePath = (await this.#findProducedFile(session, fileName)) ?? path.join(session.dirPath, fileName);
|
|
3365
3417
|
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
3366
3418
|
if (!isPlaylist) {
|
|
3367
3419
|
// Where the viewer actually is. Recorded for every segment request,
|
|
@@ -3403,7 +3455,8 @@ export class HlsSessionManager {
|
|
|
3403
3455
|
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
3404
3456
|
const isLast = index >= Math.max(0, (session.segmentBoundaries?.length ?? 1) - 2);
|
|
3405
3457
|
if (!isLast && session.ffmpeg) {
|
|
3406
|
-
const
|
|
3458
|
+
const nextName = session.segmentFormat.segmentFileName(index + 1);
|
|
3459
|
+
const nextPath = (await this.#findProducedFile(session, nextName)) ?? path.join(session.dirPath, nextName);
|
|
3407
3460
|
// The FIRST segment of a run cannot be judged by "the next one
|
|
3408
3461
|
// exists": nothing is producing a next one yet, because the run has
|
|
3409
3462
|
// only just begun here. Waiting for it holds precisely the segment a
|
|
@@ -3614,6 +3667,55 @@ export class HlsSessionManager {
|
|
|
3614
3667
|
);
|
|
3615
3668
|
}
|
|
3616
3669
|
|
|
3670
|
+
/**
|
|
3671
|
+
* The directories runs have written into, newest first.
|
|
3672
|
+
*
|
|
3673
|
+
* Runs used to share one directory, which is why a restart had to wait for
|
|
3674
|
+
* the previous ffmpeg to die: two processes writing `segment-00042.mp4` at
|
|
3675
|
+
* once produce a file that is neither. Measured 2.9.132, that wait was
|
|
3676
|
+
* 0.7-1.3 s of every seek and essentially the whole cost of a restart.
|
|
3677
|
+
* Given a directory each they cannot collide, so the new run starts at once
|
|
3678
|
+
* and the old one is left to die in the background.
|
|
3679
|
+
*
|
|
3680
|
+
* Newest first because a later run's answer for a segment supersedes an
|
|
3681
|
+
* earlier one's: the older file may be the truncated output of a run that was
|
|
3682
|
+
* killed mid-write, which is exactly what sharing a directory used to hide.
|
|
3683
|
+
*
|
|
3684
|
+
* @param {HlsSession} session
|
|
3685
|
+
* @returns {string[]}
|
|
3686
|
+
*/
|
|
3687
|
+
#runDirs(session) {
|
|
3688
|
+
try {
|
|
3689
|
+
return readdirSync(session.dirPath, { withFileTypes: true })
|
|
3690
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
|
|
3691
|
+
.map((entry) => entry.name)
|
|
3692
|
+
.sort((a, b) => Number(b.slice(4)) - Number(a.slice(4)))
|
|
3693
|
+
.map((name) => path.join(session.dirPath, name));
|
|
3694
|
+
} catch {
|
|
3695
|
+
return [];
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
/**
|
|
3700
|
+
* Where a produced file actually is, or null when no run has written it.
|
|
3701
|
+
*
|
|
3702
|
+
* @param {HlsSession} session
|
|
3703
|
+
* @param {string} fileName
|
|
3704
|
+
* @returns {Promise<string | null>}
|
|
3705
|
+
*/
|
|
3706
|
+
async #findProducedFile(session, fileName) {
|
|
3707
|
+
for (const dir of this.#runDirs(session)) {
|
|
3708
|
+
const candidate = path.join(dir, fileName);
|
|
3709
|
+
try {
|
|
3710
|
+
await access(candidate);
|
|
3711
|
+
return candidate;
|
|
3712
|
+
} catch {
|
|
3713
|
+
// Not this run's; try an older one.
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
return null;
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3617
3719
|
#holdForProduction(session, fileName, isPlaylist, options) {
|
|
3618
3720
|
if (!isPlaylist) {
|
|
3619
3721
|
this.#explainHold(session, fileName, "the file is not on disk");
|
|
@@ -226,3 +226,37 @@ test("a run's FIRST segment is served once the encoder has passed it, without wa
|
|
|
226
226
|
"the encoder is past this segment's end, so it is finished — the absence of a next one says nothing"
|
|
227
227
|
);
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
test("a segment is found in the run directory that produced it, newest run first", async (t) => {
|
|
231
|
+
const { manager, session, dirPath } = await managerWithReadySegment();
|
|
232
|
+
t.after(async () => {
|
|
233
|
+
await manager.disposeAll();
|
|
234
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
235
|
+
});
|
|
236
|
+
// Runs write into a directory each — that is what lets a restart begin
|
|
237
|
+
// without waiting for its predecessor to die, which measured 0.7-1.3 s of
|
|
238
|
+
// every seek. A later run's answer supersedes an earlier one's, because the
|
|
239
|
+
// older file may be the truncated output of a run that was killed mid-write.
|
|
240
|
+
const { mkdir } = await import("node:fs/promises");
|
|
241
|
+
const piece = selfContainedPiece(SEGMENT_START_SECONDS);
|
|
242
|
+
await mkdir(path.join(dirPath, "run-1"), { recursive: true });
|
|
243
|
+
await mkdir(path.join(dirPath, "run-2"), { recursive: true });
|
|
244
|
+
await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
|
|
245
|
+
await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
|
|
246
|
+
await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
|
|
247
|
+
await rm(path.join(dirPath, "segment-00000.mp4"));
|
|
248
|
+
await rm(path.join(dirPath, "segment-00001.mp4"));
|
|
249
|
+
session.encodeStartIndex = 0;
|
|
250
|
+
|
|
251
|
+
const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
|
|
252
|
+
|
|
253
|
+
assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
|
|
254
|
+
const chunks = [];
|
|
255
|
+
for await (const chunk of result.stream) {
|
|
256
|
+
chunks.push(chunk);
|
|
257
|
+
}
|
|
258
|
+
assert.ok(
|
|
259
|
+
Buffer.concat(chunks).length > 8,
|
|
260
|
+
"the newest run's output must win — the older file here is the 8-byte stub a killed run leaves"
|
|
261
|
+
);
|
|
262
|
+
});
|