@torrent-tv/proxy 2.80.18 → 2.81.0

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 (67) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/docs/encode-architecture.md +51 -2
  3. package/package.json +1 -1
  4. package/research/double-spawn-2026-09-10.md +171 -0
  5. package/services/disk/DiskSpace.js +146 -0
  6. package/services/disk/wire.js +60 -0
  7. package/services/encode/EncodeRun.js +37 -9
  8. package/services/encode/SegmentStore.js +284 -232
  9. package/services/encode/run-command.js +16 -1
  10. package/services/hls-session-manager.js +31 -128
  11. package/services/orchestrators/EncodeOrchestrator.js +52 -38
  12. package/services/piece-store/allowance.js +107 -0
  13. package/services/piece-store/piece-disk-store.js +365 -0
  14. package/services/piece-store/shared-piece-store.js +1549 -1535
  15. package/services/segment-formats/fmp4.js +54 -0
  16. package/services/segment-formats/mpegts.js +54 -0
  17. package/services/torrent-worker/client.js +32 -0
  18. package/services/torrent-worker/pool-adapter.js +15 -0
  19. package/services/torrent-worker/protocol.js +9 -0
  20. package/services/torrent-worker/worker.js +8 -1
  21. package/services/viewer/positions.js +48 -0
  22. package/test/audio-inventory.test.js +176 -176
  23. package/test/auto-quality-step.test.js +514 -514
  24. package/test/concurrent-cost.test.js +138 -138
  25. package/test/coverage-follows-the-disk.test.js +191 -187
  26. package/test/coverage-map.test.js +195 -195
  27. package/test/declared-tracks.test.js +35 -35
  28. package/test/disk-space.test.js +138 -0
  29. package/test/encode-orchestrator.test.js +0 -3
  30. package/test/encode-run.test.js +5 -12
  31. package/test/held-request-width.test.js +155 -155
  32. package/test/helpers/encode-run.js +2 -2
  33. package/test/matroska-blocks.test.js +0 -0
  34. package/test/matroska-cues-track.test.js +192 -192
  35. package/test/mp4-composition-times.test.js +0 -0
  36. package/test/mp4-subtitles.test.js +173 -173
  37. package/test/one-authority.test.js +281 -220
  38. package/test/orchestrator-wired.test.js +199 -195
  39. package/test/packet-witness-ring.test.js +236 -236
  40. package/test/packet-witness.test.js +148 -148
  41. package/test/piece-disk-store.test.js +267 -0
  42. package/test/piece-reader.test.js +4 -4
  43. package/test/piece-store-eviction.test.js +17 -17
  44. package/test/piece-store-reservations.test.js +20 -1
  45. package/test/piece-store-slow-disk.test.js +16 -1
  46. package/test/produced-copy-choice.test.js +258 -358
  47. package/test/read-window.test.js +6 -6
  48. package/test/run-intervals.test.js +100 -100
  49. package/test/seek-landing.test.js +109 -109
  50. package/test/segment-serve-wiring.test.js +8 -9
  51. package/test/segment-store-eviction.test.js +232 -0
  52. package/test/segment-store.test.js +238 -216
  53. package/test/segments-are-shared.test.js +1 -1
  54. package/test/shared-piece-store.test.js +12 -12
  55. package/test/sidecar-naming.test.js +142 -142
  56. package/test/subtitle-cue-framing.test.js +200 -200
  57. package/test/subtitle-cue-walk.test.js +369 -369
  58. package/test/subtitle-defaults.test.js +97 -97
  59. package/test/subtitle-track-numbering.test.js +370 -370
  60. package/test/tail-duplication.test.js +167 -167
  61. package/test/tracks-begin-together.test.js +195 -195
  62. package/test/two-viewers-one-picture.test.js +374 -374
  63. package/test/video-facts.test.js +102 -102
  64. package/test/wedge-certainty.test.js +131 -131
  65. package/services/encode/open-piece.js +0 -135
  66. package/services/piece-store/disk-tier.js +0 -151
  67. package/test/open-piece.test.js +0 -152
