@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.131",
3
+ "version": "2.9.133",
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
  }
@@ -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
- await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
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 readdirSync(session.dirPath, { withFileTypes: false })) {
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 nextPath = path.join(session.dirPath, session.segmentFormat.segmentFileName(index + 1));
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
+ });