@torrent-tv/proxy 2.89.6 → 2.89.8

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.
Files changed (92) hide show
  1. package/AGENTS.md +32 -12
  2. package/CHANGELOG.md +2340 -2330
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/biome.json +412 -412
  6. package/docs/browser-proxy-contract.md +155 -155
  7. package/docs/container-architecture.md +273 -273
  8. package/docs/encode-run-state.md +1 -1
  9. package/docs/playback-start-forecast.md +113 -98
  10. package/package.json +51 -50
  11. package/routes/api/sources/files/get.js +106 -106
  12. package/scripts/build-audio-samples.mjs +31 -31
  13. package/services/encode/EncodeRun.js +67 -15
  14. package/services/encode/EncodeRuns.js +19 -7
  15. package/services/encode/OutputOpening.js +784 -784
  16. package/services/encode/audio-calibration.js +67 -67
  17. package/services/encode/audio-work-rate.js +15 -15
  18. package/services/encode/encode-exit.js +2 -0
  19. package/services/encode/hwaccel.js +1894 -1894
  20. package/services/encode/output/index.js +24 -24
  21. package/services/encode/piece-completeness.js +60 -2
  22. package/services/encode/run-command.js +757 -755
  23. package/services/encode/segment-formats/index.js +1 -0
  24. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  25. package/services/media/ContainerOrchestrator.js +202 -202
  26. package/services/media/KeyframeTables.js +356 -356
  27. package/services/media/SubtitleCues.js +592 -592
  28. package/services/media/audio-inventory.js +299 -299
  29. package/services/media/container/AviContainer.js +268 -268
  30. package/services/media/container/Container.js +632 -632
  31. package/services/media/container/ContainerFactory.js +38 -38
  32. package/services/media/container/MatroskaContainer.js +1031 -1031
  33. package/services/media/container/Mp4Container.js +1354 -1354
  34. package/services/media/container/SubtitleFileContainer.js +339 -339
  35. package/services/media/container/ebml-stream.js +174 -174
  36. package/services/media/container/matroska-clusters.js +1165 -1165
  37. package/services/media/container/unavailable.js +77 -77
  38. package/services/media/tracks/ContainerTrack.js +84 -84
  39. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  40. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  41. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  42. package/services/torrent/Contents.js +382 -382
  43. package/services/torrent/episode-naming.js +182 -182
  44. package/services/torrent/torrent-pool.js +2688 -2688
  45. package/services/torrent/worker/client.js +836 -836
  46. package/services/torrent/worker/held-bytes.js +231 -231
  47. package/services/torrent/worker/worker.js +1137 -1137
  48. package/services/transport/data-channel-handler.js +1373 -1373
  49. package/services/viewer/PriorityMap.js +421 -421
  50. package/test/audio-calibration.test.js +23 -23
  51. package/test/auto-quality-step.test.js +762 -762
  52. package/test/coalesce.test.js +39 -39
  53. package/test/coverage-follows-the-disk.test.js +5 -0
  54. package/test/decode-cost.test.js +475 -475
  55. package/test/encode-run.test.js +58 -0
  56. package/test/episode-naming.test.js +131 -131
  57. package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
  58. package/test/fixtures/audio-frame-boundary.json +1 -1
  59. package/test/fixtures/refused-audio-20261002.json +10 -0
  60. package/test/fmp4-presentation-clock.test.js +4 -2
  61. package/test/format-keyed-outputs.test.js +78 -78
  62. package/test/health-metrics.test.js +11 -13
  63. package/test/held-read-ownership.test.js +77 -77
  64. package/test/helpers/encode-run.js +14 -6
  65. package/test/helpers/held-file.js +77 -77
  66. package/test/helpers/manager.js +62 -62
  67. package/test/helpers/matroska-file.js +296 -296
  68. package/test/keyframe-table-budget.test.js +123 -123
  69. package/test/matroska-cluster-discovery.test.js +323 -323
  70. package/test/one-output-one-session.test.js +273 -273
  71. package/test/output-speed.test.js +6 -1
  72. package/test/piece-completeness.test.js +37 -1
  73. package/test/quality-variants.test.js +1274 -1270
  74. package/test/rate-control-in-the-output.test.js +4 -3
  75. package/test/reads-not-yet-arrived.test.js +218 -218
  76. package/test/seek-frees-the-viewers-requests.test.js +1 -1
  77. package/test/segment-closed-on-both-branches.test.js +114 -114
  78. package/test/segment-judged-by-its-own-header.test.js +222 -222
  79. package/test/segment-serve-wiring.test.js +481 -481
  80. package/test/serving-existing-output.test.js +111 -111
  81. package/test/source-files-route.test.js +113 -113
  82. package/test/subtitle-cue-walk.test.js +385 -385
  83. package/test/subtitle-metadata.test.js +15 -15
  84. package/test/subtitle-plan-waits.test.js +125 -125
  85. package/test/subtitle-track-numbering.test.js +391 -391
  86. package/test/two-viewers-one-picture.test.js +508 -508
  87. package/test/viewer-requests-admission.test.js +92 -92
  88. package/test/viewer-starts-on-map.test.js +27 -27
  89. package/test/viewer-states.test.js +5 -2
  90. package/test/viewing-generations.test.js +390 -390
  91. package/test/whole-file-held-read.test.js +30 -30
  92. package/utils/coalesce.js +41 -41
