@torrent-tv/proxy 2.89.3 → 2.89.4

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 (49) hide show
  1. package/AGENTS.md +338 -336
  2. package/CHANGELOG.md +12 -0
  3. package/docs/encode-architecture.md +55 -19
  4. package/docs/playback-start-forecast.md +13 -5
  5. package/docs/storage-architecture.md +6 -0
  6. package/package.json +1 -1
  7. package/routes/stream/get.js +38 -2
  8. package/server.js +748 -745
  9. package/services/encode/EncodeOrchestrator.js +24 -49
  10. package/services/encode/EncodePlan.js +5 -3
  11. package/services/encode/EncodeRun.js +80 -17
  12. package/services/encode/EncodeRuns.js +1339 -1239
  13. package/services/encode/Renditions.js +2 -1
  14. package/services/encode/RunClock.js +87 -0
  15. package/services/encode/RunProgress.js +1 -6
  16. package/services/encode/encoder-readings.js +26 -28
  17. package/services/encode/piece-completeness.js +46 -0
  18. package/services/encode/quality/EncodeCost.js +1043 -1167
  19. package/services/encode/quality/OutputQualityState.js +1 -5
  20. package/services/encode/quality/QualityController.js +213 -390
  21. package/services/encode/quality/link-budget.js +5 -17
  22. package/services/encode/run-inputs.js +13 -2
  23. package/services/server/SegmentServing.js +3 -1
  24. package/services/server/ViewerRequests.js +2 -7
  25. package/services/server/wire-outputs.js +12 -7
  26. package/services/storage/segment-store/SegmentStore.js +46 -29
  27. package/services/torrent/supply-margin.js +7 -5
  28. package/test/auto-quality-step.test.js +75 -29
  29. package/test/decode-cost.test.js +1 -1
  30. package/test/encode-cost.test.js +31 -39
  31. package/test/encode-orchestrator.test.js +15 -8
  32. package/test/encode-plan-viewers.test.js +34 -27
  33. package/test/encode-run.test.js +34 -6
  34. package/test/encoder-readings.test.js +48 -26
  35. package/test/helpers/encode-run.js +113 -9
  36. package/test/link-budget.test.js +4 -5
  37. package/test/manager-surface.test.js +1 -2
  38. package/test/orchestrator-wired.test.js +5 -4
  39. package/test/output-speed.test.js +0 -1
  40. package/test/piece-completeness.test.js +42 -0
  41. package/test/quality-offer.test.js +1 -3
  42. package/test/quality-variants.test.js +1 -1
  43. package/test/run-progress.test.js +2 -3
  44. package/test/same-height-switch.test.js +1 -2
  45. package/test/segment-store.test.js +15 -15
  46. package/test/stream-route.test.js +28 -0
  47. package/test/supply-margin.test.js +13 -0
  48. package/test/two-viewers-one-picture.test.js +1 -1
  49. package/test/viewer-outputs.test.js +1 -1
@@ -15,6 +15,79 @@
15
15
 
16
16
  import { EncodeRun } from "../../services/encode/EncodeRun.js";
17
17
 
