@torrent-tv/proxy 2.83.4 → 2.83.5

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.
@@ -83,6 +83,17 @@ export class PieceDiskStore {
83
83
  /** Where the live readers stand, from whoever holds that fact. @type {() => number[]} */
84
84
  #readHeads;
85
85
 
86
+ /**
87
+ * Said whenever this tier stops holding a piece, whatever the reason.
88
+ *
89
+ * It states THIS tier's own fact and nothing more — not whether the bytes can
90
+ * still be had elsewhere, which this class has no way of knowing. Whoever
91
+ * listens decides what the loss means.
92
+ *
93
+ * @type {(index: number) => void}
94
+ */
95
+ #onForgotten;
96
+
86
97
  /**
87
98
  * @param {object} params
88
99
  * @param {string} params.directory - Where this torrent's pieces live.
@@ -95,12 +106,21 @@ export class PieceDiskStore {
95
106
  * means nobody has said yet, and nothing is evicted until somebody does.
96
107
  * @param {() => number} [params.now]
97
108
  */
98
- constructor({ directory, name, chunkLength, allowanceBytes = null, now = Date.now, readHeads = () => [] }) {
109
+ constructor({
110
+ directory,
111
+ name,
112
+ chunkLength,
113
+ allowanceBytes = null,
114
+ now = Date.now,
115
+ readHeads = () => [],
116
+ onForgotten = () => undefined
117
+ }) {
99
118
  this.#directory = path.join(directory, name);
100
119
  this.#chunkLength = chunkLength;
101
120
  this.#allowanceBytes = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
102
121
  this.#now = now;
103
122
  this.#readHeads = typeof readHeads === "function" ? readHeads : () => [];
123
+ this.#onForgotten = typeof onForgotten === "function" ? onForgotten : () => undefined;
104
124
  this.#adoptWhatIsAlreadyHere();
105
125
  }
106
126
 
@@ -345,6 +365,9 @@ export class PieceDiskStore {
345
365
  }
346
366
  });
347
367
  this.#removing.set(index, removal);
368
+ // SAID AFTER THE FACT IS TRUE, so a listener that asks this tier whether it
369
+ // holds the piece is told the same thing this method has just made so.
370
+ this.#onForgotten(index);
348
371
  }
349
372
 
350
373
  /**
@@ -321,6 +321,11 @@ export class SharedPieceStore {
321
321
  admittedWithoutSlot: 0,
322
322
  evictedOnRevise: 0,
323
323
  spillFailures: 0,
324
+ // Claims withdrawn: pieces this store stopped being able to produce at all,
325
+ // and said so. It is the figure that says whether the bargain the eviction
326
+ // states — "a seek back re-downloads it" — is being honoured, and before
327
+ // 2026-09-12 it was not honoured once, because nothing was ever said.
328
+ withdrawn: 0,
324
329
  // Whether the store is doing its job or being asked to hold more than it
325
330
  // has room for. An eviction that had to take a piece a reader declared it
326
331
  // wants is the second, and it comes back from disk moments later
@@ -413,6 +418,31 @@ export class SharedPieceStore {
413
418
  */
414
419
  #isElsewhere = null;
415
420
 
421
+ /**
422
+ * Said when this store can no longer produce a piece AT ALL — not resident,
423
+ * not on disk, and not inside a file held whole.
424
+ *
425
+ * **Why it has to exist.** This store holds the bytes, so it owns the fact
426
+ * "this proxy has piece N". Something else kept a second copy of that fact —
427
+ * the torrent's own completion bitfield — and nothing reconciled them: the
428
+ * disk tier drops a piece behind every read head, correctly, and the bitfield
429
+ * goes on saying the piece is verified. A read then concludes the piece is
430
+ * had, asks for it, is told it is absent, and fails; and it is never
431
+ * re-downloaded either, because the library does not fetch what it believes
432
+ * it already owns. Field 2026-09-12: a film played 80 seconds, the encoder
433
+ * ran on to 725 s, `forgetBehind` dropped some 565 spilled pieces including
434
+ * piece 0, the encoder restarted and re-opened its input at byte 0, and
435
+ * `/stream` answered `0 of 2363497962 bytes: Piece 0 is verified but absent
436
+ * from the store` for 92 minutes while the browser retried one segment.
437
+ *
438
+ * So the bitfield becomes a projection of this fact, maintained by this
439
+ * announcement. This store does not know what a torrent is, who listens, or
440
+ * what they do about it — the same shape as `readPieceElsewhere` above.
441
+ *
442
+ * @type {((what: object) => void) | null}
443
+ */
444
+ #onPieceGone = null;
445
+
416
446
  /** Whether the last revision had to exceed the machine's share. */