@@ -1,77 +1,77 @@
1
- /**
2
- * @file A read of what is downloaded asks for nothing, and hands the other
3
- * thread memory that is its own.
4
- *
5
- * Until 2026-10-01 the read opened the torrent's own file stream on bytes that
6
- * had not arrived; that stream selects pieces itself, outside the demand
7
- * register, and waited up to thirty seconds — 36 such reads at a torrent's open
8
- * competed with the file being watched. And the bytes it returns are now handed
9
- * to the main thread instead of copied, which is safe only for a buffer that is
10
- * the whole of its own memory. Fakes only.
11
- */
12
-
13
- import test from "node:test";
14
- import assert from "node:assert/strict";
15
- import { Readable } from "node:stream";
16
- import { MessageChannel } from "node:worker_threads";
17
- import { createHash } from "node:crypto";
18
-
19
- import { ownsItsMemory, readHeldBytes } from "../services/torrent/worker/held-bytes.js";
20
-
21
- const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
22
-
23
- /** A torrent of one file whose stream serves views of one shared store buffer. */
24
- function torrentOver(store, held) {
25
- const streams = [];
26
- const file = {
27
- name: "film.mkv",
28
- length: store.length,
29
- offset: 0,
30
- createReadStream({ start, end }) {
31
- streams.push({ start, end });
32
- return Readable.from([store.subarray(start, end + 1)]);
33
- }
34
- };
35
- return {
36
- streams,
37
- torrent: { pieceLength: 16, bitfield: { get: (index) => held(index) }, files: [file] }
38
- };
39
- }
40
-
41
- test("a range not wholly downloaded is answered at once with nothing, and no stream is opened", async () => {
42
- const store = Buffer.alloc(64, 1);
43
- const { torrent, streams } = torrentOver(store, (index) => index !== 2);
44
- assert.equal(await readHeldBytes(torrent, 0, 0, 63), null);
45
- assert.equal(streams.length, 0, "nothing was asked of the torrent");
46
- assert.notEqual(await readHeldBytes(torrent, 0, 0, 31), null, "a range that is here is read");
47
- });
48
-
49
- test("what a held read returns owns its memory; a pool slice and shared memory do not", async () => {
50
- const store = Buffer.from(Array.from({ length: 64 }, (_, index) => index));
51
- const { torrent } = torrentOver(store, () => true);
52
- const bytes = await readHeldBytes(torrent, 0, 3, 40);
53
- assert.equal(ownsItsMemory(bytes), true);
54
- assert.equal(ownsItsMemory(Buffer.from("small")), false, "a slice of Node's pool");
55
- assert.equal(ownsItsMemory(new Uint8Array(new SharedArrayBuffer(8))), false, "shared memory");
56
- assert.equal(ownsItsMemory(store.subarray(4, 8)), false, "a view into another buffer");
57
- });
58
-
59
- test("handing a read's bytes to another thread leaves the store and an overlapping read as they were", async () => {
60
- const store = Buffer.from(Array.from({ length: 256 }, (_, index) => index % 251));
61
- const before = digest(store);
62
- const { torrent } = torrentOver(store, () => true);
63
- const [first, second] = await Promise.all([readHeldBytes(torrent, 0, 10, 200), readHeldBytes(torrent, 0, 50, 120)]);
64
- const secondBefore = digest(second);
65
-
66
- const { port1, port2 } = new MessageChannel();
67
- const received = new Promise((resolve) => port2.once("message", resolve));
68
- port1.postMessage({ bytes: first }, [first.buffer]);
69
- const message = await received;
70
- port1.close();
71
- port2.close();
72
-
73
- assert.equal(first.byteLength, 0, "the sender no longer holds the memory it handed over");
74
- assert.deepEqual(Buffer.from(message.bytes), store.subarray(10, 201), "the receiver has every byte");
75
- assert.equal(digest(store), before, "the store's own bytes are untouched");
76
- assert.equal(digest(second), secondBefore, "the overlapping read is untouched");
77
- });
1
+ /**
2
+ * @file A read of what is downloaded asks for nothing, and hands the other
3
+ * thread memory that is its own.
4
+ *
5
+ * Until 2026-10-01 the read opened the torrent's own file stream on bytes that
6
+ * had not arrived; that stream selects pieces itself, outside the demand
7
+ * register, and waited up to thirty seconds — 36 such reads at a torrent's open
8
+ * competed with the file being watched. And the bytes it returns are now handed
9
+ * to the main thread instead of copied, which is safe only for a buffer that is
10
+ * the whole of its own memory. Fakes only.
11
+ */
12
+
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { Readable } from "node:stream";
16
+ import { MessageChannel } from "node:worker_threads";
17
+ import { createHash } from "node:crypto";
18
+
19
+ import { ownsItsMemory, readHeldBytes } from "../services/torrent/worker/held-bytes.js";
20
+
21
+ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
22
+
23
+ /** A torrent of one file whose stream serves views of one shared store buffer. */
24
+ function torrentOver(store, held) {
25
+ const streams = [];
26
+ const file = {
27
+ name: "film.mkv",
28
+ length: store.length,
29
+ offset: 0,
30
+ createReadStream({ start, end }) {
31
+ streams.push({ start, end });
32
+ return Readable.from([store.subarray(start, end + 1)]);
33
+ }
34
+ };
35
+ return {
36
+ streams,
37
+ torrent: { pieceLength: 16, bitfield: { get: (index) => held(index) }, files: [file] }
38
+ };
39
+ }
40
+
41
+ test("a range not wholly downloaded is answered at once with nothing, and no stream is opened", async () => {
42
+ const store = Buffer.alloc(64, 1);
43
+ const { torrent, streams } = torrentOver(store, (index) => index !== 2);
44
+ assert.equal(await readHeldBytes(torrent, 0, 0, 63), null);
45
+ assert.equal(streams.length, 0, "nothing was asked of the torrent");
46
+ assert.notEqual(await readHeldBytes(torrent, 0, 0, 31), null, "a range that is here is read");
47
+ });
48
+
49
+ test("what a held read returns owns its memory; a pool slice and shared memory do not", async () => {
50
+ const store = Buffer.from(Array.from({ length: 64 }, (_, index) => index));
51
+ const { torrent } = torrentOver(store, () => true);
52
+ const bytes = await readHeldBytes(torrent, 0, 3, 40);
53
+ assert.equal(ownsItsMemory(bytes), true);
54
+ assert.equal(ownsItsMemory(Buffer.from("small")), false, "a slice of Node's pool");
55
+ assert.equal(ownsItsMemory(new Uint8Array(new SharedArrayBuffer(8))), false, "shared memory");
56
+ assert.equal(ownsItsMemory(store.subarray(4, 8)), false, "a view into another buffer");
57
+ });
58
+
59
+ test("handing a read's bytes to another thread leaves the store and an overlapping read as they were", async () => {
60
+ const store = Buffer.from(Array.from({ length: 256 }, (_, index) => index % 251));
61
+ const before = digest(store);
62
+ const { torrent } = torrentOver(store, () => true);
63
+ const [first, second] = await Promise.all([readHeldBytes(torrent, 0, 10, 200), readHeldBytes(torrent, 0, 50, 120)]);
64
+ const secondBefore = digest(second);
65
+
66
+ const { port1, port2 } = new MessageChannel();
67
+ const received = new Promise((resolve) => port2.once("message", resolve));
68
+ port1.postMessage({ bytes: first }, [first.buffer]);
69
+ const message = await received;
70
+ port1.close();
71
+ port2.close();
72
+
73
+ assert.equal(first.byteLength, 0, "the sender no longer holds the memory it handed over");
74
+ assert.deepEqual(Buffer.from(message.bytes), store.subarray(10, 201), "the receiver has every byte");
75
+ assert.equal(digest(store), before, "the store's own bytes are untouched");
76
+ assert.equal(digest(second), secondBefore, "the overlapping read is untouched");
77
+ });
@@ -8,9 +8,9 @@
8
8
  * product builds and injects only the one thing a test may not have, which is a