18
+ /** The working time between two closed pieces of a test measurement. */
19
+ const MEASURED_STEP_MS = 1000;
20
+
21
+ /** Closed pieces in a test measurement: the last two give the reading. */
22
+ const MEASURED_STEPS = 3;
23
+
24
+ /**
25
+ * The clock each run built by `startRunOn` reads: standing while a measurement
26
+ * is said, the wall clock otherwise. @type {WeakMap<EncodeRun, { at: number | null }>}
27
+ */
28
+ const wallClocks = new WeakMap();
29
+
30
+ /**
31
+ * Make a run measure a speed the way it does in the product: progress reports
32
+ * and the names of closed pieces on the encoder's own channels, one second of
33
+ * the run's own work apart. A closed piece is published when the next name
34
+ * arrives, and each publication is one reading against the one before, so the
35
+ * last of three steps reads exactly `speedX` whatever the run measured before.
36
+ *
37
+ * @param {EncodeRun} run - Its process exposes `stdout` and `stdio[3]`.
38
+ * @param {number} speedX - Film seconds per second of the run's work.
39
+ * @param {{ at: number } | null} [clock] - What the run's `now` reads; advanced
40
+ * here. Absent for a run built by `startRunOn` without one: its clock is
41
+ * stood just behind the wall clock for the measurement and released after,
42
+ * so the reading is dated now.
43
+ * @param {number} [waitedMs] - How long the run's input waits for the swarm in
44
+ * each step, on top of its second of work.
45
+ * @returns {void}
46
+ */
47
+ export function measureSpeed(run, speedX, clock = null, waitedMs = 0) {
48
+ if (clock === null) {
49
+ const wall = wallClocks.get(run);
50
+ if (!wall) {
51
+ throw new TypeError("measureSpeed needs the clock the run reads");
52
+ }
53
+ // Never behind the run's start or its own last reading: a step back in
54
+ // time is no work.
55
+ wall.at = Math.max(
56
+ Date.now() - (MEASURED_STEP_MS + waitedMs) * MEASURED_STEPS,
57
+ run.startedAt,
58
+ run.speedReading?.at ?? Number.NEGATIVE_INFINITY
59
+ );
60
+ measureSpeed(run, speedX, wall, waitedMs);
61
+ wall.at = null;
62
+ return;
63
+ }
64
+ const { stdout, stdio } = run.process;
65
+ let produced = run.progress.processedSeconds - run.progress.startPositionSeconds;
66
+ for (let step = 0; step < MEASURED_STEPS; step += 1) {
67
+ if (waitedMs > 0) {
68
+ run.inputWaitBegins();
69
+ clock.at += waitedMs;
70
+ run.inputWaitEnds();
71
+ }
72
+ clock.at += MEASURED_STEP_MS;
73
+ produced += (speedX * MEASURED_STEP_MS) / 1000;
74
+ say(stdout, `out_time_ms=${Math.round(produced * 1_000_000)}\n`);
75
+ say(stdio[3], `measured-${step}\n`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {{ write?: (text: string) => void, emit?: (event: string, text: string) => void }} channel
81
+ * @param {string} text
82
+ */
83
+ function say(channel, text) {
84
+ if (typeof channel.write === "function") {
85
+ channel.write(text);
86
+ } else {
87
+ channel.emit("data", text);
88
+ }
89
+ }
90
+
18
91
  /**
19
92
  * A child process that records what was done to it.
20
93
  *
@@ -26,6 +99,8 @@ import { EncodeRun } from "../../services/encode/EncodeRun.js";
26
99
  export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
27
100
  /** @type {Map<string, (...args: unknown[]) => void>} */
28
101
  const listeners = new Map();
102
+ const stdout = channel();
103
+ const closedPieces = channel();
29
104
  return {
30
105
  pid,
31
106
  killed: false,
@@ -33,8 +108,10 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
33
108
  signalCode: null,
34
109
  /** Every signal it was sent, in order. */
35
110
  signals: [],
36
- stdout: { on() {} },
111
+ stdout,
37
112
  stderr: { on() {} },
113
+ // The descriptor the encoder names its closed pieces on (`-segment_list pipe:3`).
114
+ stdio: [null, stdout, null, closedPieces],
38
115
  on(event, handler) {
39
116
  const kept = listeners.get(event) ?? [];
40
117
  kept.push(handler);
@@ -71,6 +148,24 @@ export function fakeProcess({ pid = null, exitsWhenKilled = true } = {}) {
71
148
  };
72
149
  }
73
150
 
151
+ /**
152
+ * A readable side of a pipe that a test writes into.
153
+ *
154
+ * @returns {{ on: (event: string, handler: (chunk: string) => void) => void, write: (text: string) => void }}
155
+ */
156
+ function channel() {
157
+ /** @type {((chunk: string) => void)[]} */
158
+ const handlers = [];
159
+ return {
160
+ on(event, handler) {
161
+ if (event === "data") handlers.push(handler);
162
+ },
163
+ write(text) {
164
+ for (const handler of handlers) handler(text);
165
+ }
166
+ };
167
+ }
168
+
74
169
  /** A logger that says nothing, for tests that are not about the log. */
75
170
  export const silentLogger = { info() {}, warn() {}, error() {} };
76
171
 
@@ -100,14 +195,17 @@ export function startRunOn(session, options = {}) {
100
195
  producing = true,
101
196
  lastSegmentIndex = null,
102
197
  usesExplicitCuts = false,
103
- // WHAT FFMPEG HAS REPORTED IT IS RUNNING AT. It says so on its progress
104
- // channel from the first seconds, before any piece closes — so a run can be
105
- // starting and already have a speed, and every real one does. Left at zero,
106
- // the plan has no speed to compute an arrival from, every arrangement of
107
- // encoders is equally hopeless, they all tie, and the encoder is taken away
108
- // for changing nothing.
109
- speedX = 0
198
+ // THE SPEED IT HAS MEASURED: film made over its own working time between
199
+ // two closed pieces. Left at zero, the plan has no speed to compute an
200
+ // arrival from, every arrangement of encoders is equally hopeless, they all
201
+ // tie, and the encoder is taken away for changing nothing.
202
+ speedX = 0,
203
+ // What the run reads as now, for a test that drives time itself. Absent,
204
+ // the run reads the wall clock, which stands still only while a
205
+ // measurement is said.
206
+ clock: given = null
110
207
  } = options;
208
+ const clock = given ?? { at: null };
111
209
  const run = new EncodeRun({
112
210
  address: session.outputKey ?? session.id ?? "output",
113
211
  encoder: { name: "libx264", kind: "software" },
@@ -118,9 +216,15 @@ export function startRunOn(session, options = {}) {
118
216
  logger: silentLogger,
119
217
  lastSegmentIndex: () => lastSegmentIndex,
120
218
  usesExplicitCuts,
219
+ now: () => clock.at ?? Date.now(),
121
220
  because: "a test asked for it"
122
221
  });
123
- run.noteSpeed(speedX);
222
+ if (given === null) {
223
+ wallClocks.set(run, clock);
224
+ }
225
+ if (speedX > 0) {
226
+ measureSpeed(run, speedX, given);
227
+ }
124
228
  if (producing) {
125
229
  // What moves a run out of starting is its first segment, in the product as
126
230
  // here — so a run that is producing has made one, and its head stands one
@@ -9,7 +9,6 @@ import test from "node:test";
9
9
  import assert from "node:assert/strict";
10
10
  import {
11
11
  admissionRank,
12
- LINK_SAFETY,
13
12
  LINK_VERDICT,
14
13
  linkCouldCarry,
15
14
  loadOf,
@@ -67,11 +66,11 @@ test("a load is as trustworthy as its least trustworthy part", () => {
67
66
  assert.ok(Math.abs(loadOf(known(3), known(0.128)).totalMbps - 3.128) < 1e-9, "the sound is added to the picture");
68
67
  });
69
68
 
70
- test("the sound is counted before the link's usable share is applied", () => {
71
- // The picture alone fits the usable share exactly; with the soundtrack the
72
- // viewer hears, it does not.
69
+ test("the sound is counted with the picture against the whole measured link", () => {
70
+ // The picture alone fits the link exactly; with the soundtrack the viewer
71
+ // hears, it does not. No share of the link is held back.
73
72
  const link = 10;
74
- const picture = known(link * LINK_SAFETY);
73
+ const picture = known(link);
75
74
  assert.equal(linkCouldCarry(link, loadOf(picture, null)).verdict, LINK_VERDICT.FITS, "without sound it would pass");
76
75
  assert.equal(
77
76
  linkCouldCarry(link, loadOf(picture, known(AUDIO_TRANSCODE_KBPS / 1000))).verdict,
@@ -142,7 +142,7 @@ function fakeSession({ id = SESSION_ID } = {}) {
142
142
  useSyntheticPlaylist: true,
143
143
  playlistText: "#EXTM3U\n",
144
144
  segmentCount: 10,
145
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
145
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
146
146
  };
147
147
  }
148
148
 
@@ -182,7 +182,6 @@ test("the progress report keeps every figure it carries today", async () => {
182
182
  "percent",
183
183
  "remainingSeconds",
184
184
  "segmentDurationSec",
185
- "speed",
186
185
  "currentHeight",
187
186
  "offeredHeights",
188
187
  "requestedHeight",
@@ -18,7 +18,7 @@ import { wireOutputs } from "../services/server/wire-outputs.js";
18
18
  import { SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
19
19
  import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
20
20
  import { qualityStateOf } from "../services/encode/quality/OutputQualityState.js";
21
- import { startRunOn } from "./helpers/encode-run.js";
21
+ import { measureSpeed, startRunOn } from "./helpers/encode-run.js";
22
22
  import { outputSpec } from "./helpers/output-spec.js";
23
23
 
24
24
  const KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
@@ -65,9 +65,10 @@ function sessionOn({ manager, id, dirPath, encodeStartIndex = 0, runEndIndex = -
65
65
  const run = startRunOn(session, { from: encodeStartIndex, to: runEndIndex });
66
66
  manager.encodeOrchestrator.adopt(KEY, run);
67
67
  session.testRun = run;
68
- // A speed is a reading taken FROM a run, so it names the run it came from.
69
- qualityStateOf(session).recentSpeed = speed > 0 ? { speed, at: Date.now(), run } : null;
70
- run.noteSpeed(speed);
68
+ // A speed is the run's own reading, measured from its closed pieces.
69
+ if (speed > 0) {
70
+ measureSpeed(run, speed);
71
+ }
71
72
  }
72
73
  return session;
73
74
  }
@@ -25,7 +25,6 @@ function costOf(sessions, host = {}) {
25
25
  return new EncodeCost({
26
26
  runsFor: () => [],
27
27
  stateFor: () => "IDLE",
28
- progressFor: () => null,
29
28
  outputs,
30
29
  host: () => ({
31
30
  benchmark: host.benchmark ?? null,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @file Whether a closed piece holds the whole of its stretch of film.
3
+ *
4
+ * Decided by the encoding from the cut table and the piece's own media; the
5
+ * segment store only keeps what it is given.
6
+ */
7
+
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { cutOf, judgePiece } from "../services/encode/piece-completeness.js";
11
+ import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "../services/encode/output/index.js";
12
+
13
+ /** Media ending where stated, in the shape a format's reading has. */
14
+ const endingAt = (seconds) => ({ end: seconds });
15
+ const format = { producedThroughSeconds: (ranges) => ranges?.end ?? null };
16
+
17
+ /** Cuts every ten seconds, ten pieces. */
18
+ const timeline = { segmentCount: 10, publishedStartOf: (index) => index * 10 };
19
+
20
+ test("a piece must reach its next cut less the delta the muxer is given", () => {
21
+ assert.equal(cutOf(timeline, 3), 40 - SEGMENT_CUT_TIME_DELTA_SECONDS);
22
+ });
23
+
24
+ test("the final piece has no next cut", () => {
25
+ assert.equal(cutOf(timeline, 9), undefined);
26
+ assert.deepEqual(judgePiece(format, endingAt(95), cutOf(timeline, 9)), { whole: true, throughSeconds: 95 });
27
+ });
28
+
29
+ test("an audio piece ending within the delta of its cut is whole", () => {
30
+ // Field 2026-10-01: AAC frames end 4.7389 s into a piece cut at 4.755 s.
31
+ const cut = 4.755 - SEGMENT_CUT_TIME_DELTA_SECONDS;
32
+ assert.equal(judgePiece(format, endingAt(4.738913832199547), cut).whole, true);
33
+ });
34
+
35
+ test("a piece closed short of its cut is not whole", () => {
36
+ // A run killed mid-piece: 84 ms of film where ten seconds were cut.
37
+ assert.deepEqual(judgePiece(format, endingAt(456.084), 466), { whole: false, throughSeconds: 456.084 });
38
+ });
39
+
40
+ test("a piece with no playable media is not whole, whatever its cut", () => {
41
+ assert.deepEqual(judgePiece(format, null, undefined), { whole: false, throughSeconds: null });
42
+ });
@@ -58,10 +58,8 @@ function offerOver(
58
58
  runningEncoders: () => 0,
59
59
  encodersRunningNow: () => 0,
60
60
  torrentCostSecFor: () => 0,
61
- boundBy: async () => "cpu",
62
61
  runsFor: (session) => [...(session.runs ?? [])],
63
- stateFor: (session) => runStateOf(session.runs),
64
- progressFor: (session) => session.progress ?? null
62
+ stateFor: (session) => runStateOf(session.runs)
65
63
  });
66
64
  let computed = 0;
67
65
  const real = cost.sustainableHeights.bind(cost);
@@ -131,7 +131,7 @@ function fakeSession({
131
131
  useSyntheticPlaylist: true,
132
132
  playlistText: "#EXTM3U\n",
133
133
  segmentCount: 100,
134
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
134
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
135
135
  };
136
136
  }
137
137
 
@@ -16,7 +16,7 @@ test("a run rebases ffmpeg's relative clock onto its own source position", () =>
16
16
  let now = 10;
17
17
  const progress = new RunProgress({ startSeconds: 600, totalSeconds: 1000, now: () => now });
18
18
  now = 20;
19
- progress.note({ processedSeconds: 12.5, speed: "2.0x" });
19
+ progress.note({ processedSeconds: 12.5 });
20
20
 
21
21
  assert.deepEqual(progress.snapshot(), {
22
22
  processedSeconds: 612.5,
@@ -24,7 +24,6 @@ test("a run rebases ffmpeg's relative clock onto its own source position", () =>
24
24
  totalSeconds: 1000,
25
25
  percent: 3.125,
26
26
  remainingSeconds: 387.5,
27
- speed: "2.0x",
28
27
  updatedAt: 20
29
28
  });
30
29
  });
@@ -33,7 +32,7 @@ test("two runs of one output retain separate clocks", () => {
33
32
  const made = orchestrator();
34
33
  const first = { from: 0, to: 49, isAlive: true, progress: new RunProgress({ startSeconds: 0 }) };
35
34
  const second = { from: 50, to: 99, isAlive: true, progress: new RunProgress({ startSeconds: 200 }) };
36
- first.progress.note({ processedSeconds: 40, speed: "1.0x" });
35
+ first.progress.note({ processedSeconds: 40 });
37
36
  second.progress.note({ processedSeconds: 8, speed: "2.0x" });
38
37
  made.adopt("picture", first);
39
38
  made.adopt("picture", second);
@@ -65,7 +65,6 @@ import { SourceFile } from "../services/media/SourceFile.js";
65
65
  import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
66
66
  import { outputSpec } from "./helpers/output-spec.js";
67
67
  import { maxrateKbpsFor, nominalKbpsFor, softwareRateControlFor } from "../services/encode/args.js";
68
- import { LINK_SAFETY } from "../services/encode/quality/link-budget.js";
69
68
 
70
69
  const BASE_ID = "1111111122223333";
71
70
  const SEGMENT_SECONDS = 4;
@@ -86,7 +85,7 @@ const AUDIO_MBPS = 0.128;
86
85
  * @returns {number}
87
86
  */
88
87
  function linkJustFor(limit) {
89
- return (maxrateKbpsFor(limit) / 1000 + AUDIO_MBPS + 0.01) / LINK_SAFETY;
88
+ return maxrateKbpsFor(limit) / 1000 + AUDIO_MBPS + 0.01;
90
89
  }
91
90
 
92
91
  /**
@@ -10,7 +10,6 @@ import os from "node:os";
10
10
  import path from "node:path";
11
11
  import { directoryNameFor, SegmentStore } from "../services/storage/segment-store/SegmentStore.js";
12
12
  import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
13
- import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "../services/encode/output/index.js";
14
13
 
15
14
  /**
16
15
  * @returns {{ store: SegmentStore, root: string, lines: string[] }}
@@ -36,32 +35,33 @@ function writeSegment(dir, index, bytes = 16) {
36
35
 
37
36
  const KEY = "torrent:abc:fmt=fmp4:grid=kf@0:video-only:v=0/copy";
38
37
 
39
- test("a complete audio cut within the configured muxer delta remains usable", (t) => {
38
+ test("a published piece keeps the media intervals the encoding read from it", (t) => {
39
+ // The store does not judge a piece; whether it is whole is the encoding's
40
+ // decision (`encode/piece-completeness.js`), taken before it is published.
40
41
  const { store, root } = storeInATempRoot();
41
42
  t.after(() => { store.dropAll("the check is over"); rmSync(root, { recursive: true, force: true }); });
42
- const format = { ...fmp4Format, readMediaRanges: () => [{ start: 0, end: 4.738913832199547 }],
43
- producedThroughSeconds: (ranges) => ranges.at(-1).end };
43
+ const format = { ...fmp4Format, readMediaRanges: () => assert.fail("the store does not read a piece it was handed") };
44
44
  const dir = store.directoryFor(KEY);
45
45
  store.useFormat(KEY, format);
46
- const endSeconds = 4.755 - SEGMENT_CUT_TIME_DELTA_SECONDS;
47
- writeFileSync(path.join(dir, "making-0-00000.mp4"), Buffer.alloc(16));
48
- assert.equal(store.publish(KEY, "making-0-00000.mp4", format, { endSeconds }), "segment-00000.mp4");
49
- assert.ok(store.mediaRangesOf(KEY, 0, { endSeconds }));
46
+ writeFileSync(path.join(dir, "making-0-00000.mp4"), Buffer.from("closed"));
47
+ assert.deepEqual(store.closedBytesOf(KEY, "making-0-00000.mp4"), Buffer.from("closed"));
48
+ const mediaRanges = { tracks: [] };
49
+ assert.equal(store.publish(KEY, "making-0-00000.mp4", format, { mediaRanges }), "segment-00000.mp4");
50
+ assert.equal(store.mediaRangesOf(KEY, 0), mediaRanges);
50
51
  });
51
52
 
52
- test("a closed but truncated non-final piece is not published or reused", (t) => {
53
- const { store, root } = storeInATempRoot();
53
+ test("a stored piece is taken off the disk at its owner's word", (t) => {
54
+ const { store, root, lines } = storeInATempRoot();
54
55
  t.after(() => { store.dropAll("the check is over"); rmSync(root, { recursive: true, force: true }); });
55
- const format = { ...fmp4Format, readMediaRanges: () => [{ start: 456, end: 456.084 }],
56
- producedThroughSeconds: (ranges) => ranges.at(-1).end };
56
+ const format = { ...fmp4Format, readMediaRanges: () => ({ tracks: [] }) };
57
57
  const dir = store.directoryFor(KEY);
58
58
  store.useFormat(KEY, format);
59
- writeFileSync(path.join(dir, "making-0-00071.mp4"), Buffer.alloc(16));
60
- assert.equal(store.publish(KEY, "making-0-00071.mp4", format, { endSeconds: 466 }), null);
61
59
  writeSegment(dir, 71);
62
60
  assert.ok(store.mediaRangesOf(KEY, 71));
63
- assert.equal(store.mediaRangesOf(KEY, 71, { endSeconds: 466 }), undefined);
61
+ store.remove(KEY, 71, "short of its cut");
64
62
  assert.equal(store.pathOf(KEY, 71), null);
63
+ assert.equal(store.mediaRangesOf(KEY, 71), undefined);
64
+ assert.ok(lines.some((line) => line.includes("segment 71") && line.includes("short of its cut")));
65
65
  });
66
66
 
67
67
  test("two viewers of one output are given the same directory", (t) => {
@@ -207,3 +207,31 @@ test("a file this proxy does not have whole still goes to the torrent", async ()
207
207
  await handleStreamGet(req, reply, deps);
208
208
  assert.equal(state.prioritized.length, 1, "the ordinary path was not taken");
209
209
  });
210
+
211
+ test("every wait of an encoder's input read is marked for the run its URL names", async () => {
212
+ // The run's processing speed is read over its own working time; the time its
213
+ // input waits for the swarm is what this marks (RunClock).
214
+ const { req, reply, deps } = harness({ method: "GET", range: "bytes=0-5" });
215
+ const { files: [file] } = await deps.torrentPool.getTorrent();
216
+ const pieces = [Buffer.from("abc"), Buffer.from("def")];
217
+ file.createFragmentReader = () => ({
218
+ cancel() {},
219
+ async *[Symbol.asyncIterator]() {
220
+ for (const bytes of pieces) {
221
+ yield { bytes, release() {} };
222
+ }
223
+ }
224
+ });
225
+ reply.raw.write = (_bytes, done) => done();
226
+ reply.raw.writableEnded = false;
227
+ reply.raw.destroyed = false;
228
+ req.query.session = "output-1";
229
+ req.query.run = "7";
230
+ const marks = [];
231
+ deps.noteInputWaiting = (runToken, waiting) => marks.push([runToken, waiting]);
232
+
233
+ await handleStreamGet(req, reply, deps);
234
+
235
+ assert.deepEqual(marks, [[7, true], [7, false], [7, true], [7, false], [7, true], [7, false]],
236
+ "a wait before each fragment and one before the end, each closed");
237
+ });
@@ -175,3 +175,16 @@ test("a term with nothing observed contributes nothing", () => {
175
175
  assert.equal(minimumBufferFrom({}), null, "without a segment duration nothing can be said");
176
176
  assert.equal(minimumBufferFrom({ segmentSeconds: 0 }), null);
177
177
  });
178
+
179
+ test("a supply that delivered for a sliver of the time asks for what it measured, uncut", () => {
180
+ // Two stalls of 99.9 s with 0.1 s of delivery between them: the share lost is
181
+ // 0.999, and the speed asked for is a thousand times realtime. A cap at a
182
+ // chosen 0.99 used to report a hundred.
183
+ const answer = requiredSpeedFrom([
184
+ { waitedMs: 99_900, at: 99_900 },
185
+ { waitedMs: 99_900, at: 199_900 }
186
+ ]);
187
+ assert.ok(answer);
188
+ assert.ok(Math.abs(answer.lostShare - 0.999) < 1e-9, `got ${answer.lostShare}`);
189
+ assert.ok(Math.abs(answer.requiredSpeed - 1000) < 1e-6, `got ${answer.requiredSpeed}`);
190
+ });
@@ -132,7 +132,7 @@ function fakeSession({
132
132
  useSyntheticPlaylist: true,
133
133
  playlistText: "#EXTM3U\n",
134
134
  segmentCount: 100,
135
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
135
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
136
136
  };
137
137
  }
138
138
 
@@ -98,7 +98,7 @@ function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, i
98
98
  useSyntheticPlaylist: true,
99
99
  playlistText: "#EXTM3U\n",
100
100
  segmentCount: 100,
101
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
101
+ progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
102
102
  };
103
103
  }
104
104