417
447
  #beyondTheMachine = false;
418
448
  /**
@@ -477,6 +507,9 @@ export class SharedPieceStore {
477
507
  this.#isElsewhere = typeof options.isPieceElsewhere === "function"
478
508
  ? options.isPieceElsewhere
479
509
  : null;
510
+ this.#onPieceGone = typeof options.onPieceGone === "function"
511
+ ? options.onPieceGone
512
+ : null;
480
513
  // Plain data the layer above needs to answer those two, and which this
481
514
  // store is handed anyway: where each file of the torrent begins and how
482
515
  // long the whole of it is.
@@ -496,7 +529,12 @@ export class SharedPieceStore {
496
529
  allowanceBytes: null,
497
530
  // Where the live readers stand, so what goes first is decided by them and
498
531
  // not by which piece happened to be touched longest ago.
499
- readHeads: () => this.#lru.readHeads()
532
+ readHeads: () => this.#lru.readHeads(),
533
+ // EVERY WAY A PIECE LEAVES THE DISK COMES THROUGH HERE — behind the read
534
+ // heads, over the allowance, dropped as a duplicate, or forgotten while a
535
+ // spill finished. One listener instead of a list of call sites to
536
+ // remember, which is what let the announcement be missed at three of them.
537
+ onForgotten: (index) => this.#announceGoneIfNowhere(index)
500
538
  });
501
539
  liveStores.add(this);
502
540
  }
@@ -1131,6 +1169,38 @@ export class SharedPieceStore {
1131
1169
  : null;
1132
1170
  }
1133
1171
 
1172
+ /**
1173
+ * Say a piece has gone, but only once it has gone from everywhere.
1174
+ *
1175
+ * The disk tier announces its own loss, which is not the same statement: a
1176
+ * piece dropped as a duplicate of a file held whole has not been lost at all,
1177
+ * and one still resident is about to be spilled again rather than gone. Only
1178
+ * the third case — neither tier, no whole file — is a withdrawal of the claim
1179
+ * that this proxy has those bytes.
1180
+ *
1181
+ * Nothing is said while the store is closing: the torrent it belongs to is
1182
+ * being torn down, and a claim withdrawn against a dying torrent reaches
1183
+ * either a destroyed object or, worse, one that has already been added back.
1184
+ *
1185
+ * @param {number} index
1186
+ * @returns {void}
1187
+ */
1188
+ #announceGoneIfNowhere(index) {
1189
+ if (this.#closed || this.#onPieceGone === null) {
1190
+ return;
1191
+ }
1192
+ if (this.#buffers.has(index) || this.#disk.has(index) || this.isInWholeFiles(index)) {
1193
+ return;
1194
+ }
1195
+ this.#counters.withdrawn += 1;
1196
+ try {
1197
+ this.#onPieceGone({ index, files: this.#files, name: this.#name });
1198
+ } catch {
1199
+ // silent-ok: whoever listens is diagnosing or bookkeeping, and a listener
1200
+ // that throws must not be what fails an eviction the store needs to make.
1201
+ }
1202
+ }
1203
+
1134
1204
  /**
1135
1205
  * Whether a spilled piece is now a duplicate of bytes held elsewhere.
1136
1206
  *
@@ -18,6 +18,7 @@ import { logger } from "../utils/logger.js";
18
18
  import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
19
19
  import { Urgency, urgencyName } from "./demand/index.js";
20
20
  import { demandFor, forgetTorrent, reconcileAll, hasUnmetDemand } from "./download/registry.js";
21
+ import { withdrawClaim } from "./download/withdraw-claim.js";
21
22
  import { isAtAWatchingViewer, isBehindEverybody, isNobodyComingNow } from "./priority/PriorityMap.js";
22
23
  import { deriveSourceKey } from "./torrent-source-key.js";
23
24
 
@@ -1098,6 +1099,79 @@ export class TorrentPool {
1098
1099
  this.#storeExtras = extras && typeof extras === "object" ? extras : {};
1099
1100
  }
1100
1101
 
1102
+ /**
1103
+ * Everything a store of this pool is built with, in ONE place.
1104
+ *
1105
+ * It was assembled at both `client.add` sites — the ordinary one and the one
1106
+ * that replaces a torrent the client no longer has — and a store built by the
1107
+ * second was missing whatever the first had gained. That is how a store came
1108
+ * to exist without the whole-file reader it needs, and it is why this is a
1109
+ * method rather than a literal.
1110
+ *
1111
+ * @returns {object}
1112
+ */
1113
+ #storeOptions() {
1114
+ return {
1115
+ memoryBytes: this.#memoryBytes,
1116
+ ...this.#storeExtras,
1117
+ // WIRED BY THE POOL ITSELF, not handed in: withdrawing the claim needs
1118
+ // only a torrent, and this class is the one that owns them. Last, so a
1119
+ // caller's extras cannot displace the one thing that keeps the library's
1120
+ // completion bitfield in step with the bytes.
1121
+ onPieceGone: (what) => this.withdrawPieceClaim(what)
1122
+ };
1123
+ }
1124
+
1125
+ /**
1126
+ * Withdraw the claim that this proxy has a piece, because the store that held
1127
+ * it no longer can produce it.
1128
+ *
1129
+ * **Whose job this is.** The store owns the bytes and therefore owns the fact;
1130
+ * the library keeps a second copy of that fact in its completion bitfield, and
1131
+ * this pool is the only thing here that owns a torrent, so reconciling the two
1132
+ * is this class's and nothing else's. The store announces and does not know
1133
+ * who listens.
1134
+ *
1135
+ * **What it costs.** The piece is re-created as incomplete, so the next read
1136
+ * that wants it waits for a download instead of failing. Field 2026-09-12,
1137
+ * which is what this is for: 565 spilled pieces were dropped behind the read
1138
+ * heads — correctly, they were behind every reader — the bitfield went on
1139
+ * saying they were verified, the encoder restarted and re-opened its input at
1140
+ * byte 0, and `Piece 0 is verified but absent from the store` then answered
1141
+ * every read for 92 minutes. Nothing re-downloaded it, because the library
1142
+ * does not fetch what it believes it owns.
1143
+ *
1144
+ * **What it deliberately does not do.** `_markUnverified` would also re-select
1145
+ * the piece, and does not here: every torrent is added with `deselect: true`,
1146
+ * which sets the library's own `_startAsDeselected` and makes it skip that
1147
+ * call. The download set has one owner — `SwarmSelection`, from the priority
1148
+ * map — and a piece withdrawn here is fetched again when a read states it,
1149
+ * which is the same statement every other piece waits on.
1150
+ *
1151
+ * @param {object} what
1152
+ * @param {number} what.index
1153
+ * @param {object[]} what.files - The store's own, from which the torrent that
1154
+ * owns them is found; the store has no idea what a torrent is.
1155
+ * @returns {void}
1156
+ */
1157
+ withdrawPieceClaim({ index, files }) {
1158
+ const outcome = withdrawClaim({
1159
+ index,
1160
+ files,
1161
+ warn: (line) => logger.warn(`torrent-pool: ${line}`)
1162
+ });
1163
+ if (outcome === "withdrawn") {
1164
+ this.#claimsWithdrawn += 1;
1165
+ }
1166
+ }
1167
+
1168
+ /** How many claims this pool has withdrawn, over its whole life. */
1169
+ #claimsWithdrawn = 0;
1170
+
1171
+ get claimsWithdrawn() {
1172
+ return this.#claimsWithdrawn;
1173
+ }
1174
+
1101
1175
  constructor({ maxDiskBytes, memoryBytes, dhtBootstrap } = {}) {
1102
1176
  this.#memoryBytes = Number.isFinite(memoryBytes) && memoryBytes > 0 ? memoryBytes : undefined;
1103
1177
 
@@ -1842,7 +1916,7 @@ export class TorrentPool {
1842
1916
  const addedReplacement = this.client.add(torrentId, {
1843
1917
  store: SharedPieceStore,
1844
1918
  storeCacheSlots: 0,
1845
- storeOpts: { memoryBytes: this.#memoryBytes, ...this.#storeExtras },
1919
+ storeOpts: this.#storeOptions(),
1846
1920
  deselect: true
1847
1921
  }, (replacement) => {
1848
1922
  this.torrents.set(key, replacement);
@@ -1875,7 +1949,7 @@ export class TorrentPool {
1875
1949
  const added = this.client.add(torrentId, {
1876
1950
  store: SharedPieceStore,
1877
1951
  storeCacheSlots: 0,
1878
- storeOpts: { memoryBytes: this.#memoryBytes, ...this.#storeExtras },
1952
+ storeOpts: this.#storeOptions(),
1879
1953
  // Nothing is fetched until somebody says they want it. WebTorrent's own
1880
1954
  // default is `this.select(0, this.pieces.length - 1)` — the whole
1881
1955
  // torrent — and this proxy used to undo that afterwards by deselecting
@@ -734,6 +734,10 @@ export async function* readFragments({
734
734
  }
735
735
  };
736
736
 
737
+ // How many times THIS piece has been asked for again after the store
738
+ // withdrew it. Reset once a piece is in hand, so the allowance is per piece.
739
+ let withdrawnRetries = 0;
740
+
737
741
  try {
738
742
  for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
739
743
  if (cancellation.isCancelled()) {
@@ -986,8 +990,33 @@ export async function* readFragments({
986
990
 
987
991
  if (!located) {
988
992
  store.unpin(pieceIndex);
989
- throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
993
+ // THE CLAIM HAS JUST BEEN WITHDRAWN, so this is a wait and not a
994
+ // failure. The store drops a piece behind every read head, and a read
995
+ // that re-opens an input at byte 0 — which is every encoder restart —
996
+ // asks for exactly those pieces. Until 2026-09-12 this threw, ffmpeg
997
+ // read the empty body as the end of the file, and the encoder died and
998
+ // was restarted into the same emptiness: field, `Piece 0 is verified
999
+ // but absent from the store` answered every read for 92 minutes while
1000
+ // the viewer looked at a still picture.
1001
+ //
1002
+ // Going back one step re-runs this piece's whole path rather than a
1003
+ // shortened copy of it: the bitfield now says the piece is missing, so
1004
+ // the block above declares it, steers it onto the fastest holders and
1005
+ // waits for it, exactly as it does for a piece that was never here.
1006
+ // Once per piece — a second emptiness means the bytes are not coming
1007
+ // and the caller must hear so.
1008
+ if (withdrawnRetries === 0) {
1009
+ withdrawnRetries += 1;
1010
+ pieceIndex -= 1;
1011
+ continue;
1012
+ }
1013
+ throw new Error(
1014
+ `Piece ${pieceIndex} was withdrawn from the store and did not come back.`
1015
+ );
990
1016
  }
1017
+ // Counted per piece: a long read may legitimately meet this more than
1018
+ // once, and each piece is entitled to its own second chance.
1019
+ withdrawnRetries = 0;
991
1020
 
992
1021
  let releasedThisPiece = false;
993
1022
  // Remembered so the generator can drop it itself. The pin is taken here
@@ -708,7 +708,7 @@ setInterval(() => {
708
708
  `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/` +
709
709
  `${stats.blockedByPins}/${stats.evictedOnRevise}/${stats.spillFailures}/` +
710
710
  `${stats.evictedProtected}/${stats.demand?.unionPieces ?? 0}/${stats.admittedToDisk}/` +
711
- `${stats.blocksAllocated}/${stats.blocksFree}/${stats.blocksReleased}`;
711
+ `${stats.blocksAllocated}/${stats.blocksFree}/${stats.blocksReleased}/${stats.withdrawn}`;
712
712
  if (lastReported.get(stats.name) === signature) {
713
713
  continue;
714
714
  }
@@ -729,6 +729,13 @@ setInterval(() => {
729
729
  (stats.admittedWithoutSlot > 0 ? ` to-disk-for-want-of-memory=${stats.admittedWithoutSlot}` : "") +
730
730
  (stats.stillMs > 1000 ? ` nothing-moved-for=${Math.round(stats.stillMs / 1000)}s` : "") +
731
731
  (stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "") +
732
+ // How many pieces this store stopped being able to produce and said so,
733
+ // which is what makes the eviction's own bargain checkable: every one of
734
+ // these is a piece the torrent has been told to fetch again if it is ever
735
+ // wanted. A session where this climbs while reads keep succeeding is the
736
+ // bargain working; before 2026-09-12 the figure did not exist and the
737
+ // claim was never withdrawn at all.
738
+ (stats.withdrawn > 0 ? ` withdrawn=${stats.withdrawn}` : "") +
732
739
  (stats.spillFailures > 0 ? ` spill-failures=${stats.spillFailures}` : "") +
733
740
  (stats.outstanding > 0 ? ` outstanding=${stats.outstanding}` : "")
734
741
  );
@@ -0,0 +1,261 @@
1
+ /**
2
+ * @file An output whose input is away gets no encoders until it may.
3
+ *
4
+ * A run whose input has gone is not alive, so the plan reads the stretch it held
5
+ * as free and places another run there at once — which dies the same way,
6
+ * because nothing about the state has changed. A delay existed for exactly this,
7
+ * doubling from 2 s to 15 s, and it was timed against the DEAD RUN, which the
8
+ * plan never consults. Field 2026-09-12: 2432 ffmpeg starts in 23 minutes, one
9
+ * every 0.57 s, for 61 minutes, against a delay that had reached its ceiling
10
+ * long before; the flood also turned the log over twice and destroyed the
11
+ * record of how the failure began.
12
+ *
13
+ * The delay lives beside the decision it governs now, and these check that it
14
+ * binds.
15
+ */
16
+
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { EventEmitter } from "node:events";
20
+ import { EncodeRun } from "../services/encode/EncodeRun.js";
21
+ import { ENCODE_EXIT } from "../services/encode/encode-exit.js";
22
+ import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
23
+ import { EncodeOrchestrator } from "../services/orchestrators/EncodeOrchestrator.js";
24
+
25
+ const PICTURE = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
26
+
27
+ class FakeProcess extends EventEmitter {
28
+ constructor() {
29
+ super();
30
+ this.pid = 1;
31
+ }
32
+
33
+ kill(signal) {
34
+ this.emit("exit", null, signal);
35
+ }
36
+ }
37
+
38
+ /** An orchestrator whose clock the test moves, and a count of what it started. */
39
+ function orchestrator() {
40
+ const lines = [];
41
+ let clock = 1_000;
42
+ let started = 0;
43
+ let asked = 0;
44
+ /** @type {EncodeOrchestrator} */
45
+ let made;
46
+ /** @type {{ run: EncodeRun, process: FakeProcess }[]} */
47
+ const runs = [];
48
+ made = new EncodeOrchestrator({
49
+ maxRunsFor: () => 1,
50
+ segmentSeconds: 4,
51
+ startingSpeedFor: () => 2,
52
+ refetchSecPerFilmSecond: () => 0.25,
53
+ now: () => clock,
54
+ planSoon: () => { asked += 1; },
55
+ logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
56
+ makeRun: ({ address, from, to }) => {
57
+ started += 1;
58
+ const process_ = new FakeProcess();
59
+ const run = new EncodeRun({
60
+ address,
61
+ encoder: new SoftwareEncoder(),
62
+ from,
63
+ to,
64
+ buildArgs: () => ["-i", "in", "out"],
65
+ spawn: () => process_,
66
+ logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
67
+ now: () => clock,
68
+ onEnded: (ended) => made.noteEnded(ended)
69
+ });
70
+ runs.push({ run, process: process_ });
71
+ return run;
72
+ }
73
+ });
74
+ made.setSegmentCount(PICTURE, 1000);
75
+ made.noteStartupCosts({ killCostSec: 0, firstByteWaitSec: 0.12 });
76
+
77
+ const wants = () => made.notePriorityMap(PICTURE, [
78
+ { from: 0, to: 20, priority: 1, withinSeconds: 0 }
79
+ ]);
80
+
81
+ return {
82
+ made,
83
+ lines,
84
+ wants,
85
+ runs,
86
+ startedCount: () => started,
87
+ askedToPlanAgain: () => asked,
88
+ advance: (ms) => { clock += ms; },
89
+ /** End the newest run the way a torrent going away ends one. */
90
+ loseTheInput: () => {
91
+ const newest = runs[runs.length - 1];
92
+ made.noteEnded({
93
+ address: PICTURE,
94
+ run: newest.run,
95
+ from: newest.run.from,
96
+ to: newest.run.to,
97
+ ending: ENCODE_EXIT.INPUT_LOST,
98
+ because: "the torrent went away"
99
+ });
100
+ }
101
+ };
102
+ }
103
+
104
+ test("nothing is placed on an output whose input has just gone", () => {
105
+ const stand = orchestrator();
106
+ stand.wants();
107
+ stand.made.reconcile();
108
+ assert.equal(stand.startedCount(), 1, "one encoder for the one thing wanted");
109
+
110
+ stand.loseTheInput();
111
+ // The plan is asked again by every event there is — a request, a report, a
112
+ // piece — and in the field that was about twice a second.
113
+ for (let attempt = 0; attempt < 20; attempt += 1) {
114
+ stand.made.reconcile();
115
+ }
116
+ assert.equal(
117
+ stand.startedCount(),
118
+ 1,
119
+ "twenty decisions while the input is away must not be twenty processes"
120
+ );
121
+ });
122
+
123
+ test("once the wait is over the plan places again", () => {
124
+ const stand = orchestrator();
125
+ stand.wants();
126
+ stand.made.reconcile();
127
+ stand.loseTheInput();
128
+ stand.made.reconcile();
129
+ assert.equal(stand.startedCount(), 1);
130
+
131
+ // The first wait is the base delay; anything past it lets the plan act.
132
+ stand.advance(2_001);
133
+ stand.made.reconcile();
134
+ assert.equal(stand.startedCount(), 2, "the data may be back, and that is worth one attempt");
135
+ });
136
+
137
+ test("the wait doubles while the input stays away, and is capped", () => {
138
+ const stand = orchestrator();
139
+ stand.wants();
140
+ stand.made.reconcile();
141
+
142
+ const waits = [];
143
+ for (let attempt = 0; attempt < 8; attempt += 1) {
144
+ stand.loseTheInput();
145
+ // Find the shortest advance that lets the plan act again, by stepping to
146
+ // just before and just after the boundary rather than reading a private.
147
+ let waited = 0;
148
+ const step = 250;
149
+ for (;;) {
150
+ const before = stand.startedCount();
151
+ stand.made.reconcile();
152
+ if (stand.startedCount() > before) {
153
+ break;
154
+ }
155
+ stand.advance(step);
156
+ waited += step;
157
+ assert.ok(waited < 60_000, "a wait must not be unbounded");
158
+ }
159
+ waits.push(waited);
160
+ }
161
+
162
+ for (let at = 1; at < waits.length; at += 1) {
163
+ assert.ok(
164
+ waits[at] >= waits[at - 1],
165
+ `the wait must not shrink while the input stays away: ${JSON.stringify(waits)}`
166
+ );
167
+ }
168
+ assert.ok(
169
+ waits[waits.length - 1] <= 15_000,
170
+ `and it must stop growing: ${JSON.stringify(waits)}`
171
+ );
172
+ assert.ok(
173
+ waits[waits.length - 1] > waits[0],
174
+ `and it must actually have grown: ${JSON.stringify(waits)}`
175
+ );
176
+ });
177
+
178
+ test("a wake-up is asked for, because no event arrives while the data is away", async () => {
179
+ const stand = orchestrator();
180
+ stand.wants();
181
+ stand.made.reconcile();
182
+ stand.loseTheInput();
183
+ // The wake-up runs on the real clock — the orchestrator's injected `now` is
184
+ // what the DECISION reads, and a timer is not a decision. So this waits for
185
+ // the condition, with a deadline only as a backstop; a fixed pause here would
186
+ // measure the machine.
187
+ const deadline = Date.now() + 10_000;
188
+ while (stand.askedToPlanAgain() === 0) {
189
+ if (Date.now() > deadline) {
190
+ assert.fail("nothing about the state changes while the input is missing, so the plan must be recalled");
191
+ }
192
+ await new Promise((resolve) => setTimeout(resolve, 25));
193
+ }
194
+ assert.ok(stand.askedToPlanAgain() >= 1);
195
+ });
196
+
197
+ test("an ending that is not about the input clears the wait", () => {
198
+ const stand = orchestrator();
199
+ stand.wants();
200
+ stand.made.reconcile();
201
+ stand.loseTheInput();
202
+ stand.made.reconcile();
203
+ assert.equal(stand.startedCount(), 1, "held back, as it should be");
204
+
205
+ stand.advance(2_001);
206
+ stand.made.reconcile();
207
+ assert.equal(stand.startedCount(), 2);
208
+
209
+ // This one PRODUCED and was then stopped: a segment came out of the input, so
210
+ // the input was plainly there and the next attempt starts from no delay.
211
+ const newest = stand.runs[stand.runs.length - 1];
212
+ stand.made.noteEnded({
213
+ address: PICTURE,
214
+ run: newest.run,
215
+ from: newest.run.from,
216
+ to: newest.run.to,
217
+ reached: newest.run.from,
218
+ firstOutputMs: 120,
219
+ ending: ENCODE_EXIT.STOPPED,
220
+ because: "we asked it to"
221
+ });
222
+ stand.made.reconcile();
223
+ assert.equal(stand.startedCount(), 3, "a file that has just produced is not suspect");
224
+ });
225
+
226
+ test("an ending that produced NOTHING does not lift the wait", () => {
227
+ const stand = orchestrator();
228
+ stand.wants();
229
+ stand.made.reconcile();
230
+ stand.loseTheInput();
231
+
232
+ // At the moment of failure several runs end at once. One of them ending
233
+ // without having made anything proves nothing about the input, and lifting
234
+ // the wait on it is the storm again with an extra step.
235
+ const newest = stand.runs[stand.runs.length - 1];
236
+ stand.made.noteEnded({
237
+ address: PICTURE,
238
+ run: newest.run,
239
+ from: newest.run.from,
240
+ to: newest.run.to,
241
+ reached: newest.run.from - 1,
242
+ firstOutputMs: null,
243
+ ending: ENCODE_EXIT.GONE,
244
+ because: "it is no longer running, and it did not say so"
245
+ });
246
+ for (let attempt = 0; attempt < 10; attempt += 1) {
247
+ stand.made.reconcile();
248
+ }
249
+ assert.equal(stand.startedCount(), 1, "an ending with nothing produced is no evidence");
250
+ });
251
+
252
+ test("the wait is said out loud, with the attempt and how long", () => {
253
+ const stand = orchestrator();
254
+ stand.wants();
255
+ stand.made.reconcile();
256
+ stand.loseTheInput();
257
+ assert.ok(
258
+ stand.lines.some((line) => /its input was not there \(attempt 1\)/.test(line)),
259
+ `the reason nothing is being placed must be readable: ${JSON.stringify(stand.lines.slice(-4))}`
260
+ );
261
+ });