9
9
  * child process.
10
10
  *
11
- * The pid is deliberately absent unless a test asks for one. `pause` and
12
- * `resume` send signals by pid, and a made-up number is somebody else's process
13
- * on the machine running the tests.
11
+ * The fake has a pid, as a started process does: a run signals only a process
12
+ * that started. The number is never used to signal anything — every signal goes
13
+ * through the child object, and this one records it.
14
14
  */
15
15
 
16
16
  import { EncodeRun } from "../../services/encode/EncodeRun.js";
@@ -92,11 +92,10 @@ function say(channel, text) {
92
92
  * A child process that records what was done to it.
93
93
  *
94
94
  * @param {object} [options]
95
- * @param {number | null} [options.pid] - Only where a test genuinely exercises
96
- * suspend or resume, and then it must be this process's own.
95
+ * @param {number | null} [options.pid] - Null for a process that did not start.
97
96
  * @returns {object}
98
97
  */
99
- export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
98
+ export function fakeProcess({ pid = 4242, exitsWhenKilled = true } = {}) {
100
99
  /** @type {Map<string, (...args: unknown[]) => void>} */
101
100
  const listeners = new Map();
102
101
  const stdout = channel();
@@ -123,6 +122,10 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
123
122
  },
124
123
  kill(signal = "SIGTERM") {
125
124
  this.signals.push(signal);
125
+ // Suspending and continuing are not ends.
126
+ if (signal === "SIGSTOP" || signal === "SIGCONT") {
127
+ return true;
128
+ }
126
129
  this.killed = true;
127
130
  // A real process answers a signal by exiting, and it does so on a later
128
131
  // turn. A fake that never exits makes every disposal wait out the grace
@@ -144,6 +147,11 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
144
147
  for (const handler of listeners.get("exit") ?? []) {
145
148
  handler(code, signal);
146
149
  }
150
+ // A real child process emits `close` after `exit`, once its stdio has
151
+ // closed, and the run takes its ending from `close`.
152
+ for (const handler of listeners.get("close") ?? []) {
153
+ handler(code, signal);
154
+ }
147
155
  }
148
156
  };
