@torrent-tv/proxy 2.107.57 → 2.107.58

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/bin/cli.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // for the measurement that made it necessary.
15
15
  import "./thread-pool.js";
16
16
  import { Command } from "commander";
17
+ import { mkdirSync } from "node:fs";
17
18
  import os from "node:os";
18
19
  import { spawnSync } from "node:child_process";
19
20
  import { createRequire } from "node:module";
@@ -35,6 +36,7 @@ import { collectHealthMetrics } from "../services/transport/health-collector.js"
35
36
  import { LOAD_REFRESH_MS, ProxyStateReporter } from "../services/transport/proxy-state.js";
36
37
  import { createPortMapper } from "../services/transport/port-mapper.js";
37
38
  import { installationId } from "../services/identity/installation-id.js";
39
+ import { PROXY_ROOT } from "../services/encode/LocalObservations.js";
38
40
  import { classifyNat } from "../services/transport/nat-classifier.js";
39
41
  import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/encode/segment-formats/index.js";
40
42
  import { logToFile, logger } from "../utils/logger.js";
@@ -100,7 +102,7 @@ program
100
102
  "--usrsctp-state",
101
103
  "Read usrsctp's association state with gdb when a wedge is declared. OFF by default: gdb attaches to THIS process and stops every thread of it while it works."
102
104
  )
103
- .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
105
+ .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default: derived from the memory available when the torrent opens, shared equally between open torrents)")
104
106
  .option("--budget <kind>", "How much of this machine the proxy may take: adaptive (default), share or fixed")
105
107
  .option("--budget-share <fraction>", "With --budget share: the fraction of what is free, 0..1")
106
108
  .option("--budget-bytes <bytes>", "With --budget fixed: the ceiling, per resource")
@@ -109,7 +111,7 @@ program
109
111
  .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
110
112
  .option(
111
113
  "--state-dir <path>",
112
- "Where to keep what this host has measured about itself (default: beside the installed proxy)"
114
+ "Where to keep this installation's id, what this host has measured about itself, and diagnostic dumps (default: the installed proxy's own directory; pass an empty value to keep nothing)"
113
115
  )
