@torrent-tv/proxy 2.80.17 → 2.80.19

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.
@@ -19,28 +19,32 @@
19
19
  * killed, work out what it is looking at — without it, everything on disk after
20
20
  * a kill is unidentifiable and can only be thrown away.
21
21
  *
22
- * **What proves a segment is closed.** On the `hls` output branch ffmpeg
23
- * renames a temporary file into place, so a file that exists is complete. On
24
- * the `segment` branch every copied picture and every rung forced onto the
25
- * source's keyframes it does not, and a file appears and grows. So the rule
26
- * this store applies to the disk is the one the serving path has always used:
27
- * **a segment is closed when the NEXT number exists.** The highest number in a
28
- * directory is therefore the only unproven one, which is exactly the file a run
29
- * killed mid-write leaves behind.
22
+ * **What proves a segment is closed: its NAME.** A piece being written is called
23
+ * something else `making-40-00057.mp4`, tagged with the run writing it and
24
+ * takes its served name only when the
25
+ * encoder has said it is closed, which it does on a channel of its own
26
+ * (`-segment_list pipe:3`). The `hls` branch needs nothing extra: its muxer
27
+ * writes through a temporary name of its own, so its files appear under their
28
+ * final name whole. One rule for both, and true whether or not this process is
29
+ * alive: **a file under its served name is complete.**
30
30
  *
31
- * **A live run does not need that rule.** While this process is alive the
32
- * coverage map is told what has been closed as it happens; the disk rule is for
33
- * what a previous life left behind, and for a run that died without saying so.
31
+ * It was "a segment is closed when the NEXT number exists". That is true of one
32
+ * writer walking forward and false the moment two runs share an output, because
33
+ * the next file is then written by another process while this one is still open
34
+ * — and two runs on one output is not a rare state, it is what the plan gives an
35
+ * output whenever it places a second encoder. Field 2026-09-08:
36
+ * `segment-00057.mp4` served at 2 268 361 bytes and then at 4 510 940, exactly
37
+ * half; the browser appended the half and refused the whole for the rest of the
38
+ * session, `bufferAppendError` fourteen times with the picture frozen at
39
+ * 319.66 s. `segment-00055.mp4` the same, 211 957 against 2 620 617.
34
40
  */
35
41
 
36
42
  import { createHash } from "node:crypto";
37
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
43
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
38
44
  import { rmSync } from "node:fs";
39
45
  import os from "node:os";
40
46
  import path from "node:path";
41
47
 
42
- import { discardOpenPiece } from "./open-piece.js";
43
-
44
48
  /** Where every output's segments live. One root for the process. */
45
49
  export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
46
50
 
