@torrent-tv/proxy 2.9.130 → 2.9.132

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,11 @@
1
+ ## 2.9.132
2
+
3
+ - **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.
4
+
5
+ ## 2.9.131
6
+
7
+ - **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.
8
+
1
9
  ## 2.9.130
2
10
 
3
11
  - **New**: A segment being held says WHY, at most once every five seconds per file. A hold was silent, and that silence has now cost three releases: a file that exists, a route answering "not yet", and nothing anywhere naming which of the several reasons applied. Measured 2026-08-09 — a run begun mid-file at segment #317 produced two minutes of video from #317 upwards at 10.5x, while #317 itself was held 46 s and then answered 404 once the browser had given up, with not one line about the cause. The line names the reason, the segment the run began at, where the viewer is, whether the encoder is alive, and which index was asked for. The case that matters most is called out on its own: the first segment of a run started mid-file is held by a rule that waits for the NEXT one to exist, and that is exactly the segment a resume depends on.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.130",
3
+ "version": "2.9.132",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -2248,6 +2248,11 @@ export class HlsSessionManager {
2248
2248
  * @returns {Promise<void>}
2249
2249
  */
2250
2250
  async #startEncodeRun(session, startIndex) {
2251
+ // Where a restart's seconds go. A seek costs 5-8 s in the field and the
2252
+ // recorded reason — waiting for the previous ffmpeg to exit, measured at
2253
+ // 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
2254
+ // in the proxy on a guess, make each stage state its own cost.
2255
+ const restartEnteredAt = Date.now();
2251
2256
  const generation = ++session.encodeRunGeneration;
2252
2257
  const previousFfmpeg = session.ffmpeg;
2253
2258
  // A suspended process does not act on SIGTERM until it is continued, so the
@@ -2259,7 +2264,11 @@ export class HlsSessionManager {
2259
2264
  } catch {
2260
2265
  // Best effort.
2261
2266
  }
2267
+ const termSentAt = Date.now();
2262
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
+ );
2263
2272
  if (!hasChildExited(previousFfmpeg)) {
2264
2273
  try {
2265
2274
  previousFfmpeg.kill("SIGKILL");
@@ -2492,6 +2501,7 @@ export class HlsSessionManager {
2492
2501
 
2493
2502
  logger.info(
2494
2503
  `transcode ${session.id} ${session.runLabel} encode-run from segment #${safeIndex} ` +
2504
+ `(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
2495
2505
  `(${formatSeconds(startSeconds)}) "${session.fileName}"`
2496
2506
  );
2497
2507
 
@@ -3404,12 +3414,27 @@ export class HlsSessionManager {
3404
3414
  const isLast = index >= Math.max(0, (session.segmentBoundaries?.length ?? 1) - 2);
3405
3415
  if (!isLast && session.ffmpeg) {
3406
3416
  const nextPath = path.join(session.dirPath, session.segmentFormat.segmentFileName(index + 1));
3417
+ // The FIRST segment of a run cannot be judged by "the next one
3418
+ // exists": nothing is producing a next one yet, because the run has
3419
+ // only just begun here. Waiting for it holds precisely the segment a
3420
+ // resume depends on — measured 2026-08-09, #807 held while it lay on
3421
+ // disk, and in August the same shape held #317 for 46 s and then
3422
+ // answered 404 to a browser that had given up.
3423
+ //
3424
+ // What "finished" means for it is that the encoder has moved PAST the
3425
+ // end of its span. ffmpeg reports the output timestamp of its last
3426
+ // encoded frame, so a run whose position is beyond this segment's end
3427
+ // has necessarily closed it.
3428
+ const isRunStart = index === (session.encodeStartIndex ?? -1);
3429
+ const encoderPosition = Number(session.progress?.processedSeconds);
3430
+ const segmentEnd = this.#segmentStartTime(session, index + 1);
3431
+ if (isRunStart && Number.isFinite(encoderPosition) && encoderPosition > segmentEnd) {
3432
+ // Past its end — it is complete, whatever the directory says about
3433
+ // what comes next.
3434
+ } else {
3407
3435
  try {
3408
3436
  await access(nextPath);
3409
3437
  } catch {
3410
- // The rule is "finished once the NEXT one has been started". For the
3411
- // FIRST segment of a run begun mid-file this is the one that decides
3412
- // whether a resume plays at all, so it must say so out loud.
3413
3438
  this.#explainHold(
3414
3439
  session,
3415
3440
  fileName,
@@ -3417,6 +3442,7 @@ export class HlsSessionManager {
3417
3442
  );
3418
3443
  return { kind: "warming-up" };
3419
3444
  }
3445
+ }
3420
3446
  }
3421
3447
  }
3422
3448
  // Cold-start: log the first servable SEGMENT of this session exactly once
@@ -1,205 +1,228 @@
1
- /**
2
- * @file A finished segment on disk must reach the viewer as bytes.
3
- *
4
- * The module tests cover each piece of the fMP4 path on its own, and every one
5
- * of them passed while playback was dead: 2.9.124 called
6
- * `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
7
- * unit test imports that function straight from `mp4-boxes.js`, so the gap
8
- * between a module and its CALLER was invisible. This test asks the session
9
- * manager for a segment that exists and insists on getting it.
10
- *
11
- * The second half pins the reason a one-word slip cost a whole release: the
12
- * failure was reported as "still being produced". Measured 2026-08-08 — segment
13
- * #0 was held for 45 281 ms with twelve finished segments in the directory, and
14
- * the log said nothing at all. Anything that goes wrong while preparing a file
15
- * that EXISTS must be named and answered, never turned into an endless wait.
16
- */
17
-
18
- import test from "node:test";
19
- import assert from "node:assert/strict";
20
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
21
- import os from "node:os";
22
- import path from "node:path";
23
- import { HlsSessionManager } from "../services/hls-session-manager.js";
24
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
25
-
26
- const MOVIE_TIMESCALE = 1000;
27
- const VIDEO_TIMESCALE = 90_000;
28
- const AUDIO_TIMESCALE = 48_000;
29
- const SEGMENT_START_SECONDS = 12.5;
30
- const SESSION_ID = "11111111-2222-3333-4444-555555555555";
31
-
32
- /**
33
- * @param {string} type
34
- * @param {Buffer} body
35
- * @returns {Buffer}
36
- */
37
- function box(type, body) {
38
- const head = Buffer.alloc(8);
39
- head.writeUInt32BE(8 + body.length, 0);
40
- head.write(type, 4, "latin1");
41
- return Buffer.concat([head, body]);
42
- }
43
-
44
- /**
45
- * `elst` holding one empty edit — how the `segment` muxer records where the
46
- * piece sits on the source timeline.
47
- *
48
- * @param {number} offsetSeconds
49
- * @returns {Buffer}
50
- */
51
- function emptyEdit(offsetSeconds) {
52
- const body = Buffer.alloc(16);
53
- body.writeUInt32BE(1, 4); // entry count
54
- body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
55
- body.writeInt32BE(-1, 12); // media_time
56
- return box("elst", body);
57
- }
58
-
59
- /**
60
- * @param {number} trackId
61
- * @param {number} timescale
62
- * @param {number} offsetSeconds
63
- * @returns {Buffer}
64
- */
65
- function trak(trackId, timescale, offsetSeconds) {
66
- const tkhdBody = Buffer.alloc(84);
67
- tkhdBody.writeUInt32BE(trackId, 12);
68
- const mdhdBody = Buffer.alloc(20);
69
- mdhdBody.writeUInt32BE(timescale, 12);
70
- return box("trak", Buffer.concat([
71
- box("tkhd", tkhdBody),
72
- box("edts", emptyEdit(offsetSeconds)),
73
- box("mdia", box("mdhd", mdhdBody))
74
- ]));
75
- }
76
-
77
- /**
78
- * @param {number} trackId
79
- * @returns {Buffer}
80
- */
81
- function traf(trackId) {
82
- const tfhdBody = Buffer.alloc(8);
83
- tfhdBody.writeUInt32BE(trackId, 4);
84
- const tfdtBody = Buffer.alloc(12);
85
- tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
86
- tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
87
- return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
88
- }
89
-
90
- /**
91
- * A piece shaped like the `segment` muxer's output: header, two fragments and a
92
- * trailing random-access index, all in one file.
93
- *
94
- * @param {number} offsetSeconds
95
- * @returns {Buffer}
96
- */
97
- function selfContainedPiece(offsetSeconds) {
98
- const mvhdBody = Buffer.alloc(100);
99
- mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
100
- const moov = box("moov", Buffer.concat([
101
- box("mvhd", mvhdBody),
102
- trak(1, VIDEO_TIMESCALE, offsetSeconds),
103
- trak(2, AUDIO_TIMESCALE, offsetSeconds)
104
- ]));
105
- const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
106
- const mdat = box("mdat", Buffer.alloc(64, 0x5a));
107
- const mfra = box("mfra", Buffer.alloc(24, 0));
108
- return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
109
- }
110
-
111
- /**
112
- * A manager holding one session whose segments are already on disk, cut at
113
- * explicit times — the ordinary keyframe-cut path.
114
- *
115
- * @param {{ segmentFormat?: object }} [overrides]
116
- * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
117
- */
118
- async function managerWithReadySegment(overrides = {}) {
119
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
120
- const piece = selfContainedPiece(SEGMENT_START_SECONDS);
121
- // Two segments, because a piece is only finished once the next one exists.
122
- await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
123
- await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
124
-
125
- const manager = new HlsSessionManager({
126
- enabled: true,
127
- ffmpegBin: "ffmpeg",
128
- localBindHost: "127.0.0.1",
129
- localPort: 9090
130
- });
131
- const session = {
132
- id: SESSION_ID,
133
- dirPath,
134
- state: "ready",
135
- fileName: "video.mkv",
136
- startedAt: Date.now(),
137
- createEntryMs: Date.now(),
138
- lastAccessedAt: Date.now(),
139
- ffmpeg: null,
140
- lastError: "",
141
- consumers: new Set(),
142
- segmentFormat: overrides.segmentFormat ?? fmp4Format,
143
- usesExplicitCuts: true,
144
- useSyntheticPlaylist: true,
145
- playlistText: "#EXTM3U\n",
146
- segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
147
- initBytes: fmp4Format.extractInit(piece),
148
- encodeStartIndex: 0,
149
- firstSegmentLogged: false,
150
- waitEpoch: 0
151
- };
152
- manager.sessionsById.set(SESSION_ID, session);
153
- return { manager, session, dirPath };
154
- }
155
-
156
- test("a segment that exists is served, not reported as still being produced", async (t) => {
157
- const { manager, dirPath } = await managerWithReadySegment();
158
- t.after(async () => {
159
- await manager.disposeAll();
160
- await rm(dirPath, { recursive: true, force: true });
161
- });
162
-
163
- const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
164
-
165
- assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
166
- assert.equal(result.contentType, fmp4Format.segmentContentType);
167
-
168
- const chunks = [];
169
- for await (const chunk of result.stream) {
170
- chunks.push(chunk);
171
- }
172
- const served = Buffer.concat(chunks);
173
- assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
174
-
175
- // The position the PIECE states, carried into the fragment it belongs to.
176
- // Reading it is the step that threw in 2.9.124.
177
- assert.equal(
178
- Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
179
- Math.round(SEGMENT_START_SECONDS * VIDEO_TIMESCALE),
180
- "the segment must be stamped with where it really begins"
181
- );
182
- });
183
-
184
- test("a fault while preparing an existing segment is named, not turned into a wait", async (t) => {
185
- const broken = {
186
- ...fmp4Format,
187
- readSegmentStartSeconds() {
188
- throw new ReferenceError("readSelfContainedStartSeconds is not defined");
189
- }
190
- };
191
- const { manager, dirPath } = await managerWithReadySegment({ segmentFormat: broken });
192
- t.after(async () => {
193
- await manager.disposeAll();
194
- await rm(dirPath, { recursive: true, force: true });
195
- });
196
-
197
- const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
198
-
199
- assert.equal(
200
- result.kind,
201
- "failed",
202
- "answering 'warming-up' hides the fault and holds every request until the viewer gives up"
203
- );
204
- assert.match(result.message, /readSelfContainedStartSeconds is not defined/);
205
- });
1
+ /**
2
+ * @file A finished segment on disk must reach the viewer as bytes.
3
+ *
4
+ * The module tests cover each piece of the fMP4 path on its own, and every one
5
+ * of them passed while playback was dead: 2.9.124 called
6
+ * `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
7
+ * unit test imports that function straight from `mp4-boxes.js`, so the gap
8
+ * between a module and its CALLER was invisible. This test asks the session
9
+ * manager for a segment that exists and insists on getting it.
10
+ *
11
+ * The second half pins the reason a one-word slip cost a whole release: the
12
+ * failure was reported as "still being produced". Measured 2026-08-08 — segment
13
+ * #0 was held for 45 281 ms with twelve finished segments in the directory, and
14
+ * the log said nothing at all. Anything that goes wrong while preparing a file
15
+ * that EXISTS must be named and answered, never turned into an endless wait.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
24
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
25
+
26
+ const MOVIE_TIMESCALE = 1000;
27
+ const VIDEO_TIMESCALE = 90_000;
28
+ const AUDIO_TIMESCALE = 48_000;
29
+ const SEGMENT_START_SECONDS = 12.5;
30
+ const SESSION_ID = "11111111-2222-3333-4444-555555555555";
31
+
32
+ /**
33
+ * @param {string} type
34
+ * @param {Buffer} body
35
+ * @returns {Buffer}
36
+ */
37
+ function box(type, body) {
38
+ const head = Buffer.alloc(8);
39
+ head.writeUInt32BE(8 + body.length, 0);
40
+ head.write(type, 4, "latin1");
41
+ return Buffer.concat([head, body]);
42
+ }
43
+
44
+ /**
45
+ * `elst` holding one empty edit — how the `segment` muxer records where the
46
+ * piece sits on the source timeline.
47
+ *
48
+ * @param {number} offsetSeconds
49
+ * @returns {Buffer}
50
+ */
51
+ function emptyEdit(offsetSeconds) {
52
+ const body = Buffer.alloc(16);
53
+ body.writeUInt32BE(1, 4); // entry count
54
+ body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
55
+ body.writeInt32BE(-1, 12); // media_time
56
+ return box("elst", body);
57
+ }
58
+
59
+ /**
60
+ * @param {number} trackId
61
+ * @param {number} timescale
62
+ * @param {number} offsetSeconds
63
+ * @returns {Buffer}
64
+ */
65
+ function trak(trackId, timescale, offsetSeconds) {
66
+ const tkhdBody = Buffer.alloc(84);
67
+ tkhdBody.writeUInt32BE(trackId, 12);
68
+ const mdhdBody = Buffer.alloc(20);
69
+ mdhdBody.writeUInt32BE(timescale, 12);
70
+ return box("trak", Buffer.concat([
71
+ box("tkhd", tkhdBody),
72
+ box("edts", emptyEdit(offsetSeconds)),
73
+ box("mdia", box("mdhd", mdhdBody))
74
+ ]));
75
+ }
76
+
77
+ /**
78
+ * @param {number} trackId
79
+ * @returns {Buffer}
80
+ */
81
+ function traf(trackId) {
82
+ const tfhdBody = Buffer.alloc(8);
83
+ tfhdBody.writeUInt32BE(trackId, 4);
84
+ const tfdtBody = Buffer.alloc(12);
85
+ tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
86
+ tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
87
+ return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
88
+ }
89
+
90
+ /**
91
+ * A piece shaped like the `segment` muxer's output: header, two fragments and a
92
+ * trailing random-access index, all in one file.
93
+ *
94
+ * @param {number} offsetSeconds
95
+ * @returns {Buffer}
96
+ */
97
+ function selfContainedPiece(offsetSeconds) {
98
+ const mvhdBody = Buffer.alloc(100);
99
+ mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
100
+ const moov = box("moov", Buffer.concat([
101
+ box("mvhd", mvhdBody),
102
+ trak(1, VIDEO_TIMESCALE, offsetSeconds),
103
+ trak(2, AUDIO_TIMESCALE, offsetSeconds)
104
+ ]));
105
+ const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
106
+ const mdat = box("mdat", Buffer.alloc(64, 0x5a));
107
+ const mfra = box("mfra", Buffer.alloc(24, 0));
108
+ return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
109
+ }
110
+
111
+ /**
112
+ * A manager holding one session whose segments are already on disk, cut at
113
+ * explicit times — the ordinary keyframe-cut path.
114
+ *
115
+ * @param {{ segmentFormat?: object }} [overrides]
116
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
117
+ */
118
+ async function managerWithReadySegment(overrides = {}) {
119
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
120
+ const piece = selfContainedPiece(SEGMENT_START_SECONDS);
121
+ // Two segments, because a piece is only finished once the next one exists.
122
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
123
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
124
+
125
+ const manager = new HlsSessionManager({
126
+ enabled: true,
127
+ ffmpegBin: "ffmpeg",
128
+ localBindHost: "127.0.0.1",
129
+ localPort: 9090
130
+ });
131
+ const session = {
132
+ id: SESSION_ID,
133
+ dirPath,
134
+ state: "ready",
135
+ fileName: "video.mkv",
136
+ startedAt: Date.now(),
137
+ createEntryMs: Date.now(),
138
+ lastAccessedAt: Date.now(),
139
+ ffmpeg: null,
140
+ lastError: "",
141
+ consumers: new Set(),
142
+ segmentFormat: overrides.segmentFormat ?? fmp4Format,
143
+ usesExplicitCuts: true,
144
+ useSyntheticPlaylist: true,
145
+ playlistText: "#EXTM3U\n",
146
+ segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
147
+ initBytes: fmp4Format.extractInit(piece),
148
+ encodeStartIndex: 0,
149
+ firstSegmentLogged: false,
150
+ waitEpoch: 0
151
+ };
152
+ manager.sessionsById.set(SESSION_ID, session);
153
+ return { manager, session, dirPath };
154
+ }
155
+
156
+ test("a segment that exists is served, not reported as still being produced", async (t) => {
157
+ const { manager, dirPath } = await managerWithReadySegment();
158
+ t.after(async () => {
159
+ await manager.disposeAll();
160
+ await rm(dirPath, { recursive: true, force: true });
161
+ });
162
+
163
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
164
+
165
+ assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
166
+ assert.equal(result.contentType, fmp4Format.segmentContentType);
167
+
168
+ const chunks = [];
169
+ for await (const chunk of result.stream) {
170
+ chunks.push(chunk);
171
+ }
172
+ const served = Buffer.concat(chunks);
173
+ assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
174
+
175
+ // The position the PIECE states, carried into the fragment it belongs to.
176
+ // Reading it is the step that threw in 2.9.124.
177
+ assert.equal(
178
+ Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
179
+ Math.round(SEGMENT_START_SECONDS * VIDEO_TIMESCALE),
180
+ "the segment must be stamped with where it really begins"
181
+ );
182
+ });
183
+
184
+ test("a fault while preparing an existing segment is named, not turned into a wait", async (t) => {
185
+ const broken = {
186
+ ...fmp4Format,
187
+ readSegmentStartSeconds() {
188
+ throw new ReferenceError("readSelfContainedStartSeconds is not defined");
189
+ }
190
+ };
191
+ const { manager, dirPath } = await managerWithReadySegment({ segmentFormat: broken });
192
+ t.after(async () => {
193
+ await manager.disposeAll();
194
+ await rm(dirPath, { recursive: true, force: true });
195
+ });
196
+
197
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
198
+
199
+ assert.equal(
200
+ result.kind,
201
+ "failed",
202
+ "answering 'warming-up' hides the fault and holds every request until the viewer gives up"
203
+ );
204
+ assert.match(result.message, /readSelfContainedStartSeconds is not defined/);
205
+ });
206
+
207
+ test("a run's FIRST segment is served once the encoder has passed it, without waiting for a next one", async (t) => {
208
+ const { manager, session, dirPath } = await managerWithReadySegment();
209
+ t.after(async () => {
210
+ await manager.disposeAll();
211
+ await rm(dirPath, { recursive: true, force: true });
212
+ });
213
+ // The shape a resume takes: a run begun mid-file, so its first segment has no
214
+ // successor and nothing is producing one. Waiting for that successor is what
215
+ // held #317 for 46 s and then answered 404 to a browser that had given up.
216
+ await rm(path.join(dirPath, "segment-00001.mp4"));
217
+ session.encodeStartIndex = 0;
218
+ session.ffmpeg = { killed: true, kill() {} };
219
+ session.progress = { processedSeconds: SEGMENT_START_SECONDS + 10 };
220
+
221
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
222
+
223
+ assert.equal(
224
+ result.kind,
225
+ "file",
226
+ "the encoder is past this segment's end, so it is finished — the absence of a next one says nothing"
227
+ );
228
+ });