114
116
  .option(
115
117
  "--log-file <path>",
@@ -137,7 +139,19 @@ const bindHost = String(options.host);
137
139
  const explicitBaseUrl = options.publicBaseUrl
138
140
  ? String(options.publicBaseUrl).replace(/\/+$/, "")
139
141
  : "";
140
- const clientId = installationId({ stateDir: options.stateDir, explicitId: options.id });
142
+ // THE STATE DIRECTORY, resolved once: the one named, the installed proxy's own
143
+ // directory when none is named, or "" when the operator passes an empty value
144
+ // (nothing is kept). Every consumer below is handed this value and none reads
145
+ // the option again.
146
+ const stateDir = options.stateDir === undefined ? PROXY_ROOT : String(options.stateDir);
147
+ if (stateDir) {
148
+ try {
149
+ mkdirSync(stateDir, { recursive: true });
150
+ } catch (error) {
151
+ logger.warn(`Could not create the state directory ${stateDir}: ${error instanceof Error ? error.message : String(error)}`);
152
+ }
153
+ }
154
+ const clientId = installationId({ stateDir, explicitId: options.id });
141
155
  const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
142
156
  const token = String(options.token ?? "");
143
157
  const transcodeAudio = options.transcodeAudio !== false;
@@ -347,15 +361,15 @@ async function shutdown(signal) {
347
361
  const diagnostics = new Diagnostics({
348
362
  logger,
349
363
  kinds: [
350
- { name: "core dumps", directory: () => options.stateDir || "", matches: (name) => name.startsWith("core.") },
364
+ { name: "core dumps", directory: () => stateDir, matches: (name) => name.startsWith("core.") },
351
365
  {
352
366
  name: "heap snapshots",
353
- directory: () => options.stateDir || "",
367
+ directory: () => stateDir,
354
368
  matches: (name) => name.endsWith(".heapsnapshot")
355
369
  },
356
370
  {
357
371
  name: "packet captures",
358
- directory: () => options.stateDir || "",
372
+ directory: () => stateDir,
359
373
  matches: (name) => name.startsWith("packet-witness")
360
374
  }
361
375
  ]
@@ -391,7 +405,7 @@ try {
391
405
  ffmpegBin,
392
406
  memoryBytes,
393
407
  segmentFormat: options.segmentFormat,
394
- stateDir: options.stateDir,
408
+ stateDir,
395
409
  diagnostics,
396
410
  budgetPolicy,
397
411
  logFile: options.logFile ?? "",
@@ -444,11 +458,11 @@ try {
444
458
  // A native fault writes the whole address space out — 4.18 GB each on the
445
459
  // field host, and four of them nearly filled a 235 GB disk. Keep the newest
446
460
  // two, which are the evidence for the fault still open, and drop the rest.
447
- void pruneCoreDumps(options.stateDir);
461
+ void pruneCoreDumps(stateDir);
448
462
  // Same policy for the packet captures the send-queue witness writes.
449
463
  packetWitness = createPacketWitness({
450
464
  log: (message) => logger.info(message),
451
- dir: options.stateDir || "",
465
+ dir: stateDir,
452
466
  port: actualPort,
453
467
  // Evidence takes room from the product, so a capture is asked for rather
454
468
  // than taken. Refused, it is a line and not a silence.
@@ -517,8 +531,6 @@ try {
517
531
  // read stayed behind the decision to write, so a quiet second costs one
518
532
  // call to `process.memoryUsage()`.
519
533
  intervalMs: 1_000,
520
- quietMs: 60_000,
521
- changeBytes: 25 * 1024 * 1024,
522
534
  diskPath: os.tmpdir(),
523
535
  // WHERE A SNAPSHOT SURVIVES THE PROCESS IT IS ABOUT, and how many are kept
524
536
  // — the same answer the torrent worker already had. This scope had neither:
@@ -526,11 +538,8 @@ try {
526
538
  // on every restart, and all of them were kept. Fifty-four were written on
527
539
  // 2026-09-07 and every one was gone by the time the process that wrote them
528
540
  // died of a heap it had never been possible to look at.
529
- snapshotDir: options.stateDir || undefined,
541
+ snapshotDir: stateDir || undefined,
530
542
  heapSnapshots: options.heapSnapshots === true,
531
- snapshotFloorBytes: 400 * 1024 * 1024,
532
- snapshotGrowthBytes: 400 * 1024 * 1024,
533
- keepSnapshots: 3
534
543
  });
535
544
 
536
545
  logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.107.57",
3
+ "version": "2.107.58",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -48,6 +48,19 @@ import path from "node:path";
48
48
  import { fileURLToPath } from "node:url";
49
49
  import { configurationKeyOf, kindOfEncoderName } from "./fingerprint.js";
50
50
 
51
+ /**
52
+ * The writer used when none is handed in (a check that only wants the file).
53
+ * The proxy always hands in the atomic one from `utils/atomic-json.js`, which
54
+ * this layer may not import.
55
+ *
56
+ * @param {string} filePath
57
+ * @param {unknown} value
58
+ * @returns {void}
59
+ */
60
+ function writeInPlace(filePath, value) {
61
+ writeFileSync(filePath, JSON.stringify(value));
62
+ }
63
+
51
64
  /**
52
65
  * The directory of the installed proxy, where these observations are kept when
53
66
  * the deployment names no state directory. Deliberately not the working
@@ -84,6 +97,8 @@ export class LocalObservations {
84
97
 
85
98
  /** @type {{ info: (m: string) => void, warn: (m: string) => void }} */
86
99
  #logger;
100
+ /** @type {(filePath: string, value: unknown) => void} */
101
+ #writeJson;
87
102
 
88
103
  /** @type {() => number} */
89
104
  #now;
@@ -99,16 +114,19 @@ export class LocalObservations {
99
114
  * @param {import("./fingerprint.js").HostFingerprint} params.fingerprint
100
115
  * @param {string} params.filePath - Where the readings are kept; empty keeps
101
116
  * them for this process only.
117
+ * @param {(filePath: string, value: unknown) => void} [params.writeJson] - How
118
+ * the file is saved; throws on failure. The atomic writer is handed in.
102
119
  * @param {{ info: (m: string) => void, warn: (m: string) => void }} [params.logger]
103
120
  * @param {() => number} [params.now]
104
121
  */
105
- constructor({ fingerprint, filePath, logger = null, now = Date.now }) {
122
+ constructor({ fingerprint, filePath, writeJson = writeInPlace, logger = null, now = Date.now }) {
106
123
  if (!fingerprint || typeof fingerprint !== "object") {
107
124
  throw new TypeError("LocalObservations requires the host's fingerprint");
108
125
  }
109
126
  this.#fingerprint = fingerprint;
110
127
  this.#filePath = typeof filePath === "string" ? filePath : "";
111
128
  this.#logger = logger ?? { info: () => {}, warn: () => {} };
129
+ this.#writeJson = writeJson;
112
130
  this.#now = now;
113
131
  }
114
132
 
@@ -367,7 +385,7 @@ export class LocalObservations {
367
385
  return;
368
386
  }
369
387
  try {
370
- writeFileSync(this.#filePath, JSON.stringify({ version: FILE_VERSION, entries: this.#entries }));
388
+ this.#writeJson(this.#filePath, { version: FILE_VERSION, entries: this.#entries });
371
389
  } catch (error) {
372
390
  // A read-only install keeps what it learned for this process only.
373
391
  this.#logger.warn(
@@ -1,9 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { writeJsonAtomic } from "../../utils/atomic-json.js";
5
+ import { logger } from "../../utils/logger.js";
4
6
 
5
7
  const ID_FILE = "proxy-identity.json";
6
8
 
9
+ /** Ids that could not be written, by identity file: kept so the process keeps one id. */
10
+ const unsavedIds = new Map();
11
+
7
12
  /**
8
13
  * Resolve this installation's registry id. Without a state directory the id
9
14
  * remains ephemeral; with one, the id survives process restarts.
@@ -27,12 +32,18 @@ export function installationId({ stateDir, explicitId, makeId = randomUUID } = {
27
32
  }
28
33
  }
29
34
 
30
- const id = requestedId || savedId || makeId();
35
+ const id = requestedId || savedId || unsavedIds.get(file) || makeId();
31
36
  if (id !== savedId) {
32
- mkdirSync(stateDir, { recursive: true });
33
- const temporary = `${file}.${process.pid}.tmp`;
34
- writeFileSync(temporary, `${JSON.stringify({ id })}\n`, { mode: 0o600 });
35
- renameSync(temporary, file);
37
+ try {
38
+ mkdirSync(stateDir, { recursive: true });
39
+ writeJsonAtomic(file, { id }, { mode: 0o600 });
40
+ unsavedIds.delete(file);
41
+ } catch (error) {
42
+ if (unsavedIds.get(file) !== id) {
43
+ logger.warn(`Cannot save the proxy identity file ${file} (${error.message}); the installation id lasts only until this process exits`);
44
+ }
45
+ unsavedIds.set(file, id);
46
+ }
36
47
  }
37
48
  return id;
38
49
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { EventEmitter } from "node:events";
12
+ import { writeJsonAtomic } from "../../utils/atomic-json.js";
12
13
  import { logger } from "../../utils/logger.js";
13
14
  import { KeyframeTables } from "../media/KeyframeTables.js";
14
15
  import { bandOf, WaitLedger } from "../encode/OutputWaitLedger.js";
@@ -100,7 +101,7 @@ import { freeBytesFor } from "../storage/free.js";
100
101
  import { PlaybackStorageDemand } from "../storage/PlaybackStorageDemand.js";
101
102
  import { inputCacheRoot, probeInputCache } from "../encode/input-cache.js";
102
103
  import path from "node:path";
103
- import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
104
+ import { contentOf, LocalObservations } from "../encode/LocalObservations.js";
104
105
  import { configurationKeyOf } from "../encode/fingerprint.js";
105
106
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
106
107
  import { preparedAddresses, watchedAddresses } from "../viewer/watched-addresses.js";
@@ -875,7 +876,9 @@ export function wireOutputs({
875
876
  if (calibration?.fingerprint) {
876
877
  parts.localObservations = new LocalObservations({
877
878
  fingerprint: calibration.fingerprint,
878
- filePath: path.join(typeof stateDir === "string" && stateDir.length > 0 ? stateDir : PROXY_ROOT, "local-observations.json"),
879
+ // The state directory arrives resolved; none keeps the observations in memory only.
880
+ filePath: typeof stateDir === "string" && stateDir.length > 0 ? path.join(stateDir, "local-observations.json") : "",
881
+ writeJson: writeJsonAtomic,
879
882
  logger
880
883
  });
881
884
  parts.localObservations.load(calibration.kinds().map((kind) => configurationKeyOf(calibration.fingerprint, {
@@ -27,6 +27,7 @@ import fs from "node:fs/promises";
27
27
  import { randomUUID } from "node:crypto";
28
28
  import os from "node:os";
29
29
  import path from "node:path";
30
+ import { writeJsonAtomic } from "../../../utils/atomic-json.js";
30
31
  import { IDLE_KEEP_MS } from "../keep.js";
31
32
 
32
33
  /**
@@ -541,7 +542,11 @@ export class CompletedFiles {
541
542
  await fs.rm(directory, { ...REMOVAL, recursive: true }).catch(() => undefined);
542
543
  return;
543
544
  }
544
- await fs.writeFile(path.join(directory, "manifest.json"), JSON.stringify(named)).catch(() => undefined);
545
+ try {
546
+ writeJsonAtomic(path.join(directory, "manifest.json"), named);
547
+ } catch {
548
+ // The manifest is a convenience for the next start; a failed write is not a failed keep.
549
+ }
545
550
  }
546
551
 
547
552
  /** Remove an empty or partial-only directory discovered during startup. */
@@ -459,13 +459,13 @@ export function startMemoryReport({
459
459
  label = "",
460
460
  diskPath = "",
461
461
  intervalMs = MEMORY_REPORT_INTERVAL_MS,
462
- quietMs = 0,
463
- changeBytes = 0,
462
+ quietMs = 60_000,
463
+ changeBytes = 25 * 1024 * 1024,
464
464
  snapshotDir = "",
465
465
  mayKeep = null,
466
- snapshotFloorBytes = 500 * 1024 * 1024,
467
- snapshotGrowthBytes = 100 * 1024 * 1024,
468
- keepSnapshots = 0,
466
+ snapshotFloorBytes = 400 * 1024 * 1024,
467
+ snapshotGrowthBytes = 400 * 1024 * 1024,
468
+ keepSnapshots = 3,
469
469
  heapSnapshots = false,
470
470
  bufferSnapshotFloorBytes = 0,
471
471
  bufferSnapshotGrowthBytes = 500 * 1024 * 1024
@@ -44,6 +44,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync,
44
44
  import { rmSync } from "node:fs";
45
45
  import os from "node:os";
46
46
  import path from "node:path";
47
+ import { writeFileAtomic } from "../../../utils/atomic-json.js";
47
48
 
48
49
  /** Where every output's segments live. One root for the process. */
49
50
  const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
@@ -235,7 +236,7 @@ export class SegmentStore {
235
236
  mkdirSync(dir, { recursive: true });
236
237
  // What this directory is, for whoever finds it after this process has
237
238
  // been killed. Without it the sweep can only throw everything away.
238
- writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
239
+ writeFileAtomic(path.join(dir, KEY_FILE), `${key}\n`);
239
240
  }
240
241
  this.#touched.set(key, this.#now());
241
242
  // Production is about to begin here, and a piece closing in this directory
@@ -793,20 +793,15 @@ startMemoryReport({
793
793
  scope: "thread",
794
794
  label: "torrent worker",
795
795
  intervalMs: WORKER_MEMORY_SAMPLE_MS,
796
- quietMs: 60_000,
797
- changeBytes: 25 * 1024 * 1024,
798
796
  snapshotDir: workerData?.stateDir || undefined,
799
797
  heapSnapshots: workerData?.heapSnapshots === true,
800
- snapshotFloorBytes: 400 * 1024 * 1024,
801
- snapshotGrowthBytes: 400 * 1024 * 1024,
802
798
  // CHOSEN, not derived: no healthy session has recorded this figure yet, so
803
799
  // there is no measured normal to stand above. It is set well below the
804
800
  // 2.1-4.3 GB the worker held in the minutes before the kills of 2026-10-01,
805
801
  // while the machine still has room to write a snapshot. The line prints what the
806
802
  // stores hold beside it, which is what a derived floor will come from.
807
803
  bufferSnapshotFloorBytes: 500 * 1024 * 1024,
808
- bufferSnapshotGrowthBytes: 500 * 1024 * 1024,
809
- keepSnapshots: 3
804
+ bufferSnapshotGrowthBytes: 500 * 1024 * 1024
810
805
  });
811
806
 
812
807
  const STORE_REPORT_INTERVAL_MS = 60_000;
@@ -0,0 +1,46 @@
1
+ import nodeFs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ let writes = 0;
5
+
6
+ /**
7
+ * Write a file so that a reader (or a restart after a crash) sees either the
8
+ * previous content or the new content, never a half-written file: the text goes
9
+ * to a temporary file beside the target, then is renamed over it. Synchronous;
10
+ * throws on failure and removes its temporary file when it can. It never writes
11
+ * `filePath` itself.
12
+ *
13
+ * @param {string} filePath
14
+ * @param {string | Uint8Array} data
15
+ * @param {{ fs?: Partial<typeof nodeFs>, mode?: number }} [options] - `fs`
16
+ * overrides individual `node:fs` functions (a check stops the write between
17
+ * the two steps with it).
18
+ * @returns {void}
19
+ */
20
+ export function writeFileAtomic(filePath, data, { fs = {}, mode } = {}) {
21
+ const io = { ...nodeFs, ...fs };
22
+ const temporary = path.join(path.dirname(filePath), `${path.basename(filePath)}.${process.pid}.${++writes}.tmp`);
23
+ try {
24
+ io.writeFileSync(temporary, data, mode === undefined ? undefined : { mode });
25
+ io.renameSync(temporary, filePath);
26
+ } catch (error) {
27
+ try {
28
+ nodeFs.rmSync(temporary, { force: true });
29
+ } catch {
30
+ // The write already failed; a temporary file that cannot be removed is the lesser fault.
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Serialise `value` as JSON and write it with {@link writeFileAtomic}.
38
+ *
39
+ * @param {string} filePath
40
+ * @param {unknown} value
41
+ * @param {{ fs?: Partial<typeof nodeFs>, mode?: number }} [options]
42
+ * @returns {void}
43
+ */
44
+ export function writeJsonAtomic(filePath, value, options = {}) {
45
+ writeFileAtomic(filePath, `${JSON.stringify(value)}\n`, options);
46
+ }