@torrent-tv/proxy 2.9.132 → 2.9.134

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.134
2
+
3
+ - **Fix**: The line reporting how long a segment waited before anyone decided to restart for it now prints. A restart backs off a segment or two from what was asked for, so the request that prompted it is recorded under a higher index than the run starts at; looking it up by the start index alone found nothing, and the instrument added in 2.9.132 never said a word. It now takes the earliest request at or above the index the run begins from.
4
+
5
+ ## 2.9.133
6
+
7
+ - **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.
8
+ - **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.
9
+
1
10
  ## 2.9.132
2
11
 
3
12
  - **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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.132",
3
+ "version": "2.9.134",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1530,7 +1530,9 @@ export class HlsSessionManager {
1530
1530
  }
1531
1531
  let names;
1532
1532
  try {
1533
- names = (await readdir(session.dirPath))
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 init = session.segmentFormat.extractInit(await readFile(path.join(session.dirPath, name)));
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 = await readdir(session.dirPath);
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 st = await stat(path.join(session.dirPath, session.segmentFormat.segmentFileName(index)));
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 readdirSync(session.dirPath, { withFileTypes: false })) {
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,28 @@ 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
+ // The restart backs off a segment or two from what was asked for, so the
2277
+ // request that prompted it is recorded under a HIGHER index than the run
2278
+ // starts at. Looking it up by the start index alone found nothing and the
2279
+ // line never printed once.
2280
+ let wantedAt = null;
2281
+ for (const [index, at] of session.firstWantedAt ?? []) {
2282
+ if (index >= startIndex && (wantedAt === null || at < wantedAt)) {
2283
+ wantedAt = at;
2284
+ }
2285
+ }
2286
+ if (typeof wantedAt === "number") {
2287
+ logger.info(
2288
+ `transcode ${session.id} restart for #${startIndex} decided ` +
2289
+ `${restartEnteredAt - wantedAt}ms after it was first asked for`
2290
+ );
2291
+ }
2256
2292
  const generation = ++session.encodeRunGeneration;
2257
2293
  const previousFfmpeg = session.ffmpeg;
2258
2294
  // A suspended process does not act on SIGTERM until it is continued, so the
@@ -2264,11 +2300,16 @@ export class HlsSessionManager {
2264
2300
  } catch {
2265
2301
  // Best effort.
2266
2302
  }
2303
+ // NOT awaited. The previous run has its own directory, so it cannot
2304
+ // corrupt this one's output by still writing; letting it die in the
2305
+ // background removes 0.7-1.3 s from every seek (measured 2.9.132, where
2306
+ // that wait was essentially the whole cost of a restart).
2267
2307
  const termSentAt = Date.now();
2268
- await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
2269
- logger.info(
2270
- `transcode ${session.id} restart: previous run took ${Date.now() - termSentAt}ms to exit after SIGTERM`
2271
- );
2308
+ void waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS).then(() => {
2309
+ logger.info(
2310
+ `transcode ${session.id} restart: previous run took ${Date.now() - termSentAt}ms to exit after SIGTERM`
2311
+ );
2312
+ });
2272
2313
  if (!hasChildExited(previousFfmpeg)) {
2273
2314
  try {
2274
2315
  previousFfmpeg.kill("SIGKILL");
@@ -2477,7 +2518,7 @@ export class HlsSessionManager {
2477
2518
  logger.info(`transcode ${session.id} ${runLabel} ffmpeg ${describeFfmpegArgs(args)}`);
2478
2519
 
2479
2520
  const ffmpeg = spawn(this.ffmpegBin, args, {
2480
- cwd: session.dirPath,
2521
+ cwd: session.runDirPath ?? session.dirPath,
2481
2522
  stdio: ["ignore", "pipe", "pipe"]
2482
2523
  });
2483
2524
  session.ffmpeg = ffmpeg;
@@ -2768,6 +2809,14 @@ export class HlsSessionManager {
2768
2809
  * @returns {void}
2769
2810
  */
2770
2811
  #ensureEncodingFor(session, index, requestSeq = Number.MAX_SAFE_INTEGER) {
2812
+ // When this segment was FIRST asked for and nobody was producing it. The
2813
+ // restart itself costs 0.7-1.3 s (measured 2.9.132), while a seek costs
2814
+ // 5-8 s end to end — so most of the wait happens before a restart is even
2815
+ // decided on, and that is what this records.
2816
+ session.firstWantedAt ??= new Map();
2817
+ if (!session.firstWantedAt.has(index)) {
2818
+ session.firstWantedAt.set(index, Date.now());
2819
+ }
2771
2820
  if (!session || session.state === "disposed" || index < 0) {
2772
2821
  return;
2773
2822
  }
@@ -3140,7 +3189,9 @@ export class HlsSessionManager {
3140
3189
  */
3141
3190
  #latestProducedSegment(session) {
3142
3191
  let highest = null;
3143
- for (const name of readdirSync(session.dirPath, { withFileTypes: false })) {
3192
+ for (const name of this.#runDirs(session).flatMap((dir) => {
3193
+ try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
3194
+ })) {
3144
3195
  if (!this.segmentFormat.isSegmentFileName(name)) {
3145
3196
  continue;
3146
3197
  }
@@ -3343,7 +3394,7 @@ export class HlsSessionManager {
3343
3394
  // piece, so the first one to exist supplies it.
3344
3395
  const bytes = session.usesExplicitCuts
3345
3396
  ? await this.#initFromFirstSegment(session)
3346
- : await readFile(path.join(session.dirPath, initFileName));
3397
+ : await readFile((await this.#findProducedFile(session, initFileName)) ?? path.join(session.dirPath, initFileName));
3347
3398
  if (!bytes || bytes.length === 0) {
3348
3399
  return { kind: "warming-up" };
3349
3400
  }
@@ -3371,7 +3422,7 @@ export class HlsSessionManager {
3371
3422
  }
3372
3423
  }
3373
3424
 
3374
- const filePath = path.join(session.dirPath, fileName);
3425
+ const filePath = (await this.#findProducedFile(session, fileName)) ?? path.join(session.dirPath, fileName);
3375
3426
  const isPlaylist = fileName === PLAYLIST_FILE_NAME;
3376
3427
  if (!isPlaylist) {
3377
3428
  // Where the viewer actually is. Recorded for every segment request,
@@ -3413,7 +3464,8 @@ export class HlsSessionManager {
3413
3464
  const index = session.segmentFormat.segmentIndexFromName(fileName);
3414
3465
  const isLast = index >= Math.max(0, (session.segmentBoundaries?.length ?? 1) - 2);
3415
3466
  if (!isLast && session.ffmpeg) {
3416
- const nextPath = path.join(session.dirPath, session.segmentFormat.segmentFileName(index + 1));
3467
+ const nextName = session.segmentFormat.segmentFileName(index + 1);
3468
+ const nextPath = (await this.#findProducedFile(session, nextName)) ?? path.join(session.dirPath, nextName);
3417
3469
  // The FIRST segment of a run cannot be judged by "the next one
3418
3470
  // exists": nothing is producing a next one yet, because the run has
3419
3471
  // only just begun here. Waiting for it holds precisely the segment a
@@ -3624,6 +3676,55 @@ export class HlsSessionManager {
3624
3676
  );
3625
3677
  }
3626
3678
 
3679
+ /**
3680
+ * The directories runs have written into, newest first.
3681
+ *
3682
+ * Runs used to share one directory, which is why a restart had to wait for
3683
+ * the previous ffmpeg to die: two processes writing `segment-00042.mp4` at
3684
+ * once produce a file that is neither. Measured 2.9.132, that wait was
3685
+ * 0.7-1.3 s of every seek and essentially the whole cost of a restart.
3686
+ * Given a directory each they cannot collide, so the new run starts at once
3687
+ * and the old one is left to die in the background.
3688
+ *
3689
+ * Newest first because a later run's answer for a segment supersedes an
3690
+ * earlier one's: the older file may be the truncated output of a run that was
3691
+ * killed mid-write, which is exactly what sharing a directory used to hide.
3692
+ *
3693
+ * @param {HlsSession} session
3694
+ * @returns {string[]}
3695
+ */
3696
+ #runDirs(session) {
3697
+ try {
3698
+ return readdirSync(session.dirPath, { withFileTypes: true })
3699
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
3700
+ .map((entry) => entry.name)
3701
+ .sort((a, b) => Number(b.slice(4)) - Number(a.slice(4)))
3702
+ .map((name) => path.join(session.dirPath, name));
3703
+ } catch {
3704
+ return [];
3705
+ }
3706
+ }
3707
+
3708
+ /**
3709
+ * Where a produced file actually is, or null when no run has written it.
3710
+ *
3711
+ * @param {HlsSession} session
3712
+ * @param {string} fileName
3713
+ * @returns {Promise<string | null>}
3714
+ */
3715
+ async #findProducedFile(session, fileName) {
3716
+ for (const dir of this.#runDirs(session)) {
3717
+ const candidate = path.join(dir, fileName);
3718
+ try {
3719
+ await access(candidate);
3720
+ return candidate;
3721
+ } catch {
3722
+ // Not this run's; try an older one.
3723
+ }
3724
+ }
3725
+ return null;
3726
+ }
3727
+
3627
3728
  #holdForProduction(session, fileName, isPlaylist, options) {
3628
3729
  if (!isPlaylist) {
3629
3730
  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
+ });