@@ -86,22 +90,6 @@ export class SegmentStore {
86
90
  /** Output key → when it was last asked for. @type {Map<string, number>} */
87
91
  #touched = new Map();
88
92
 
89
- /**
90
- * Numbers known closed for a reason other than a successor on the disk.
91
- *
92
- * Two things fill it. A live run says what it has finished as it finishes it.
93
- * And adoption records what the successor rule proved BEFORE it removes the
94
- * unproven piece — otherwise removing that piece would un-prove the segment
95
- * below it, which is a file that was demonstrably closed a moment earlier.
96
- *
97
- * @type {Map<string, Set<number>>}
98
- */
99
- #closed = new Map();
100
-
101
- /** Pieces already reported as taken on the successor rule, so one is said
102
- * once. @type {Map<string, Set<number>>} */
103
- #unreportedSaid = new Map();
104
-
105
93
  /** @type {{ info: Function, warn: Function }} */
106
94
  #logger;
107
95
 
@@ -250,42 +238,28 @@ export class SegmentStore {
250
238
  }
251
239
 
252
240
  /**
253
- * The segment numbers this output holds that are PROVEN closed.
254
- *
255
- * Two proofs, and they answer for the two ways this proxy writes segments.
256
- *
257
- * 1. **the writer said so** the `segment` muxer names each file on a channel
258
- * of its own the moment it closes it;
259
- * 2. **the next file exists** — which is all there is for the `hls` muxer,
260
- * which carries no such channel at all. That branch writes under a
261
- * temporary name and renames on close, so a file that exists is whole by
262
- * construction, and it is also what proves the pieces a previous life of
263
- * this process left behind.
264
- *
265
- * The highest number is left out either way: nothing distinguishes a finished
266
- * last piece from one that was being written when its run died.
267
- *
268
- * WHAT THE SECOND PROOF CANNOT ANSWER, and is left open deliberately: a piece
269
- * a LIVE RUN IS REWRITING. Several runs share one directory, so a file left by
270
- * an earlier one is a successor to a name the run working now has just
271
- * reopened, and the disk cannot know the difference. Whether that has ever
272
- * moved a decision is not established from any log we hold, and every remedy
273
- * for it changes what "ready" means for five readers with different questions
274
- * — so it waits for a session that shows it, rather than being guessed at.
241
+ * The segment numbers this output holds that are finished.
242
+ *
243
+ * ONE PROOF, AND IT IS THE PIECE'S OWN NAME. A piece being written is called
244
+ * `making-40-00042.mp4`; it is renamed to `segment-00042.mp4` when its writer
245
+ * says it has closed it, and on the `hls` branch which has no such channel
246
+ * the muxer's own `+temp_file` does the same rename for the same reason. So a
247
+ * file under the served name is complete, whoever made it and whenever.
248
+ *
249
+ * WHAT THIS REPLACED, because the difference is what a viewer felt. Closure
250
+ * used to be inferred from the NEXT number existing, which is sound for one
251
+ * writer walking forward and false the moment two runs share an output — and
252
+ * one-piece intervals guarantee that. Field 2026-09-08:
253
+ * `segment-00057.mp4` was served at 2 268 361 bytes and then at 4 510 940, the
254
+ * browser appended the truncated body, and `bufferAppendError` repeated to the
255
+ * end of the log with the picture frozen at 319.66 s. It also left the last
256
+ * piece of every run unprovable for ever, since nothing follows it.
275
257
  *
276
258
  * @param {string} key
277
259
  * @returns {number[]}
278
260
  */
279
261
  provenNumbers(key) {
280
- const contents = this.refresh(key);
281
- const stated = this.#closed.get(key);
282
- const proven = [];
283
- for (const index of contents.byNumber.keys()) {
284
- if (contents.byNumber.has(index + 1) || stated?.has(index)) {
285
- proven.push(index);
286
- }
287
- }
288
- return proven.sort((left, right) => left - right);
262
+ return [...this.refresh(key).byNumber.keys()].sort((left, right) => left - right);
289
263
  }
290
264
 
291
265
  /**
@@ -317,189 +291,151 @@ export class SegmentStore {
317
291
  return this.refresh(key).largest ?? { index: -1, size: 0 };
318
292
  }
319
293
 
320
- /**
321
- * A run is about to write these numbers again: forget that they were closed.
322
- *
323
- * A number closed once is not closed for ever. An encoder started at #N
324
- * rewrites #N and everything after it, and while it is doing so the file
325
- * under that name is half a segment — but the store remembered the earlier
326
- * closing and would call it whole. Field 2026-09-05: seventeen runs were
327
- * stopped and none ended normally, so numbers were being rewritten
328
- * constantly, and the player met a fatal append error it never recovered
329
- * from — an empty picture for the six minutes that followed.
330
- *
331
- * @param {string} key
332
- * @param {number} from - First number the run will write.
333
- * @param {number} [to] - Last one, inclusive. Infinite for a run given no end,
334
- * which does walk to the end of the film.
335
- */
336
- forgetClosed(key, from, to = Number.POSITIVE_INFINITY) {
337
- const known = this.#closed.get(key);
338
- if (!known || !Number.isInteger(from)) {
339
- return;
340
- }
341
- // BOUNDED BY THE RUN'S OWN STRETCH, because that is what it will rewrite.
342
- //
343
- // It used to forget everything from `from` upwards, on the reading that a
344
- // run has no end — which was true until runs were given intervals. A run of
345
- // #0..#0 then unproved the whole rest of the film, and with readiness a
346
- // projection of what is proven that is an output declaring itself unmade
347
- // every time an encoder starts anywhere near the beginning.
348
- const last = Number.isFinite(to) ? Math.max(from, Math.trunc(to)) : Number.POSITIVE_INFINITY;
349
- for (const index of known) {
350
- if (index >= from && index <= last) {
351
- known.delete(index);
352
- }
353
- }
354
- // What the directory says has to be read again too, so that the size of a
355
- // reopened piece is the size it has now and not the one it had before.
356
- this.#held.delete(key);
357
- }
358
294
 
359
295
  /**
360
296
  * Whether this piece is finished, and may therefore be served.
361
297
  *
362
- * Two proofs, and the first is the good one:
363
- *
364
- * 1. **the encoder said so** — the `segment` muxer names each file on a
365
- * channel of its own the moment it closes it, so the name is the writer's
366
- * own statement that the piece is whole;
367
- * 2. **the next file exists** — the only proof available on the `hls` branch,
368
- * which has no such channel, and for pieces left by an earlier life of this
369
- * process. On that branch it is sound: the muxer renames into place on
370
- * close, so a file that exists is finished.
371
- *
372
- * KNOWN AND LEFT ALONE HERE: on the `segment` branch this second proof can
373
- * still pass a piece a run is halfway through rewriting, which is how 110 698
374
- * bytes came to be served under a name whose neighbours are 12 MB (field
375
- * 2026-09-06). Telling the two branches apart is a fact of how a run writes,
376
- * it needs a field session of its own to verify, and it is not what stopped
377
- * playback on 2026-09-07 — so it stays open rather than being changed blind in
378
- * the path that hands bytes to a player. What the PLAN believes is a different
379
- * question and is answered: a live run's claim outranks readiness there.
298
+ * Its NAME is the proof, and there is no second one: a piece being written is
299
+ * called something else until whoever writes it says it is closed. That holds
300
+ * for a piece a live run is rewriting — the file standing there was closed by
301
+ * somebody, and it is replaced whole or not at all and for a piece left by an
302
+ * earlier life of this process, which the startup sweep answers the same way.
380
303
  *
381
304
  * @param {string} key
382
305
  * @param {number} index
383
306
  * @returns {boolean}
384
307
  */
385
308
  isClosed(key, index) {
386
- if (this.#closed.get(key)?.has(index)) {
387
- return true;
388
- }
389
- const bySuccessor = this.refresh(key).byNumber.has(index + 1);
390
- if (bySuccessor) {
391
- this.#noteUnreported(key, index);
392
- }
393
- return bySuccessor;
309
+ return this.refresh(key).byNumber.has(index);
394
310
  }
395
311
 
396
312
  /**
397
- * A piece taken as finished because the NEXT one exists, with nothing from a
398
- * run to say so.
399
- *
400
- * The successor rule is for what this process did not watch being written
401
- * pieces from a previous life of it. It is also the one way an unfinished
402
- * piece can be served: a file that stops short still has a successor if
403
- * anything wrote one, and then its name promises a whole span while it holds
404
- * a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
405
- * neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
406
- * the player jumped the hole it left.
407
- *
408
- * That cannot arise from two encoders any more — their stretches no longer
409
- * overlap — so what is left is a piece from a process that died without
410
- * clearing up. Said once per piece, with its size, so a return of it is
411
- * visible rather than inferred.
313
+ * Remove the pieces an output was in the middle of writing.
314
+ *
315
+ * They are under working names, so they were never servable and nothing has
316
+ * to be un-proven this is disk, not correctness. A process killed by the
317
+ * kernel leaves one per live run, and the kernel takes this process often
318
+ * enough for that to matter.
412
319
  *
413
320
  * @param {string} key
414
- * @param {number} index
321
+ * @param {string} dir
322
+ * @param {{ makingTagOf?: (name: string) => string | null }} format
323
+ * @param {string | null} [tag] - One run's own tag, or null for every run's.
324
+ * @returns {number} How many were removed.
415
325
  */
416
- #noteUnreported(key, index) {
417
- let said = this.#unreportedSaid.get(key);
418
- if (!said) {
419
- said = new Set();
420
- this.#unreportedSaid.set(key, said);
421
- }
422
- if (said.has(index)) {
423
- return;
424
- }
425
- said.add(index);
426
- let bytes = -1;
326
+ #sweepUnfinished(key, dir, format, tag = null) {
327
+ let removed = 0;
328
+ let names = [];
427
329
  try {
428
- bytes = statSync(this.pathOf(key, index)).size;
330
+ names = readdirSync(dir);
429
331
  } catch {
430
- // Gone between the listing and this: nothing to report about it.
431
- return;
332
+ return 0;
432
333
  }
433
- this.#logger?.info?.(
434
- `segment store: #${index} of ${key} is taken as finished because ` +
435
- `#${index + 1} exists no run reported it (${bytes} bytes). Expected only for ` +
436
- "pieces left by a previous life of this process."
437
- );
334
+ for (const name of names) {
335
+ const wroteIt = format?.makingTagOf?.(name) ?? null;
336
+ if (wroteIt === null || (tag !== null && wroteIt !== tag)) {
337
+ continue;
338
+ }
339
+ try {
340
+ rmSync(path.join(dir, name), { force: true });
341
+ removed += 1;
342
+ } catch {
343
+ // Then it stays, costing disk and nothing else.
344
+ }
345
+ }
346
+ if (removed > 0) {
347
+ this.#held.delete(key);
348
+ }
349
+ return removed;
438
350
  }