149
157
  }
@@ -1,77 +1,77 @@
1
- /**
2
- * @file A torrent reduced to what the subtitle walk may know about one file.
3
- *
4
- * Built from the REAL two functions the torrent thread answers with
5
- * (`torrent/worker/held-bytes.js`), so a check that walks cues exercises both
6
- * sides of the seam rather than a stand-in for one of them: the bitfield
7
- * becoming a list of ranges, and a range read that never fetches.
8
- *
9
- * The file's container is the one a `ContainerOrchestrator` builds, over a
10
- * reader that FETCHES — the fake file's own stream, which serves any range —
11
- * because the head and the Cues table are read that way in the product.
12
- */
13
-
14
- import { heldRangesOf, readHeldBytes } from "../../services/torrent/worker/held-bytes.js";
15
- import { ContainerOrchestrator } from "../../services/media/ContainerOrchestrator.js";
16
-
17
- /**
18
- * Every byte of a range from the fake file's stream.
19
- *
20
- * @param {object} file
21
- * @param {number} start
22
- * @param {number} end
23
- * @returns {Promise<Buffer>}
24
- */
25
- async function readThroughStream(file, start, end) {
26
- const chunks = [];
27
- for await (const chunk of file.createReadStream({ start, end })) {
28
- chunks.push(Buffer.from(chunk));
29
- }
30
- return Buffer.concat(chunks);
31
- }
32
-
33
- /** One orchestrator for the checks that do not bring their own. */
34
- const sharedContainers = new ContainerOrchestrator();
35
-
36
- /**
37
- * @param {object} torrent - The fake a check builds: `pieceLength`, `bitfield`,
38
- * `files[]` with `offset`, `length`, `name`, `createReadStream`.
39
- * @param {number} fileIndex
40
- * @param {string} sourceKey
41
- * @param {{ containers?: ContainerOrchestrator, readRange?: (start: number, end: number) => Promise<Buffer | null>, wantedSeconds?: () => number[], portionBytes?: number }} [options]
42
- * @returns {import("../../services/media/SubtitleCues.js").HeldFile}
43
- */
44
- export function heldFileOver(torrent, fileIndex, sourceKey, options = {}) {
45
- const file = torrent?.files?.[fileIndex] ?? {};
46
- const containers = options.containers ?? sharedContainers;
47
- const readRange = options.readRange ?? ((start, end) => readThroughStream(file, start, end));
48
- return {
49
- sourceKey,
50
- fileIndex,
51
- name: String(file.name ?? ""),
52
- length: Number(file.length) || 0,
53
- portionBytes: options.portionBytes,
54
- container: () =>
55
- containers.containerFor({
56
- sourceKey,
57
- fileIndex,
58
- readRange,
59
- fileSize: Number(file.length) || 0,
60
- label: String(file.name ?? ""),
61
- portionBytes: options.portionBytes
62
- }),
63
- heldRanges: async () => heldRangesOf(torrent, fileIndex),
64
- readHeld: (start, end) => readHeldBytes(torrent, fileIndex, start, end),
65
- wantedSeconds: options.wantedSeconds
66
- };
67
- }
68
-
69
- /**
70
- * Forget what the shared orchestrator built for a source.
71
- *
72
- * @param {string} sourceKey
73
- * @returns {void}
74
- */
75
- export function forgetContainers(sourceKey) {
76
- sharedContainers.forget(sourceKey);
77
- }
1
+ /**
2
+ * @file A torrent reduced to what the subtitle walk may know about one file.
3
+ *
4
+ * Built from the REAL two functions the torrent thread answers with
5
+ * (`torrent/worker/held-bytes.js`), so a check that walks cues exercises both
6
+ * sides of the seam rather than a stand-in for one of them: the bitfield
7
+ * becoming a list of ranges, and a range read that never fetches.
8
+ *
9
+ * The file's container is the one a `ContainerOrchestrator` builds, over a
10
+ * reader that FETCHES — the fake file's own stream, which serves any range —
11
+ * because the head and the Cues table are read that way in the product.
12
+ */
13
+
14
+ import { heldRangesOf, readHeldBytes } from "../../services/torrent/worker/held-bytes.js";
15
+ import { ContainerOrchestrator } from "../../services/media/ContainerOrchestrator.js";
16
+
17
+ /**
18
+ * Every byte of a range from the fake file's stream.
19
+ *
20
+ * @param {object} file
21
+ * @param {number} start
22
+ * @param {number} end
23
+ * @returns {Promise<Buffer>}
24
+ */
25
+ async function readThroughStream(file, start, end) {
26
+ const chunks = [];
27
+ for await (const chunk of file.createReadStream({ start, end })) {
28
+ chunks.push(Buffer.from(chunk));
29
+ }
30
+ return Buffer.concat(chunks);
31
+ }
32
+
33
+ /** One orchestrator for the checks that do not bring their own. */
34
+ const sharedContainers = new ContainerOrchestrator();
35
+
36
+ /**
37
+ * @param {object} torrent - The fake a check builds: `pieceLength`, `bitfield`,
38
+ * `files[]` with `offset`, `length`, `name`, `createReadStream`.
39
+ * @param {number} fileIndex
40
+ * @param {string} sourceKey
41
+ * @param {{ containers?: ContainerOrchestrator, readRange?: (start: number, end: number) => Promise<Buffer | null>, wantedSeconds?: () => number[], portionBytes?: number }} [options]
42
+ * @returns {import("../../services/media/SubtitleCues.js").HeldFile}
43
+ */
44
+ export function heldFileOver(torrent, fileIndex, sourceKey, options = {}) {
45
+ const file = torrent?.files?.[fileIndex] ?? {};
46
+ const containers = options.containers ?? sharedContainers;
47
+ const readRange = options.readRange ?? ((start, end) => readThroughStream(file, start, end));
48
+ return {
49
+ sourceKey,
50
+ fileIndex,
51
+ name: String(file.name ?? ""),
52
+ length: Number(file.length) || 0,
53
+ portionBytes: options.portionBytes,
54
+ container: () =>
55
+ containers.containerFor({
56
+ sourceKey,
57
+ fileIndex,
58
+ readRange,
59
+ fileSize: Number(file.length) || 0,
60
+ label: String(file.name ?? ""),
61
+ portionBytes: options.portionBytes
62
+ }),
63
+ heldRanges: async () => heldRangesOf(torrent, fileIndex),
64
+ readHeld: (start, end) => readHeldBytes(torrent, fileIndex, start, end),
65
+ wantedSeconds: options.wantedSeconds
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Forget what the shared orchestrator built for a source.
71
+ *
72
+ * @param {string} sourceKey
73
+ * @returns {void}
74
+ */
75
+ export function forgetContainers(sourceKey) {
76
+ sharedContainers.forget(sourceKey);
77
+ }
@@ -1,62 +1,62 @@
1
- /**
2
- * @file A session manager for a test, with a store of its own.
3
- *
4
- * ONE MANAGER, ONE ROOT. In production that is true by construction: one
5
- * process holds one manager, and the store's root is a fixed path so that a
6
- * restart can find what the process before it finished (`SegmentStore.sweep`).
7
- * Under `node --test` it is false — the runner starts one PROCESS PER FILE and
8
- * runs them in parallel, so every manager built without a store of its own
9
- * shared the one default root with every other file running at that moment.
10
- *
11
- * What that cost, measured 2026-09-14: `disposeAll` removes the store's whole
12
- * root, and four files call it between them 40 times. Running the twenty-one
13
- * files that build a manager gave 2, 3 and 4 failures on consecutive runs and
14
- * zero with `--test-concurrency=1`; the victim was always
15
- * `segment-serve-wiring.test.js`, reporting a segment missing that it had
16
- * written itself a moment earlier, because another process had deleted the
17
- * directory under it. `npm test` is `node --test`, so this was the project's
18
- * own test command, and it is why a red result had become something to read
19
- * past rather than to act on.
20
- *
21
- * A test that fails by luck is worse than one that does not exist: it costs a
22
- * run every time, and it trains everyone to ignore red.
23
- */
24
-
25
- import { mkdtempSync, rmSync } from "node:fs";
26
- import os from "node:os";
27
- import path from "node:path";
28
- import { wireOutputs } from "../../services/server/wire-outputs.js";
29
- import { SegmentStore } from "../../services/storage/segment-store/SegmentStore.js";
30
-
31
- /**
32
- * A manager whose produced segments live where nothing else can reach them.
33
- *
34
- * @param {object} [options] - Passed to the manager, over the defaults below.
35
- * A `segmentStore` given here wins, for a test that wants to watch the store
36
- * itself.
37
- * @returns {{ manager: object, store: SegmentStore, root: string,
38
- * cleanup: () => void }} `cleanup` removes the root; call it from `t.after`.
39
- */
40
- export function managerWithOwnStore(options = {}) {
41
- const root = mkdtempSync(path.join(os.tmpdir(), "ttv-store-"));
42
- const store = options.segmentStore ?? new SegmentStore({ root });
43
- const manager = wireOutputs({
44
- enabled: true,
45
- ffmpegBin: "ffmpeg",
46
- localBindHost: "127.0.0.1",
47
- localPort: 9090,
48
- // What this host has measured about itself is kept beside the store, not
49
- // beside the installed proxy: read from there, one run's figures became
50
- // the next run's premises, and a check passed only on a machine that had
51
- // run the suite before.
52
- stateDir: root,
53
- ...options,
54
- segmentStore: store
55
- });
56
- return {
57
- manager,
58
- store,
59
- root,
60
- cleanup: () => rmSync(root, { recursive: true, force: true })
61
- };
62
- }
1
+ /**
2
+ * @file A session manager for a test, with a store of its own.
3
+ *
4
+ * ONE MANAGER, ONE ROOT. In production that is true by construction: one
5
+ * process holds one manager, and the store's root is a fixed path so that a
6
+ * restart can find what the process before it finished (`SegmentStore.sweep`).
7
+ * Under `node --test` it is false — the runner starts one PROCESS PER FILE and
8
+ * runs them in parallel, so every manager built without a store of its own
9
+ * shared the one default root with every other file running at that moment.
10
+ *
11
+ * What that cost, measured 2026-09-14: `disposeAll` removes the store's whole
12
+ * root, and four files call it between them 40 times. Running the twenty-one
13
+ * files that build a manager gave 2, 3 and 4 failures on consecutive runs and
14
+ * zero with `--test-concurrency=1`; the victim was always
15
+ * `segment-serve-wiring.test.js`, reporting a segment missing that it had
16
+ * written itself a moment earlier, because another process had deleted the
17
+ * directory under it. `npm test` is `node --test`, so this was the project's
18
+ * own test command, and it is why a red result had become something to read
19
+ * past rather than to act on.
20
+ *
21
+ * A test that fails by luck is worse than one that does not exist: it costs a
22
+ * run every time, and it trains everyone to ignore red.
23
+ */
24
+
25
+ import { mkdtempSync, rmSync } from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { wireOutputs } from "../../services/server/wire-outputs.js";
29
+ import { SegmentStore } from "../../services/storage/segment-store/SegmentStore.js";
30
+
31
+ /**
32
+ * A manager whose produced segments live where nothing else can reach them.
33
+ *
34
+ * @param {object} [options] - Passed to the manager, over the defaults below.
35
+ * A `segmentStore` given here wins, for a test that wants to watch the store
36
+ * itself.
37
+ * @returns {{ manager: object, store: SegmentStore, root: string,
38
+ * cleanup: () => void }} `cleanup` removes the root; call it from `t.after`.
39
+ */
40
+ export function managerWithOwnStore(options = {}) {
41
+ const root = mkdtempSync(path.join(os.tmpdir(), "ttv-store-"));
42
+ const store = options.segmentStore ?? new SegmentStore({ root });
43
+ const manager = wireOutputs({
44
+ enabled: true,
45
+ ffmpegBin: "ffmpeg",
46
+ localBindHost: "127.0.0.1",
47
+ localPort: 9090,
48
+ // What this host has measured about itself is kept beside the store, not
49
+ // beside the installed proxy: read from there, one run's figures became
50
+ // the next run's premises, and a check passed only on a machine that had
51
+ // run the suite before.
52
+ stateDir: root,
53
+ ...options,
54
+ segmentStore: store
55
+ });
56
+ return {
57
+ manager,
58
+ store,
59
+ root,
60
+ cleanup: () => rmSync(root, { recursive: true, force: true })
61
+ };
62
+ }