@@ -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(
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { createReadStream, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
- import { access, readdir, readFile, rm, stat, unlink } from "node:fs/promises";
11
+ import { access, readFile, rm, stat, unlink } from "node:fs/promises";
12
12
  import { Readable } from "node:stream";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
@@ -70,7 +70,6 @@ import { Output, Outputs } from "./output/Output.js";
70
70
  import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./output/playlists.js";
71
71
  import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
72
72
  import { ProducedIndex } from "./produced-index.js";
73
- import { discardOpenPiece } from "./encode/open-piece.js";
74
73
  import { SegmentStore } from "./encode/SegmentStore.js";
75
74
  import { EncodeCost } from "./quality/EncodeCost.js";
76
75
  import {
@@ -89,11 +88,13 @@ import {
89
88
  // — did not move when the code did.
90
89
  export { ffmpegSeconds, onKeyframeGridFor, seekLandingOffsetFor, segmentCutTimesFrom };
91
90
  import { viewersOf } from "./viewer/Viewer.js";
91
+ import { viewerSegmentsOn } from "./viewer/positions.js";
92
92
  import { Viewers } from "./viewer/Viewers.js";
93
93
  import { LiveOutputs } from "./output/LiveOutputs.js";
94
94
  import { variantHeightsFor } from "./output/ladder.js";
95
95
  import { EncodeOrchestrator } from "./orchestrators/EncodeOrchestrator.js";
96
96
  import { readDiskFree } from "./memory-report.js";
97
+ import { wireDiskSpace } from "./disk/wire.js";
97
98
 
98
99
  /**
99
100
  * Whether an encoder run died because its INPUT went away, rather than because
@@ -425,10 +426,6 @@ const TRUE_START_MEMORY = 200;
425
426
  // twice. It runs on the restart path and a session an hour in has thousands of
426
427
  // segments; the figure is for a comparison, not an inventory.
427
428
  const BACKWARD_RESTART_SCAN_SEGMENTS = 300;
428
- // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
429
- // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
430
- // the same session directory. See #startEncodeRun.
431
- const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
432
429
  // A seek-restart run that exits this fast never did real work — it failed at
433
430
  // the seek/open step itself (container demux error, bad audio frame boundary,
434
431
  // etc.), not mid-stream. Used to tell a genuine seek failure apart from a
@@ -478,18 +475,6 @@ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
478
475
  * there for the life of the process.
479
476
  */
480
477
  const SEGMENT_STORE_IDLE_MS = 6 * 60 * 60 * 1000;
481
- /**
482
- * The share of FREE disk the produced segments may take.
483
- *
484
- * A share of what is free NOW, re-read on every sweep, for the same reason the
485
- * piece store re-derives its memory allowance every minute: a machine that
486
- * fills up after this proxy started would otherwise go on spending an allowance
487
- * taken when it was empty. A Home Assistant install often runs from a 32 GB
488
- * card carrying everything else in the house.
489
- */
490
- const SEGMENT_STORE_FREE_SHARE = 0.25;
491
- /** What the store may hold where the free space cannot be read at all. */
492
- const SEGMENT_STORE_FALLBACK_BYTES = 2 * 1024 * 1024 * 1024;
493
478
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
494
479
  // Realtime budget — runtime downswitch (software encoder only). Periodically
495
480
  // check each active software-transcode session's ffmpeg `speed`; when it stays
@@ -1609,6 +1594,13 @@ export class HlsSessionManager {
1609
1594
  void this.cleanupExpired();
1610
1595
  }, CLEANUP_INTERVAL_MS);
1611
1596
  this.cleanupTimer.unref();
1597
+ // One owner of the disk, and the list of what takes it lives with the owner.
1598
+ this.diskSpace = wireDiskSpace({
1599
+ segmentStore: this.segmentStore,
1600
+ torrentPool: this.torrentPool,
1601
+ readFree: readDiskFree,
1602
+ logger
1603
+ });
1612
1604
  // Realtime-budget monitor: only meaningful for the software encoder with a
1613
1605
  // benchmark (the only path that can pick/step resolution). Cheap no-op scan
1614
1606
  // otherwise.
@@ -5535,7 +5527,10 @@ export class HlsSessionManager {
5535
5527
  inputUnavailable: (message) => isInputUnavailable(message),
5536
5528
  onProgress: (report) => this.#noteRunProgress(session, run, report),
5537
5529
  indexOfName: (name) => session.segmentFormat.segmentIndexFromName(name),
5538
- onClosed: (name) => this.segmentStore.markClosed(session.outputKey ?? "", session.segmentFormat.segmentIndexFromName(name)),
5530
+ // Why this encoder exists, recorded with its argument list. It used to be
5531
+ // handed to a separate `start` call; there is no separate call now.
5532
+ because,
5533
+ onClosed: (name) => this.segmentStore.publish(session.outputKey ?? "", name, session.segmentFormat),
5539
5534
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
5540
5535
  });
5541
5536
  session.runs.add(run);
@@ -5548,8 +5543,6 @@ export class HlsSessionManager {
5548
5543
  // premature downscale.
5549
5544
  session.budgetSlowSince = 0;
5550
5545
 
5551
- run.start(because);
5552
-
5553
5546
  logger.info(
5554
5547
  `transcode ${session.id} encode-run #${safeIndex}..#${runEnd} from segment #${safeIndex} ` +
5555
5548
  `(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
@@ -7692,31 +7685,15 @@ export class HlsSessionManager {
7692
7685
  // disposed. A run that had already finished or failed is not among them,
7693
7686
  // which is what keeps a stop from erasing how it actually ended.
7694
7687
  for (const run of running) {
7695
- const ffmpeg = run.process;
7696
- // The stretch it was given, read now rather than when the process finally
7697
- // exits: by then the session may have started another run with another
7698
- // stretch, and the piece to discard belongs to this one.
7699
- const stoppedSpan = {
7700
- from: Number.isInteger(run.from) ? run.from : 0,
7701
- to: Number.isInteger(run.to) ? run.to : -1
7702
- };
7703
7688
  // The run resumes itself if it was suspended — a stopped process does not
7704
7689
  // act on SIGTERM until it is continued — records the cause, and answers
7705
7690
  // its own exit. Nothing here has to null a field so that the exit is read
7706
7691
  // correctly, because there is no shared field left to misread.
7707
7692
  run.stop(reason);
7708
- // The session outlives its runs a stopped rung keeps serving what it
7709
- // made so the piece this one had open must not be left looking like one
7710
- // of them. Not awaited: the caller's own work does not depend on it, and
7711
- // the wait is for a process that has already been told to go.
7712
- // WHAT THE RUN ITSELF NAMED, which is the only thing that can say a piece
7713
- // is whole — a piece cut short still decodes. Written 2026-09-06 and never
7714
- // passed from here, so the clearing-up ran with nothing proven and fell
7715
- // back to bounding itself by the run's declared stretch alone.
7716
- const provenName = typeof run.provenName === "string" ? run.provenName : null;
7717
- void waitForChildExit(ffmpeg, ENCODE_RUN_TERMINATE_GRACE_MS).then(() =>
7718
- this.#discardUnfinishedPiece(session, session.dirPath, stoppedSpan, provenName)
7719
- );
7693
+ // Nothing is cleared up from here. What this run left open is under its
7694
+ // own working name, and the encoding layer removes it when the run's
7695
+ // ending reaches it one place, and it needs neither the stretch nor the
7696
+ // init bytes this method used to fetch to judge a file by its contents.
7720
7697
  }
7721
7698
  logger.info(`transcode ${session.id} ${running.length} encoder(s) stopped: ${reason}`);
7722
7699
  }
@@ -9589,63 +9566,6 @@ export class HlsSessionManager {
9589
9566
  return this.#producedIndex(session).pathOf(fileName);
9590
9567
  }
9591
9568
 
9592
- /**
9593
- * Throw away the piece a run was in the middle of when it ended.
9594
- *
9595
- * The `segment` muxer creates its output file when it opens it and writes
9596
- * into it until the next cut, so at any instant exactly one file in a run's
9597
- * directory is unfinished: the highest-numbered one. A run that reaches the
9598
- * end of its work closes that file properly and it is a good piece; a run
9599
- * killed for a seek does not — measured 2026-09-03, ffmpeg exited 19 ms after
9600
- * SIGTERM and left `segment-00025.mp4` at zero bytes.
9601
- *
9602
- * Leaving it is what created the deadlock this method exists to prevent: an
9603
- * empty file has a name like any other, so it closed the only hole in the
9604
- * numbering and the look-ahead kept the encoder stopped for having "produced"
9605
- * it. Removing it at the moment the run ends means the question never has to
9606
- * be asked again by anyone.
9607
- *
9608
- * A piece is removed only when it is unusable. A run that finished its last
9609
- * file — the ordinary end of a file, or a stop that arrived between two cuts
9610
- * — has nothing wrong with it, and deleting good output would cost the work
9611
- * of making it twice.
9612
- *
9613
- * @param {HlsSession} session
9614
- * @param {string | null | undefined} runDirPath
9615
- * @returns {Promise<void>}
9616
- */
9617
- async #discardUnfinishedPiece(session, runDirPath, within = null, provenName = null) {
9618
- const canJudgeTracks =
9619
- typeof session.segmentFormat?.hasEveryTrack === "function" &&
9620
- session.initBytes &&
9621
- session.initBytes.length > 0;
9622
- const removed = await discardOpenPiece(
9623
- runDirPath,
9624
- session.segmentFormat,
9625
- within,
9626
- canJudgeTracks
9627
- ? (raw) => {
9628
- const bytes = cutsAtGivenTimes(session) && session.segmentFormat.stripInit
9629
- ? session.segmentFormat.stripInit(raw)
9630
- : raw;
9631
- return session.segmentFormat.hasEveryTrack(bytes, session.initBytes);
9632
- }
9633
- : null,
9634
- provenName
9635
- );
9636
- if (removed !== null) {
9637
- // Removed on purpose, so the index must not go on answering with it.
9638
- this.#producedIndex(session).invalidate();
9639
- logger.info(
9640
- `transcode ${session.id} discarded segment #${removed}: ` +
9641
- "the run ended while it was open, so it holds no usable piece"
9642
- );
9643
- }
9644
- }
9645
-
9646
-
9647
-
9648
-
9649
9569
  #holdForProduction(session, fileName, isPlaylist, options) {
9650
9570
  /** @type {{ address: string, rank: number, topRank: number } | null} */
9651
9571
  let ranked = null;
@@ -9745,32 +9665,22 @@ export class HlsSessionManager {
9745
9665
  // The segments outlive every session on them, so what they cost is decided
9746
9666
  // here rather than by anybody's departure: how long ago each output was
9747
9667
  // last read, and how much room the disk has for the lot.
9668
+ // The room is the disk owner's to divide; this asks what the share is now.
9669
+ await this.diskSpace.revise();
9748
9670
  this.segmentStore.enforce({
9749
9671
  idleMs: SEGMENT_STORE_IDLE_MS,
9750
- maxBytes: await this.#segmentStoreAllowance()
9672
+ maxBytes: this.diskSpace.segmentBytes(),
9673
+ viewersAt: (key) =>
9674
+ viewerSegmentsOn({
9675
+ sessions: this.sessionsById.values(),
9676
+ outputKey: key,
9677
+ segmentAt: (session, seconds) => this.#segmentIndexForTime(session, seconds),
9678
+ now: Date.now(),
9679
+ staleAfterMs: this.presenceStaleAfterMs()
9680
+ })
9751
9681
  });
9752
9682
  }
9753
9683
 
9754
- /**
9755
- * How much disk the produced segments may hold.
9756
- *
9757
- * A share of what is FREE now rather than a figure fixed at startup, for the
9758
- * same reason the piece store's memory allowance is re-derived every minute:
9759
- * a machine that fills up after this proxy started would otherwise go on
9760
- * spending an allowance taken when it was empty. On a Home Assistant install
9761
- * that disk is often a 32 GB card carrying everything else the household
9762
- * runs.
9763
- *
9764
- * @returns {Promise<number>}
9765
- */
9766
- async #segmentStoreAllowance() {
9767
- const free = await readDiskFree(this.segmentStore.root);
9768
- if (!Number.isFinite(free) || free <= 0) {
9769
- return SEGMENT_STORE_FALLBACK_BYTES;
9770
- }
9771
- return Math.max(SEGMENT_STORE_FALLBACK_BYTES, Math.floor(free * SEGMENT_STORE_FREE_SHARE));
9772
- }
9773
-
9774
9684
  /**
9775
9685
  * Return a progress snapshot for the given session, or `null` if not found.
9776
9686
  * Also refreshes `lastAccessedAt` to prevent the session from expiring.
@@ -10156,14 +10066,7 @@ export class HlsSessionManager {
10156
10066
  for (const sessionId of activeIds) {
10157
10067
  await this.disposeSession(sessionId);
10158
10068
  }
10159
- const rootDir = path.join(os.tmpdir(), "torrent-tv-hls");
10160
- try {
10161
- const dirs = await readdir(rootDir);
10162
- if (dirs.length === 0) {
10163
- await rm(rootDir, { recursive: true, force: true });
10164
- }
10165
- } catch (_error) {
10166
- // Best effort cleanup.
10167
- }
10069
+ // Everything this process owns, root included. See SegmentStore.dropAll.
10070
+ this.segmentStore.dropAll("the proxy is shutting down");
10168
10071
  }
10169
10072
  }
@@ -273,11 +273,10 @@ export class EncodeOrchestrator {
273
273
  * @param {number} index
274
274
  */