439
351
 
440
352
  /**
441
- * Say that a segment is closed for a reason the disk cannot show.
442
- *
443
- * A run reports what it has finished; the successor rule is only for what
444
- * this process did not watch being written.
353
+ * The encoder has closed a piece: give it the name it is served under.
354
+ *
355
+ * One rename inside the output's own directory one filesystem operation, and
356
+ * atomic there. Before it the file is not a segment and no request can reach
357
+ * it; after it, its existence IS the proof that it is whole, and that is one
358
+ * rule for every branch whether or not our own process is alive.
359
+ *
360
+ * It replaced a rule that served half a segment: a piece was taken as finished
361
+ * when the NEXT file existed. That is true of one writer walking forward and
362
+ * false the moment two runs share an output, because the next file is then
363
+ * written by another process while this one is still open — and two runs on one
364
+ * output is not a rare state, it is what the plan gives an output whenever it
365
+ * places a second encoder.
366
+ *
367
+ * Field 2026-09-08: `segment-00057.mp4` was served at 2 268 361 bytes and then
368
+ * at 4 510 940 — exactly half of it. The browser appended the half and refused
369
+ * the whole for the rest of the session, `bufferAppendError` fourteen times
370
+ * over with the picture frozen at 319.66 s. `segment-00055.mp4` went the same
371
+ * way, 211 957 against 2 620 617.
445
372
  *
446
373
  * @param {string} key
447
- * @param {number} index
374
+ * @param {string} makingName - What the encoder called it while writing.
375
+ * @param {{ servedNameOf?: (name: string) => string | null }} format
376
+ * @returns {string | null} The served name, or null where nothing was renamed.
448
377
  */
