@torrent-tv/proxy 2.9.132 → 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 +5 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +106 -14
- package/test/segment-serve-wiring.test.js +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 2.9.132
|
|
2
7
|
|
|
3
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.
|
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
|
}
|
|
@@ -2253,6 +2267,19 @@ export class HlsSessionManager {
|
|
|
2253
2267
|
// 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
|
|
2254
2268
|
// in the proxy on a guess, make each stage state its own cost.
|
|
2255
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
|
+
}
|
|
2256
2283
|
const generation = ++session.encodeRunGeneration;
|
|
2257
2284
|
const previousFfmpeg = session.ffmpeg;
|
|
2258
2285
|
// A suspended process does not act on SIGTERM until it is continued, so the
|
|
@@ -2264,11 +2291,16 @@ export class HlsSessionManager {
|
|
|
2264
2291
|
} catch {
|
|
2265
2292
|
// Best effort.
|
|
2266
2293
|
}
|
|
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).
|
|
2267
2298
|
const termSentAt = Date.now();
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
+
});
|
|
2272
2304
|
if (!hasChildExited(previousFfmpeg)) {
|
|
2273
2305
|
try {
|
|
2274
2306
|
previousFfmpeg.kill("SIGKILL");
|
|
@@ -2477,7 +2509,7 @@ export class HlsSessionManager {
|
|
|
2477
2509
|
logger.info(`transcode ${session.id} ${runLabel} ffmpeg ${describeFfmpegArgs(args)}`);
|
|
2478
2510
|
|
|
2479
2511
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
2480
|
-
cwd: session.dirPath,
|
|
2512
|
+
cwd: session.runDirPath ?? session.dirPath,
|
|
2481
2513
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2482
2514
|
});
|
|
2483
2515
|
session.ffmpeg = ffmpeg;
|
|
@@ -2768,6 +2800,14 @@ export class HlsSessionManager {
|
|
|
2768
2800
|
* @returns {void}
|
|
2769
2801
|
*/
|
|
2770
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
|
+
}
|
|
2771
2811
|
if (!session || session.state === "disposed" || index < 0) {
|
|
2772
2812
|
return;
|
|
2773
2813
|
}
|
|
@@ -3140,7 +3180,9 @@ export class HlsSessionManager {
|
|
|
3140
3180
|
*/
|
|
3141
3181
|
#latestProducedSegment(session) {
|
|
3142
3182
|
let highest = null;
|
|
3143
|
-
for (const name of
|
|
3183
|
+
for (const name of this.#runDirs(session).flatMap((dir) => {
|
|
3184
|
+
try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
|
|
3185
|
+
})) {
|
|
3144
3186
|
if (!this.segmentFormat.isSegmentFileName(name)) {
|
|
3145
3187
|
continue;
|
|
3146
3188
|
}
|
|
@@ -3343,7 +3385,7 @@ export class HlsSessionManager {
|
|
|
3343
3385
|
// piece, so the first one to exist supplies it.
|
|
3344
3386
|
const bytes = session.usesExplicitCuts
|
|
3345
3387
|
? await this.#initFromFirstSegment(session)
|
|
3346
|
-
: await readFile(path.join(session.dirPath, initFileName));
|
|
3388
|
+
: await readFile((await this.#findProducedFile(session, initFileName)) ?? path.join(session.dirPath, initFileName));
|
|
3347
3389
|
if (!bytes || bytes.length === 0) {
|
|
3348
3390
|
return { kind: "warming-up" };
|
|
3349
3391
|
}
|
|
@@ -3371,7 +3413,7 @@ export class HlsSessionManager {
|
|
|
3371
3413
|
}
|
|
3372
3414
|
}
|
|
3373
3415
|
|
|
3374
|
-
const filePath = path.join(session.dirPath, fileName);
|
|
3416
|
+
const filePath = (await this.#findProducedFile(session, fileName)) ?? path.join(session.dirPath, fileName);
|
|
3375
3417
|
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
3376
3418
|
if (!isPlaylist) {
|
|
3377
3419
|
// Where the viewer actually is. Recorded for every segment request,
|
|
@@ -3413,7 +3455,8 @@ export class HlsSessionManager {
|
|
|
3413
3455
|
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
3414
3456
|
const isLast = index >= Math.max(0, (session.segmentBoundaries?.length ?? 1) - 2);
|
|
3415
3457
|
if (!isLast && session.ffmpeg) {
|
|
3416
|
-
const
|
|
3458
|
+
const nextName = session.segmentFormat.segmentFileName(index + 1);
|
|
3459
|
+
const nextPath = (await this.#findProducedFile(session, nextName)) ?? path.join(session.dirPath, nextName);
|
|
3417
3460
|
// The FIRST segment of a run cannot be judged by "the next one
|
|
3418
3461
|
// exists": nothing is producing a next one yet, because the run has
|
|
3419
3462
|
// only just begun here. Waiting for it holds precisely the segment a
|
|
@@ -3624,6 +3667,55 @@ export class HlsSessionManager {
|
|
|
3624
3667
|
);
|
|
3625
3668
|
}
|
|
3626
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
|
+
|
|
3627
3719
|
#holdForProduction(session, fileName, isPlaylist, options) {
|
|
3628
3720
|
if (!isPlaylist) {
|
|
3629
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
|
+
});
|