275
275
  noteProduced(address, index) {
276
- // TOLD TO THE AUTHORITY, not only to the map. A piece being closed is a fact
277
- // about the disk, and the store is what holds those; told to the map alone
278
- // it would survive exactly until the next time the map is brought back into
279
- // step, and then be gone with no file to show for it.
280
- this.segmentStore?.markClosed(address, index);
276
+ // Nothing is told to the store: by the time this is called the piece is
277
+ // ALREADY under its served name, because the rename is what closing it
278
+ // means. A statement kept beside the disk would be a second owner of one
279
+ // fact, which is what item 87 removed from the coverage map.
281
280
  this.coverageOf(address).markReady(index);
282
281
  for (const run of this.runsOn(address)) {
283
282
  run.noteProduced(index);
@@ -375,6 +374,23 @@ export class EncodeOrchestrator {
375
374
  // when the reason it cuts the budget changes, so asking it three times in
376
375
  // one pass is three chances to say a thing that happened once.
377
376
  const maxRuns = this.#affordableOn(address, live);
377
+ // THE TERMS EVERY ARRIVAL IS COMPUTED FROM, named here so the line below can
378
+ // print them. A decision of this plan is `delay + (index - at) / rate +
379
+ // madeBetween * refetch` against a deadline, so without the rate and the two
380
+ // prices no recorded decision can be reproduced — which is what happened
381
+ // with the one-piece intervals of 2026-09-08: the rate was substituted six
382
+ // times from the speeds the session reported elsewhere and none of them gave
383
+ // the answer the plan had given.
384
+ const costs = this.#costs.seconds();
385
+ const refetchSecPerFilmSecond = this.refetchSecPerFilmSecond(address);
386
+ // The best figure this host has: what a run here is doing now, what one was
387
+ // last measured doing, or what the startup benchmark predicted. The first
388
+ // two are this output's own; the third exists before either.
389
+ const speedX = Math.max(
390
+ live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
391
+ this.#lastSpeed.get(address) ?? 0,
392
+ this.startingSpeedFor(address) || 0
393
+ );
378
394
  const actions = planEncoders({
379
395
  coverage,
380
396
  windows,
@@ -387,26 +403,19 @@ export class EncodeOrchestrator {
387
403
  // What a start and a kill cost, measured from this host's own runs rather
388
404
  // than written into the code from one machine's reading. Zero until
389
405
  // something has been measured, which is the same convention as the
390
- // refetch price below and is stated so the bias is known.
391
- ...this.#costs.seconds(),
406
+ // refetch price and is stated so the bias is known.
407
+ ...costs,
392
408
  // What a second of film costs to fetch again, in seconds of swarm time.
393
409
  // Answered by whoever measures the film's own byte rate and the swarm's;
394
410
  // zero until they have, which makes driving through look cheaper than it
395
411
  // is and is stated here so the bias is known.
396
- refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
412
+ refetchSecPerFilmSecond,
397
413
  // How much slower one encoder runs beside others, read off this host's own
398
414
  // startup measurement. A pure function over a measured table: beyond what
399
415
  // was measured it holds the largest reading rather than continuing a curve
400
416
  // nothing observed.
401
417
  contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
402
- // The best figure this host has: what a run here is doing now, what one
403
- // was last measured doing, or what the startup benchmark predicted. The
404
- // first two are this output's own; the third exists before either.
405
- speedX: Math.max(
406
- live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
407
- this.#lastSpeed.get(address) ?? 0,
408
- this.startingSpeedFor(address) || 0
409
- )
418
+ speedX
410
419
  });
411
420
 
412
421
  // A move is the plan taking a running encoder away from where it already
@@ -427,10 +436,21 @@ export class EncodeOrchestrator {
427
436
  // exiting, twelve of them normally — and the reasons printed beside them
428
437
  // read as moves, so the fault was diagnosed three times as something it was
429
438
  // not. An interval is what a run is, and it was the one thing missing.
439
+ //
440
+ // AND THE TERMS, for the same reason one step further: an interval says what
441
+ // was decided and the terms say why. `speed` is what every arrival is
442
+ // divided by, so a decision recorded without it can be re-read and not
443
+ // recomputed; `firstByte` and `kill` are what a start and a stop cost here;
444
+ // `refetch` is what a second of film costs to fetch again. A zero in the
445
+ // last three is a measurement nobody has taken, not a free operation, and it
446
+ // is printed so that reading it as free is a choice rather than an accident.
430
447
  if (actions.some((action) => action.type !== "keep")) {
431
448
  this.logger.info(
432
449
  `encode-plan on ${address}: ` +
433
- `${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}`
450
+ `${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}` +
451
+ ` [speed=${speedX.toFixed(2)}x firstByte=${costs.firstByteWaitSec.toFixed(2)}s ` +
452
+ `kill=${costs.killCostSec.toFixed(2)}s refetch=${refetchSecPerFilmSecond.toFixed(3)}s/s ` +
453
+ `maxRuns=${maxRuns} live=${live.length}]`
434
454
  );
435
455
  }
436
456
  if (actions.some((action) => action.type === "move")) {
@@ -547,18 +567,16 @@ export class EncodeOrchestrator {
547
567
  const onThisOutput = this.#runs.get(address) ?? [];
548
568
  onThisOutput.push(run);
549
569
  this.#runs.set(address, onThisOutput);
550
- // This run rewrites the stretch it was given, so what was closed inside that
551
- // stretch is no longer closed. Without this a number closed by an earlier run
552
- // stays servable while a later one is halfway through writing it again.
553
- //
554
- // Bounded by the run's own end, which is the same number the claim below
555
- // carries. Unbounded it unproved the whole film beyond the start of any run,
556
- // and readiness is now a projection of what is proven so a one-segment run
557
- // at the beginning would have declared the rest of the output unmade.
558
- const runsTo = endOfRun({ from, to });
559
- this.segmentStore?.forgetClosed(address, from, runsTo);
560
- this.coverageOf(address).claim(run, from, runsTo);
561
- run.start(because);
570
+ // Nothing has to be un-proved when a run takes a stretch that has already
571
+ // been written. A piece is only ever NAMED as served once it is closed, and
572
+ // a run writing that number again writes under a working name until it
573
+ // closes its own — so the file standing there is a complete piece made by
574
+ // somebody, and serving it is right until the newer one replaces it whole.
575
+ // The claim comes after the run exists, and that is not a race: everything
576
+ // between is synchronous, and a process cannot say a word before the next
577
+ // tick. What it removes is the second act building a run and starting it
578
+ // were two steps, and two owners each performed the second one.
579
+ this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
562
580
  }
563
581
 
564
582
  /**
@@ -654,15 +672,11 @@ export class EncodeOrchestrator {
654
672
  this.#costs.note(ended);
655
673
  // Exactly one ending is normal — the run reached the end of the stretch it
656
674
  // was given and closed its last file. Every other leaves a piece open, and
657
- // that file looks finished however the run ended: stopped, ffmpeg writes it
658
- // out and names it like any other; killed harder, it leaves the bytes it
659
- // had. Either way it decodes and holds less film than its number promises.
660
- // So what is kept is what the run PROVED it finished, and nothing beyond.
661
- if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
662
- void this.segmentStore
663
- .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
664
- .catch(() => {});
665
- }
675
+ // that piece is under this run's OWN working name, so clearing up after it
676
+ // is a name match: no stretch to search and no bytes to judge. Done for
677
+ // every ending, the normal one included, since a run that finished cleanly
678
+ // has nothing under a working name and the sweep then removes nothing.
679
+ this.segmentStore?.clearUpAfter(ended.address, ended.from);
666
680
  this.coverageOf(ended.address).release(ended.run);
667
681
  const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
668
682
  if (remaining.length === 0) {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @file How much of a machine's resource the stores may hold between them.
3
+ *
4
+ * Written once and used twice, because memory and disk are the same question
5
+ * asked of two resources: what is free now, plus what we already hold, less
6
+ * what everything that is not us has recently been seen to need.
7
+ *
8
+ * It used to exist only for memory, inside the memory store, keyed on module
9
+ * state. Disk had no such rule at all — the spill file grew until the machine
10
+ * did, 14.4 GB in a single viewing on 2026-08-31 — and copying the memory rule
11
+ * across would have made two rules to keep in step. One rule, two readings.
12
+ */
13
+
14
+ /**
15
+ * How many observations of other processes' demand are kept.
16
+ *
17
+ * A window rather than a high-water: a single spike would otherwise stand for
18
+ * the life of the process and hold the stores down long after whatever caused
19
+ * it had finished.
20
+ */
21
+ const OTHER_DEMAND_SAMPLES = 60;
22
+
23
+ /**
24
+ * What everything that is not us has recently been seen to need.
25
+ *
26
+ * One instance per resource. A fall in what is free that we did not cause is a
27
+ * measurement of somebody else's demand; a fall we did cause is our own doing
28
+ * and says nothing about the machine.
29
+ */
30
+ export class OtherDemand {
31
+ /** @type {number[]} */
32
+ #falls = [];
33
+
34
+ #lastFreeBytes = 0;
35
+
36
+ #lastHeldBytes = 0;
37
+
38
+ /**
39
+ * Take a reading, and answer what to reserve for others.
40
+ *
41
+ * @param {number} freeBytes - What the machine says is free now.
42
+ * @param {number} heldBytes - What the stores hold of this resource now.
43
+ * @returns {number} The reserve, in bytes.
44
+ */
45
+ note(freeBytes, heldBytes) {
46
+ if (this.#lastFreeBytes > 0) {
47
+ const fell = this.#lastFreeBytes - freeBytes;
48
+ const ours = heldBytes - this.#lastHeldBytes;
49
+ this.#falls.push(Math.max(0, fell - ours));
50
+ if (this.#falls.length > OTHER_DEMAND_SAMPLES) {
51
+ this.#falls.shift();
52
+ }
53
+ }
54
+ this.#lastFreeBytes = freeBytes;
55
+ this.#lastHeldBytes = heldBytes;
56
+ return this.reserve();
57
+ }
58
+
59
+ /** @returns {number} */
60
+ reserve() {
61
+ return this.#falls.length === 0 ? 0 : Math.max(...this.#falls);
62
+ }
63
+
64
+ /** Forget the readings. For tests, which share a module. */
65
+ forget() {
66
+ this.#falls = [];
67
+ this.#lastFreeBytes = 0;
68
+ this.#lastHeldBytes = 0;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * How much of the resource the stores may hold between them.
74
+ *
75
+ * What the machine reports free is what could be taken ON TOP of what is
76
+ * already held, so the stores' own bytes are added back: the pair is the
77
+ * ceiling the stores could reach.
78
+ *
79
+ * @param {number} freeBytes
80
+ * @param {number} heldBytes
81
+ * @param {number} reserveBytes
82
+ * @returns {number}
83
+ */
84
+ export function machineAllowanceBytes(freeBytes, heldBytes, reserveBytes) {
85
+ return Math.max(0, Math.max(freeBytes, 0) + Math.max(heldBytes, 0) - Math.max(reserveBytes, 0));
86
+ }
87
+
88
+ /**
89
+ * Divide what the machine allows between the stores, by what each is asking
90
+ * for.
91
+ *
92
+ * When everyone's ask fits, everyone gets it and the machine's limit never
93
+ * binds. When the asks do not fit, each store is cut in proportion to what it
94
+ * asked, so a store wanting little is not cut to make room for one wanting
95
+ * much.
96
+ *
97
+ * @param {number[]} wantedBytes - What each store is asking for, in order.
98
+ * @param {number} allowanceBytes
99
+ * @returns {number[]} What each store may hold, in the same order.
100
+ */
101
+ export function divideAllowance(wantedBytes, allowanceBytes) {
102
+ const total = wantedBytes.reduce((sum, want) => sum + Math.max(0, want), 0);
103
+ if (total <= allowanceBytes || total === 0) {
104
+ return wantedBytes.map((want) => Math.max(0, want));
105
+ }
106
+ return wantedBytes.map((want) => Math.floor(allowanceBytes * (Math.max(0, want) / total)));
107
+ }