449
- markClosed(key, index) {
450
- if (!Number.isInteger(index) || index < 0) {
451
- return;
378
+ publish(key, makingName, format) {
379
+ const served = format?.servedNameOf?.(makingName) ?? null;
380
+ if (!served) {
381
+ return null;
452
382
  }
453
- let known = this.#closed.get(key);
454
- if (!known) {
455
- known = new Set();
456
- this.#closed.set(key, known);
383
+ const dir = path.join(this.#root, directoryNameFor(key));
384
+ try {
385
+ renameSync(path.join(dir, makingName), path.join(dir, served));
386
+ } catch (error) {
387
+ // The file may already be gone — a process killed between closing the
388
+ // piece and this line. Said rather than swallowed: a piece the encoder
389
+ // reported and the disk does not have is worth knowing about.
390
+ this.#logger?.warn?.(
391
+ `segment store: could not publish ${makingName} of ${key.slice(0, 60)}: ` +
392
+ `${error instanceof Error ? error.message : String(error)}`
393
+ );
394
+ return null;
457
395
  }
458
- known.add(index);
396
+ // What the directory holds has changed, so the memory of it is stale.
397
+ this.#held.delete(key);
398
+ return served;
459
399
  }
460
400
 
461
401
  /**
462
- * Throw away the piece a run had open when it ended, if it is unusable.
402
+ * Clear up after a run that has ended: remove what it left unfinished.
403
+ *
404
+ * Every file it left open carries its own tag, so this is a name match and
405
+ * nothing else — no stretch to search, no bytes to judge, and no chance of
406
+ * removing a piece somebody else closed.
463
407
  *
464
- * The store owns this output's directory and knows how its files are named,
465
- * so it is the one place that can answer which file a run left open. The
466
- * judging of a NON-EMPTY file does it carry every track it should — needs
467
- * the output's init bytes and belongs to whoever holds them; passed in, and
468
- * absent it only an empty file is removed, which is the case that caused this
469
- * to be written (a run stopped 548 ms after starting left a zero-byte file
470
- * whose name then read as a segment made).
408
+ * WHAT IT REPLACED, because the difference is the whole of the rename design.
409
+ * It used to take the highest SERVED name inside the stretch the ended run was
410
+ * given and judge whether its bytes looked usable a guess, needed only
411
+ * because an unfinished piece was indistinguishable from a finished one. Under
412
+ * the naming rule it would now remove a complete segment: the highest served
413
+ * name in a dead run's stretch is a piece it closed.
471
414
  *
472
415
  * @param {string} key
473
- * @param {{ from: number, to: number } | null} within - The run's own
474
- * numbers: several runs write into one directory, so the piece to discard
475
- * has to be looked for inside the stretch the ended run was given.
476
- * @param {((raw: Buffer) => boolean) | null} [judgeUsable]
477
- * @returns {Promise<number | null>} The segment number removed, or null.
416
+ * @param {number} startedAt - The run's first segment number, which is its tag.
417
+ * @returns {number} How many unfinished pieces were removed.
478
418
  */
479
- async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
419
+ clearUpAfter(key, startedAt) {
480
420
  const format = this.#formats.get(key);
481
421
  if (!format) {
482
- return null;
422
+ return 0;
483
423
  }
484
- const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
485
- if (removed !== null) {
486
- this.#held.delete(key);
424
+ const removed = this.#sweepUnfinished(
425
+ key,
426
+ this.directoryFor(key),
427
+ format,
428
+ String(Number.isInteger(startedAt) && startedAt > 0 ? startedAt : 0)
429
+ );
430
+ if (removed > 0) {
487
431
  this.#logger?.info?.(
488
- `segment store: discarded the open piece #${removed} of ${key.slice(0, 60)}`
432
+ `segment store: cleared up ${removed} unfinished piece(s) of the run at ` +
433
+ `#${startedAt} on ${key.slice(0, 60)}`
489
434
  );
490
435
  }
491
436
  return removed;
492
437
  }
493
438
 
494
- /**
495
- * The one number in this output whose closure nothing on disk proves.
496
- *
497
- * @param {string} key
498
- * @returns {number} -1 when the directory holds no segments.
499
- */
500
- unprovenNumber(key) {
501
- return this.refresh(key).unproven;
502
- }
503
439
 
504
440
  /**
505
441
  * Where a segment is, or null when this output does not hold it.
@@ -542,7 +478,6 @@ export class SegmentStore {
542
478
  this.#held.delete(key);
543
479
  this.#formats.delete(key);
544
480
  this.#touched.delete(key);
545
- this.#closed.delete(key);
546
481
  this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
547
482
  }
548
483
 
@@ -712,30 +647,19 @@ export class SegmentStore {
712
647
  continue;
713
648
  }
714
649
  this.#formats.set(entry.key, format);
715
- // Recorded BEFORE the unproven piece goes: taking that file away would
716
- // otherwise leave the segment below it without a successor, and a file
717
- // that was demonstrably closed a moment ago would stop being servable.
718
- for (const index of this.provenNumbers(entry.key)) {
719
- this.markClosed(entry.key, index);
720
- }
721
- const unproven = this.unprovenNumber(entry.key);
722
- if (unproven >= 0) {
723
- const held = this.refresh(entry.key);
724
- const filePath = held.byNumber.get(unproven);
725
- if (filePath) {
726
- try {
727
- rmSync(filePath, { force: true });
728
- unprovenRemoved += 1;
729
- } catch {
730
- // Then it stays unproven and is simply never served.
731
- }
732
- }
733
- this.#held.delete(entry.key);
734
- }
650
+ // EVERY SEGMENT FOUND IS COMPLETE, because a piece is given its served
651
+ // name only once the encoder has said it is closed. So there is nothing to
652
+ // prove here and nothing to un-prove: what the directory holds under
653
+ // served names is what a killed process finished.
654
+ //
655
+ // What it may also hold is pieces it was in the middle of, under their
656
+ // working names, and those are swept — the file a run was writing when the
657
+ // kernel took the process is exactly this.
658
+ unprovenRemoved += this.#sweepUnfinished(entry.key, entry.dir, format);
735
659
  adopted += 1;
736
660
  this.#logger.info(
737
661
  `segment-store adopted ${path.basename(entry.dir)}: ${this.provenNumbers(entry.key).length} ` +
738
- `segments a killed process had already made, ${unproven >= 0 ? "1" : "no"} unfinished piece removed`
662
+ "segments a killed process had already finished"
739
663
  );
740
664
  }
741
665
  return { adopted, dropped, unprovenRemoved };
@@ -703,7 +703,22 @@ export function buildRunCommand({
703
703
  "-segment_list_flags",
704
704
  "+live",
705
705
  ...explicitTimes,
706
- segmentFormat.segmentFileNameTemplate()
706
+ // UNDER A WORKING NAME, not the one it is served as. A piece under its
707
+ // served name is complete by construction then, whoever else is writing
708
+ // into the same directory — and the name arrives on the channel above the
709
+ // instant ffmpeg closes it, which is what turns it into the served one.
710
+ //
711
+ // The other branch needs none of this: the HLS muxer writes through a
712
+ // temporary name of its own (`+temp_file`), so its files appear under
713
+ // their final name whole.
714
+ //
715
+ // TAGGED WITH THE STRETCH IT WAS GIVEN, which names the run without any
716
+ // counter to keep: intervals never overlap, so two live runs of one output
717
+ // begin at different numbers by construction. That is what makes clearing
718
+ // up after a dead run a well-formed question — its unfinished pieces are
719
+ // the ones carrying its own tag — where before it was answered by taking
720
+ // the highest SERVED name inside its stretch and judging the bytes.
721
+ segmentFormat.makingFileNameTemplate(String(safeIndex))
707
722
  );
708
723
  } else {
709
724
  args.push(
@@ -98,47 +98,22 @@ export class RunCosts {
98
98
  * @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
99
99
  */
100
100
  seconds() {
101
- const dying = middleOf(this.#dying);
102
- const first = middleOf(this.#firstOutput);
101
+ // MEASURED OR ABSENT, and absent is said as zero rather than as a guess.
102
+ //
103
+ // There was an `Infinity` here for a while, for the cost of a move, on the
104
+ // reasoning that an unmeasured price must not license an irreversible act.
105
+ // It was an exception in a model that needs none, and it is not required: a
106
+ // first piece cannot appear faster than it takes to ENCODE one, and how fast
107
+ // this host encodes is measured before any viewer exists. The floor is
108
+ // derived from that where the arithmetic is, and every figure here stays a
109
+ // plain reading or a plain zero.
110
+ //
111
+ // `firstByteWaitSec` is spawn to first piece, so it already contains one
112
+ // piece's encoding. Whoever uses it separates the two, because the piece
113
+ // costs more when encoders share the machine and the spawn does not.
103
114
  return {
104
- // UNKNOWN IS NOT ZERO, and for a cost it is not a small number either: it
105
- // is the figure that makes the act it prices never worth doing. Reported
106
- // as 0, an unmeasured move was FREE in the plan's arithmetic, so any gain
107
- // however small justified it — and moving an encoder is irreversible,
108
- // because the process it kills cannot be un-killed.
109
- //
110
- // The blindness was self-sustaining: `#firstOutput` only takes a reading
111
- // from a run that produced something, and a run killed 0.8 s after
112
- // starting produces nothing. So a thrash prevented the measurement that
113
- // would have stopped it. Field 2026-09-08: 39 moves in one session, 24 of
114
- // them between three adjacent numbers — #58 to #59, #59 to #58, #58 to
115
- // #60, #60 to #58, six times each — while the picture stood still for
116
- // 116.7 s.
117
- //
118
- // TWO QUESTIONS, NOT ONE, and they take the unknown differently.
119
- //
120
- // PLACING an encoder where there is none has no alternative: the film gets
121
- // made or it does not. So an unmeasured cost must not stand in the way,
122
- // and the honest figure is what has been measured or nothing.
123
- //
124
- // MOVING one has an alternative — leave it alone — and it is
125
- // irreversible, because the process it kills cannot be un-killed. There
126
- // an unmeasured cost must not license the act, and `Infinity` is the
127
- // identity of the comparison that consumes it: "nobody has measured what
128
- // this costs" and "never worth doing" are the same statement about an
129
- // action whose price is unknown.
130
- //
131
- // Reported as 0 for both, an unmeasured move was FREE in the plan's
132
- // arithmetic, so a gain of a fraction of a second justified it. And the
133
- // blindness was self-sustaining: `#firstOutput` takes a reading only from
134
- // a run that produced something, and every run in a thrash is killed
135
- // before it finishes anything.
136
- killCostSec: (dying ?? 0) / 1000,
137
- firstByteWaitSec: (first ?? 0) / 1000,
138
- moveCostSec:
139
- first === null
140
- ? Number.POSITIVE_INFINITY
141
- : ((dying ?? 0) + first) / 1000,
115
+ killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
116
+ firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
142
117
  samples: Math.min(this.#dying.length, this.#firstOutput.length)
143
118
  };
144
119
  }