@torrent-tv/proxy 2.33.0 → 2.35.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.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/server.js +8 -1
- package/services/contention.js +128 -0
- package/services/hls-session-manager.js +57 -1
- package/services/hwaccel.js +83 -0
- package/services/torrent-worker/piece-reader.js +33 -0
- package/services/torrent-worker/worker.js +13 -2
- package/test/contention.test.js +76 -0
- package/services/torrent-worker/supply-interruptions.js +0 -155
- package/test/supply-interruptions.test.js +0 -125
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.35.0
|
|
2
|
+
|
|
3
|
+
- **New**: A second job's cost is measured on the host instead of being added as though jobs were independent. Measured on the addon host 2026-08-18, decoding the same clip: **2.10-2.25x alone, 0.79-0.90x with one encoder beside it, 0.56-0.64x with two** — the same work costs 2.6× more for having company, and 3.7× for having two. Heat is not the cause: the hot idle machine (68 °C, a lower reported clock) was the fastest reading of all, which settles what roadmap item 6 was opened for. Four cores sharing one path to memory is the cause, and it contradicts the SHAPE of the budget rather than its constants — everything in the quality offer adds seconds of work per second of content, and these readings say two jobs that each fit alone do not fit together. So the penalty is now measured at startup the way everything else is (the cheapest clip decoded alone, then again while an encoder of it runs), and the offer multiplies a step's cost by it when anything else is encoding. Beyond the readings it holds the largest rather than extrapolating: two points say nothing about a fourth job, and a budget that guesses at a memory bottleneck will be wrong in whichever direction it guesses. With nothing measured, nothing is corrected. This is separate from the availability share of 2.33.0, which removes work nobody has been charged for; this is our own work colliding with itself.
|
|
4
|
+
|
|
5
|
+
## 2.34.0
|
|
6
|
+
|
|
7
|
+
- **New**: The proxy tells the browser the smallest buffer at which no interruption reaches the viewer, measured on the file being watched. It is one whole segment — the one being played — plus the worst wait its own reader met before the buffer could refill, from that file's recent interruptions on that swarm. On the field torrent of 2026-08-17 that is 7-9 s, where the browser has been waiting for a hand-chosen 25: sixteen seconds of spinner that nothing had shown to be necessary. Null until the reader has seen two interruptions — one wait shows no interval, and an interval invented from one point is what this work exists to remove — and the browser keeps its own figure until then. The reader measures it, the session manager states it with its own segment length, and the progress reply carries it.
|
|
8
|
+
- **Chore**: Removed `services/torrent-worker/supply-interruptions.js`, a second copy of the same arithmetic that was wired to nothing.
|
|
9
|
+
|
|
1
10
|
## 2.33.0
|
|
2
11
|
|
|
3
12
|
- **New**: A quality step is judged on the machine it will actually run on. The encoder benchmark measures a QUIET host — one ffmpeg and nothing else — while the addon host was measured 99 % busy, and a step predicted at 1.83x ran at 1.01-1.12x (2026-08-17). The offer now multiplies each prediction by the share of the machine that is free, taken from the same `host-load` reading that is already printed every five seconds. What is subtracted is ONLY the work nobody has been charged for — the kernel, the container, whatever else the owner runs — because our own encoders are already priced by the concurrency arithmetic and the proxy's own work per megabyte moved. Charging those here as well is what shipped in 2.21.0 and emptied the quality menu down to a single copied height. On the field reading the correction is about 0.77, and the "not offering" line now says what the machine had to spare when it decided.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -37,7 +37,7 @@ import { createSourceRegistry } from "./store/source-registry.js";
|
|
|
37
37
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
38
38
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
39
39
|
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
40
|
-
import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, detectTonemapSupport } from "./services/hwaccel.js";
|
|
40
|
+
import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, benchmarkContention, detectTonemapSupport } from "./services/hwaccel.js";
|
|
41
41
|
import { logger } from "./utils/logger.js";
|
|
42
42
|
|
|
43
43
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -130,6 +130,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
130
130
|
const decodeCostModel = videoEncoder?.kind === "software"
|
|
131
131
|
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
132
132
|
: null;
|
|
133
|
+
// What a second job costs on this host. Measured because the budget adds
|
|
134
|
+
// independent prices and this host says two jobs that each fit alone do not
|
|
135
|
+
// fit together — 2.6× on the addon box (2026-08-18).
|
|
136
|
+
const contentionPenalties = videoEncoder?.kind === "software"
|
|
137
|
+
? await benchmarkContention({ ffmpegBin, logger })
|
|
138
|
+
: null;
|
|
133
139
|
const softwarePresetBenchmark = videoEncoder?.kind === "software"
|
|
134
140
|
? await benchmarkSoftwarePresets({ ffmpegBin, logger })
|
|
135
141
|
: null;
|
|
@@ -147,6 +153,7 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
147
153
|
videoEncoder,
|
|
148
154
|
softwarePresetBenchmark,
|
|
149
155
|
decodeCostModel,
|
|
156
|
+
contentionPenalties,
|
|
150
157
|
tonemapSupported,
|
|
151
158
|
segmentFormatId: segmentFormat,
|
|
152
159
|
stateDir,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a second job costs on this machine — measured, because it is not
|
|
3
|
+
* the sum of two prices.
|
|
4
|
+
*
|
|
5
|
+
* Measured on the addon host 2026-08-18, decoding the same clip:
|
|
6
|
+
*
|
|
7
|
+
* | what else was running | decode speed | cost |
|
|
8
|
+
* |---|---|---|
|
|
9
|
+
* | nothing (cold) | 2.10-2.25x | 0.46 s/s |
|
|
10
|
+
* | nothing (hot, 68 °C) | 2.30-2.33x | 0.43 s/s |
|
|
11
|
+
* | one encoder | 0.79-0.90x | 1.18 s/s |
|
|
12
|
+
* | two encoders | 0.56-0.64x | 1.67 s/s |
|
|
13
|
+
*
|
|
14
|
+
* The same work costs **2.6× more** with one encoder beside it and 3.7× with
|
|
15
|
+
* two. Heat is not the cause — the hot idle machine was the fastest of all.
|
|
16
|
+
* Four cores share one narrow path to memory, and decoding and encoding both
|
|
17
|
+
* saturate it.
|
|
18
|
+
*
|
|
19
|
+
* That contradicts the shape of the budget, not just its constants. Everything
|
|
20
|
+
* in the quality offer adds seconds of work per second of content — this
|
|
21
|
+
* encode, plus that decode, plus what is already committed — and these readings
|
|
22
|
+
* say two jobs that each fit alone do not fit together. The availability
|
|
23
|
+
* correction in `available-share.js` does not cover it either: that subtracts
|
|
24
|
+
* the share of the machine taken by work nobody has been charged for, while
|
|
25
|
+
* this is our own work colliding with itself.
|
|
26
|
+
*
|
|
27
|
+
* So the penalty is measured rather than derived, at startup, on the host it
|
|
28
|
+
* describes — and beyond the range that was measured it does not extrapolate:
|
|
29
|
+
* it holds the largest reading and says so, because nothing here knows what a
|
|
30
|
+
* fourth job would do.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Penalties measured on this host, keyed by how many OTHER jobs were running.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {Map<number, number>} ContentionPenalties
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The multiplier for a job's cost when `othersRunning` other jobs share the
|
|
41
|
+
* machine.
|
|
42
|
+
*
|
|
43
|
+
* @param {number} othersRunning
|
|
44
|
+
* @param {ContentionPenalties | null} measured
|
|
45
|
+
* @returns {{ penalty: number, measured: boolean, from: number }}
|
|
46
|
+
* `from` is the reading it came from — equal to `othersRunning` when one was
|
|
47
|
+
* measured for exactly that many, and the largest available otherwise.
|
|
48
|
+
*/
|
|
49
|
+
export function contentionPenalty(othersRunning, measured) {
|
|
50
|
+
const others = Number.isFinite(othersRunning) ? Math.max(0, Math.round(othersRunning)) : 0;
|
|
51
|
+
if (others === 0) {
|
|
52
|
+
// Alone on the machine is what every benchmark measures, so there is
|
|
53
|
+
// nothing to correct.
|
|
54
|
+
return { penalty: 1, measured: true, from: 0 };
|
|
55
|
+
}
|
|
56
|
+
if (!(measured instanceof Map) || measured.size === 0) {
|
|
57
|
+
// Nothing measured: the honest multiplier is 1, and the caller's own log
|
|
58
|
+
// says the prediction is uncorrected. Inventing a penalty would be the
|
|
59
|
+
// same mistake as inventing a fill rate.
|
|
60
|
+
return { penalty: 1, measured: false, from: 0 };
|
|
61
|
+
}
|
|
62
|
+
const exact = measured.get(others);
|
|
63
|
+
if (Number.isFinite(exact) && exact > 0) {
|
|
64
|
+
return { penalty: exact, measured: true, from: others };
|
|
65
|
+
}
|
|
66
|
+
// Beyond what was measured, hold the largest reading rather than continue the
|
|
67
|
+
// curve. Two readings say nothing about the shape past them, and a budget
|
|
68
|
+
// that extrapolates a memory bottleneck will be wrong in whichever direction
|
|
69
|
+
// it guesses.
|
|
70
|
+
let largestKey = 0;
|
|
71
|
+
let largestValue = 1;
|
|
72
|
+
for (const [key, value] of measured) {
|
|
73
|
+
if (key <= others && key > largestKey && Number.isFinite(value) && value > 0) {
|
|
74
|
+
largestKey = key;
|
|
75
|
+
largestValue = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (largestKey === 0) {
|
|
79
|
+
return { penalty: 1, measured: false, from: 0 };
|
|
80
|
+
}
|
|
81
|
+
return { penalty: largestValue, measured: true, from: largestKey };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A cost, corrected for what else will be running.
|
|
86
|
+
*
|
|
87
|
+
* @param {number} costSecondsPerSecond - As the benchmarks priced it, alone.
|
|
88
|
+
* @param {number} othersRunning
|
|
89
|
+
* @param {ContentionPenalties | null} measured
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
export function costWithContention(costSecondsPerSecond, othersRunning, measured) {
|
|
93
|
+
if (!Number.isFinite(costSecondsPerSecond) || costSecondsPerSecond <= 0) {
|
|
94
|
+
return costSecondsPerSecond;
|
|
95
|
+
}
|
|
96
|
+
return costSecondsPerSecond * contentionPenalty(othersRunning, measured).penalty;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Turn measured decode speeds into penalties.
|
|
101
|
+
*
|
|
102
|
+
* @param {number} aloneSpeed - Decode speed with the machine to itself.
|
|
103
|
+
* @param {Array<{ others: number, speed: number }>} beside - One reading per
|
|
104
|
+
* number of other jobs running.
|
|
105
|
+
* @returns {ContentionPenalties | null} Null when the alone reading is missing,
|
|
106
|
+
* since every penalty is relative to it.
|
|
107
|
+
*/
|
|
108
|
+
export function penaltiesFrom(aloneSpeed, beside) {
|
|
109
|
+
if (!Number.isFinite(aloneSpeed) || aloneSpeed <= 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const penalties = new Map();
|
|
113
|
+
for (const reading of Array.isArray(beside) ? beside : []) {
|
|
114
|
+
const others = Number.isFinite(reading?.others) ? Math.round(reading.others) : null;
|
|
115
|
+
const speed = Number(reading?.speed);
|
|
116
|
+
if (others === null || others <= 0 || !Number.isFinite(speed) || speed <= 0) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// Cost is the reciprocal of speed, so the penalty is the reciprocal ratio.
|
|
120
|
+
// A job that runs at half the speed costs twice as much.
|
|
121
|
+
const penalty = aloneSpeed / speed;
|
|
122
|
+
// Below 1 would mean the machine got FASTER for being busier. It happens in
|
|
123
|
+
// the noise on a fast host, and it is not a discovery — it is a reading
|
|
124
|
+
// that says there is no penalty to measure here.
|
|
125
|
+
penalties.set(others, penalty > 1 ? penalty : 1);
|
|
126
|
+
}
|
|
127
|
+
return penalties.size > 0 ? penalties : null;
|
|
128
|
+
}
|
|
@@ -21,6 +21,8 @@ import { readKeyframeIndex } from "./container-index/index.js";
|
|
|
21
21
|
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
22
|
import { speedFromReadings } from "./encoder-readings.js";
|
|
23
23
|
import { availableShareFrom, correctForAvailability } from "./available-share.js";
|
|
24
|
+
import { contentionPenalty } from "./contention.js";
|
|
25
|
+
import { minimumBufferFrom } from "./supply-margin.js";
|
|
24
26
|
import {
|
|
25
27
|
ENCODE_RUN_EVENT,
|
|
26
28
|
ENCODE_RUN_STATE,
|
|
@@ -1371,6 +1373,10 @@ export class HlsSessionManager {
|
|
|
1371
1373
|
softwarePresetBenchmark = null,
|
|
1372
1374
|
decodeCostModel = null,
|
|
1373
1375
|
getSourceStats = null,
|
|
1376
|
+
// What a second job costs on this host, measured at startup. Null when it
|
|
1377
|
+
// could not be measured, and then nothing is corrected — the alternative
|
|
1378
|
+
// is inventing a penalty, which is the same fault as inventing a fill rate.
|
|
1379
|
+
contentionPenalties = null,
|
|
1374
1380
|
tonemapSupported = false,
|
|
1375
1381
|
getCachedMediaInfo = null,
|
|
1376
1382
|
getCachedAudioTracks = null,
|
|
@@ -1397,6 +1403,7 @@ export class HlsSessionManager {
|
|
|
1397
1403
|
// realtime budget to tell a CPU limit from a download-starved input:
|
|
1398
1404
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
1399
1405
|
this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
|
|
1406
|
+
this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
1400
1407
|
// Totals across every torrent this proxy holds, used to price what the
|
|
1401
1408
|
// torrent itself costs the machine (item 7). Optional: a proxy wired
|
|
1402
1409
|
// without it simply never learns that figure.
|
|
@@ -2915,6 +2922,26 @@ export class HlsSessionManager {
|
|
|
2915
2922
|
* @param {string} event - One of {@link ENCODE_RUN_EVENT}.
|
|
2916
2923
|
* @returns {string} The state now in force.
|
|
2917
2924
|
*/
|
|
2925
|
+
/**
|
|
2926
|
+
* How many of this proxy's encoders are running right now.
|
|
2927
|
+
*
|
|
2928
|
+
* Suspended runs are not counted: a process stopped by the look-ahead cap
|
|
2929
|
+
* competes for nothing, and counting it would price a machine as busier than
|
|
2930
|
+
* it is — the same distinction the host-load line had to learn (2026-08-15,
|
|
2931
|
+
* `ffmpeg=0% system=24%` with both encoders parked).
|
|
2932
|
+
*
|
|
2933
|
+
* @returns {number}
|
|
2934
|
+
*/
|
|
2935
|
+
#encodersRunningNow() {
|
|
2936
|
+
let running = 0;
|
|
2937
|
+
for (const session of this.sessionsById.values()) {
|
|
2938
|
+
if (session.runState === ENCODE_RUN_STATE.STARTING || session.runState === ENCODE_RUN_STATE.PRODUCING) {
|
|
2939
|
+
running += 1;
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
return running;
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2918
2945
|
#transitionRun(session, event) {
|
|
2919
2946
|
const from = session.runState ?? INITIAL_RUN_STATE;
|
|
2920
2947
|
const to = nextState(from, event);
|
|
@@ -3368,6 +3395,12 @@ export class HlsSessionManager {
|
|
|
3368
3395
|
if (!stats) {
|
|
3369
3396
|
return "unknown";
|
|
3370
3397
|
}
|
|
3398
|
+
// What this file's own interruptions demand, measured by the reader. Kept
|
|
3399
|
+
// on the session because the browser is told the buffer that follows from
|
|
3400
|
+
// it, and because the quality offer will be held to the speed it names.
|
|
3401
|
+
if (stats.supply) {
|
|
3402
|
+
session.supplyFigures = stats.supply;
|
|
3403
|
+
}
|
|
3371
3404
|
// A fully (or almost fully) downloaded file cannot be download-bound.
|
|
3372
3405
|
if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
|
|
3373
3406
|
return "cpu";
|
|
@@ -6067,7 +6100,19 @@ export class HlsSessionManager {
|
|
|
6067
6100
|
// quarter of it unattributed. Only the unattributed part is charged
|
|
6068
6101
|
// here: our own encoders are already in `concurrentBesideThis` and the
|
|
6069
6102
|
// proxy's own work is already priced per megabyte moved.
|
|
6070
|
-
|
|
6103
|
+
// Two corrections, and they are different facts about the machine. The
|
|
6104
|
+
// availability share removes work nobody has been charged for; the
|
|
6105
|
+
// contention penalty says what OUR OWN second job costs, because the
|
|
6106
|
+
// budget adds independent prices and this host does not behave that way
|
|
6107
|
+
// — the same work measured 2.6× dearer beside one encoder and 3.7×
|
|
6108
|
+
// beside two (2026-08-18). `concurrentBesideThis` already counts what is
|
|
6109
|
+
// committed; this multiplies by how badly running at all together goes.
|
|
6110
|
+
const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
|
|
6111
|
+
const { penalty } = contentionPenalty(othersRunning, this.contentionPenalties);
|
|
6112
|
+
const onThisMachine = correctForAvailability(
|
|
6113
|
+
speed === null ? null : speed / penalty,
|
|
6114
|
+
this.hostAvailability
|
|
6115
|
+
);
|
|
6071
6116
|
// Kept against the step's own session, so that when it runs the field
|
|
6072
6117
|
// says what the prediction was worth. Without this the only comparison
|
|
6073
6118
|
// available is between two figures written minutes apart in different
|
|
@@ -7589,6 +7634,17 @@ export class HlsSessionManager {
|
|
|
7589
7634
|
// the browser tracks its sessions by the id it was given.
|
|
7590
7635
|
sessionId: named.id,
|
|
7591
7636
|
state: wireState(session.runState),
|
|
7637
|
+
// The smallest buffer at which no interruption reaches the viewer, from
|
|
7638
|
+
// THIS file's own recent interruptions: one whole segment — the one being
|
|
7639
|
+
// played — plus the worst wait that can arrive before the buffer refills.
|
|
7640
|
+
// On the field torrent that is 7-9 s where the browser waits for a
|
|
7641
|
+
// hand-chosen 25, which is sixteen seconds of staring at a spinner that
|
|
7642
|
+
// nothing had shown to be necessary. Null until the reader has seen two
|
|
7643
|
+
// interruptions; the browser keeps its own figure until then.
|
|
7644
|
+
minimumBufferSeconds: minimumBufferFrom({
|
|
7645
|
+
segmentSeconds: this.segmentDurationSec,
|
|
7646
|
+
worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
|
|
7647
|
+
})?.seconds ?? null,
|
|
7592
7648
|
processedSeconds: session.progress.processedSeconds,
|
|
7593
7649
|
startPositionSeconds: session.progress.startPositionSeconds ?? 0,
|
|
7594
7650
|
totalSeconds: session.progress.totalSeconds,
|
package/services/hwaccel.js
CHANGED
|
@@ -25,6 +25,7 @@ import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { fitDecodeCost } from "./decode-cost-fit.js";
|
|
28
|
+
import { penaltiesFrom } from "./contention.js";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
import {
|
|
30
31
|
parseFfmpegBitrateKbps,
|
|
@@ -751,6 +752,88 @@ function parseClipCharacteristics(stderr) {
|
|
|
751
752
|
* @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
|
|
752
753
|
* @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
|
|
753
754
|
*/
|
|
755
|
+
/**
|
|
756
|
+
* What a second job costs on this host, measured rather than assumed.
|
|
757
|
+
*
|
|
758
|
+
* The budget adds seconds of work per second of content — this encode, plus
|
|
759
|
+
* that decode, plus what is already committed — and the addon host contradicted
|
|
760
|
+
* that directly on 2026-08-18: decoding ran at 2.10-2.25x alone, 0.79-0.90x
|
|
761
|
+
* with one encoder beside it and 0.56-0.64x with two. The same work costs 2.6×
|
|
762
|
+
* more for having company. Heat is not the cause (the hot idle machine was the
|
|
763
|
+
* fastest reading of all); four cores sharing one path to memory is.
|
|
764
|
+
*
|
|
765
|
+
* So it is measured the way everything else here is: the same clip decoded
|
|
766
|
+
* alone, then decoded again while an encoder of the same clip runs beside it.
|
|
767
|
+
* The ratio is the penalty. The encoder is stopped as soon as the reading is
|
|
768
|
+
* taken, and the whole thing costs one decode plus one short encode.
|
|
769
|
+
*
|
|
770
|
+
* @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string, upTo?: number }} options
|
|
771
|
+
* @returns {Promise<Map<number, number> | null>} Penalties by how many other
|
|
772
|
+
* jobs were running, or null when the readings could not be taken.
|
|
773
|
+
*/
|
|
774
|
+
export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, upTo = 2 }) {
|
|
775
|
+
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
776
|
+
// The cheapest clip in the set: this measures the MACHINE's behaviour under
|
|
777
|
+
// company, not the clip's own cost, so the smallest one says it soonest.
|
|
778
|
+
const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
|
|
779
|
+
const startedAt = Date.now();
|
|
780
|
+
const alone = await measureDecodeSlope(ffmpegBin, clip);
|
|
781
|
+
if (!alone) {
|
|
782
|
+
log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
/** @type {Array<{ others: number, speed: number }>} */
|
|
786
|
+
const beside = [];
|
|
787
|
+
/** @type {import("node:child_process").ChildProcess[]} */
|
|
788
|
+
const load = [];
|
|
789
|
+
try {
|
|
790
|
+
for (let others = 1; others <= Math.max(1, upTo); others += 1) {
|
|
791
|
+
load.push(
|
|
792
|
+
spawn(
|
|
793
|
+
ffmpegBin,
|
|
794
|
+
[
|
|
795
|
+
"-hide_banner", "-loglevel", "error", "-nostats",
|
|
796
|
+
"-stream_loop", "-1", "-i", clip,
|
|
797
|
+
"-an", "-c:v", "libx264", "-preset", "fast", "-f", "null", "-"
|
|
798
|
+
],
|
|
799
|
+
{ stdio: ["ignore", "ignore", "ignore"], windowsHide: true }
|
|
800
|
+
)
|
|
801
|
+
);
|
|
802
|
+
// Let the encoder reach its own speed before reading anything: an encode
|
|
803
|
+
// measured in its first moments is measuring the process starting.
|
|
804
|
+
await new Promise((resolve) => {
|
|
805
|
+
setTimeout(resolve, 2_000);
|
|
806
|
+
});
|
|
807
|
+
const withCompany = await measureDecodeSlope(ffmpegBin, clip);
|
|
808
|
+
if (withCompany) {
|
|
809
|
+
beside.push({ others, speed: withCompany.speed });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
} finally {
|
|
813
|
+
for (const child of load) {
|
|
814
|
+
try {
|
|
815
|
+
child.kill("SIGKILL");
|
|
816
|
+
} catch {
|
|
817
|
+
// Already gone: the reading is what mattered, and nothing else uses it.
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const penalties = penaltiesFrom(alone.speed, beside);
|
|
822
|
+
if (!penalties) {
|
|
823
|
+
log.warn("hwaccel: contention readings said nothing; costs will be added as though jobs were independent");
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
log.info(
|
|
827
|
+
`hwaccel: a second job costs ${[...penalties.entries()]
|
|
828
|
+
.map(([others, penalty]) => `${penalty.toFixed(2)}x beside ${others}`)
|
|
829
|
+
.join(", ")} ` +
|
|
830
|
+
`(decode alone ${alone.speed.toFixed(2)}x, ` +
|
|
831
|
+
`${beside.map((reading) => `${reading.speed.toFixed(2)}x beside ${reading.others}`).join(", ")}, ` +
|
|
832
|
+
`measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
|
|
833
|
+
);
|
|
834
|
+
return penalties;
|
|
835
|
+
}
|
|
836
|
+
|
|
754
837
|
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
755
838
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
756
839
|
const startedAllAt = Date.now();
|
|
@@ -340,6 +340,39 @@ const supplyReportedAt = new Map();
|
|
|
340
340
|
* @param {number} waitedMs
|
|
341
341
|
* @returns {void}
|
|
342
342
|
*/
|
|
343
|
+
/**
|
|
344
|
+
* What this file's recent interruptions demand, for a caller that has to decide
|
|
345
|
+
* something with them.
|
|
346
|
+
*
|
|
347
|
+
* Exported because the figures are measured HERE — the reader is the only place
|
|
348
|
+
* that knows how long it waited — while the decisions they feed are made
|
|
349
|
+
* elsewhere: the smallest buffer that hides an interruption goes to the browser,
|
|
350
|
+
* and the speed a step must sustain goes to the quality offer.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} infoHash
|
|
353
|
+
* @param {string} fileName
|
|
354
|
+
* @param {number} segmentSeconds - The session's own segment duration.
|
|
355
|
+
* @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number, minimumBufferSec: number } | null}
|
|
356
|
+
*/
|
|
357
|
+
export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
|
|
358
|
+
const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
|
|
359
|
+
const demand = requiredSpeedFrom(history ?? []);
|
|
360
|
+
if (!demand) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
const buffer = minimumBufferFrom({
|
|
364
|
+
segmentSeconds,
|
|
365
|
+
worstSupplyWaitSec: demand.worstWaitSec
|
|
366
|
+
});
|
|
367
|
+
return {
|
|
368
|
+
requiredSpeed: demand.requiredSpeed,
|
|
369
|
+
worstWaitSec: demand.worstWaitSec,
|
|
370
|
+
medianIntervalSec: demand.medianIntervalSec,
|
|
371
|
+
samples: demand.samples,
|
|
372
|
+
minimumBufferSec: buffer ? buffer.seconds : null
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
343
376
|
function noteSupplyWait(key, label, waitedMs) {
|
|
344
377
|
const history = supplyWaits.get(key) ?? [];
|
|
345
378
|
history.push({ waitedMs, at: Date.now() });
|
|
@@ -26,7 +26,7 @@ import "./install-webrtc-shim.js";
|
|
|
26
26
|
import { parentPort, workerData } from "node:worker_threads";
|
|
27
27
|
import { createSendStream } from "./channel.js";
|
|
28
28
|
import { createFileClaims } from "./file-claims.js";
|
|
29
|
-
import { readFragments } from "./piece-reader.js";
|
|
29
|
+
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
30
30
|
import { Command, Event } from "./protocol.js";
|
|
31
31
|
|
|
32
32
|
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
@@ -358,9 +358,20 @@ async function runCommand(command, params, id) {
|
|
|
358
358
|
|
|
359
359
|
case Command.FILE_STATS: {
|
|
360
360
|
const torrent = await requireTorrent(params.sourceKey);
|
|
361
|
-
|
|
361
|
+
const stats = pool.getFileStats(torrent, params.fileIndex, {
|
|
362
362
|
resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
|
|
363
363
|
});
|
|
364
|
+
// What this file's own interruptions demand, measured by the reader in
|
|
365
|
+
// this thread. It travels with the stats because the caller asking for
|
|
366
|
+
// them is the one that has to decide with them — the browser's smallest
|
|
367
|
+
// safe buffer, and the speed a quality step must sustain. Null until a
|
|
368
|
+
// second interruption has been seen: one wait shows no interval, and an
|
|
369
|
+
// interval invented from one point is exactly what this work removes.
|
|
370
|
+
const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
|
|
371
|
+
return {
|
|
372
|
+
...stats,
|
|
373
|
+
supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4)
|
|
374
|
+
};
|
|
364
375
|
}
|
|
365
376
|
|
|
366
377
|
case Command.PRIORITIZE: {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The price of a second job, with the readings it was derived from.
|
|
3
|
+
*
|
|
4
|
+
* Measured on the addon host 2026-08-18: decoding the same clip ran at 2.20x
|
|
5
|
+
* alone, 0.85x with one encoder beside it and 0.60x with two. The budget has
|
|
6
|
+
* been adding independent prices, and these say two jobs that each fit alone do
|
|
7
|
+
* not fit together.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
|
|
13
|
+
import { contentionPenalty, costWithContention, penaltiesFrom } from "../services/contention.js";
|
|
14
|
+
|
|
15
|
+
/** The addon host's own readings. */
|
|
16
|
+
const ALONE = 2.2;
|
|
17
|
+
const BESIDE = [
|
|
18
|
+
{ others: 1, speed: 0.85 },
|
|
19
|
+
{ others: 2, speed: 0.6 }
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
test("the penalties are the ratios of the measured speeds", () => {
|
|
23
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
24
|
+
|
|
25
|
+
assert.ok(penalties);
|
|
26
|
+
// 2.2 / 0.85 = 2.59: the same work costs two and a half times more with one
|
|
27
|
+
// encoder beside it.
|
|
28
|
+
assert.ok(Math.abs(penalties.get(1) - 2.588) < 0.01, `${penalties.get(1)}`);
|
|
29
|
+
assert.ok(Math.abs(penalties.get(2) - 3.667) < 0.01, `${penalties.get(2)}`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("alone on the machine is what the benchmarks measure, so nothing is corrected", () => {
|
|
33
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
34
|
+
const answer = contentionPenalty(0, penalties);
|
|
35
|
+
assert.equal(answer.penalty, 1);
|
|
36
|
+
assert.equal(answer.measured, true);
|
|
37
|
+
assert.equal(costWithContention(0.45, 0, penalties), 0.45);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a cost beside one other job is the measured multiple, not the sum of two prices", () => {
|
|
41
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
42
|
+
// Decoding priced at 0.45 s/s alone becomes 1.17 s/s beside an encoder —
|
|
43
|
+
// which is what the field measured (1.18), and what additivity cannot say.
|
|
44
|
+
const corrected = costWithContention(0.45, 1, penalties);
|
|
45
|
+
assert.ok(Math.abs(corrected - 1.165) < 0.01, `${corrected}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("beyond the readings it holds the largest instead of extrapolating", () => {
|
|
49
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
50
|
+
const four = contentionPenalty(4, penalties);
|
|
51
|
+
assert.equal(four.penalty, penalties.get(2), "two readings say nothing about a fourth job");
|
|
52
|
+
assert.equal(four.from, 2, "and the answer says which reading it came from");
|
|
53
|
+
assert.equal(four.measured, true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("with nothing measured the prediction is left alone, not guessed at", () => {
|
|
57
|
+
const answer = contentionPenalty(1, null);
|
|
58
|
+
assert.equal(answer.penalty, 1);
|
|
59
|
+
assert.equal(answer.measured, false, "the caller must be able to say the figure is uncorrected");
|
|
60
|
+
assert.equal(costWithContention(0.45, 1, null), 0.45);
|
|
61
|
+
assert.equal(contentionPenalty(1, new Map()).measured, false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a machine that got faster for being busier has no penalty to measure", () => {
|
|
65
|
+
// Noise on a fast host, not a discovery. Measured on the developer's desktop,
|
|
66
|
+
// the difference between one and two jobs is inside the scatter.
|
|
67
|
+
const penalties = penaltiesFrom(20, [{ others: 1, speed: 21 }]);
|
|
68
|
+
assert.equal(penalties.get(1), 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("readings that are not measurements are left out", () => {
|
|
72
|
+
assert.equal(penaltiesFrom(0, BESIDE), null, "every penalty is relative to the alone reading");
|
|
73
|
+
assert.equal(penaltiesFrom(ALONE, []), null);
|
|
74
|
+
assert.equal(penaltiesFrom(ALONE, [{ others: 0, speed: 2 }]), null, "zero others is not a penalty");
|
|
75
|
+
assert.equal(penaltiesFrom(ALONE, [{ others: 1, speed: Number.NaN }]), null);
|
|
76
|
+
});
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What the supply's own interruptions require of a quality step, and of
|
|
3
|
-
* the buffer in front of the viewer.
|
|
4
|
-
*
|
|
5
|
-
* Both figures are chosen by hand today — a speed margin of 1.5 and a 25-second
|
|
6
|
-
* prebuffer — and both stand for a quantity that is measured every few seconds
|
|
7
|
-
* anyway: how long a read waits for a piece, and how often that happens.
|
|
8
|
-
*
|
|
9
|
-
* The arithmetic, from the measurements of 2026-08-17:
|
|
10
|
-
*
|
|
11
|
-
* A step producing at speed `v` gains `v - 1` seconds of cushion per second
|
|
12
|
-
* of playback. An interruption of `W` seconds costs `W`. So a step survives
|
|
13
|
-
* its own supply exactly when it can rebuild what one interruption takes
|
|
14
|
-
* before the next one arrives:
|
|
15
|
-
*
|
|
16
|
-
* (v - 1) × T > W ⇔ v > 1 + W / T
|
|
17
|
-
*
|
|
18
|
-
* On the field torrent that day: waits of 1.49 s median, 3.16 s worst, one
|
|
19
|
-
* every 2.22 s → a required speed of 1.67 against the 1.5 chosen by hand, and
|
|
20
|
-
* against 1.05 actually measured, which is why it stalled. A copy running at
|
|
21
|
-
* 8x gains 15.5 s between interruptions against 1.5-4.8 s lost, which is the
|
|
22
|
-
* same formula explaining why a copy never stalls.
|
|
23
|
-
*
|
|
24
|
-
* The buffer follows from the same numbers: it must hold the segment being
|
|
25
|
-
* played, whole, plus the worst interruption that can arrive before it can be
|
|
26
|
-
* refilled — whichever source that interruption comes from.
|
|
27
|
-
*
|
|
28
|
-
* B_min = segment duration + max(W_supply, D_production, T_transfer)
|
|
29
|
-
*
|
|
30
|
-
* 7-9 s on that torrent, against the 25 s in the browser today.
|
|
31
|
-
*
|
|
32
|
-
* Every term is measured, none is chosen, and the figures rise by themselves
|
|
33
|
-
* when a session's interruptions grow — which is what makes lowering the buffer
|
|
34
|
-
* safe. Pure: no torrent, no clock of its own, no state beyond the samples it
|
|
35
|
-
* is given.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* How many recent interruptions are kept per file. Enough for a median to mean
|
|
40
|
-
* something, short enough that a swarm which has recovered is not judged by how
|
|
41
|
-
* it behaved ten minutes ago.
|
|
42
|
-
*/
|
|
43
|
-
const SAMPLE_LIMIT = 24;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* A reading of one interruption: how long the reader waited, and when.
|
|
47
|
-
*
|
|
48
|
-
* @typedef {object} Interruption
|
|
49
|
-
* @property {number} waitedMs
|
|
50
|
-
* @property {number} at - Epoch milliseconds when the wait ENDED.
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Add one interruption to a record, keeping only the recent ones.
|
|
55
|
-
*
|
|
56
|
-
* @param {Interruption[]} samples - Existing readings, oldest first.
|
|
57
|
-
* @param {Interruption} interruption
|
|
58
|
-
* @returns {Interruption[]} A new array; the input is not modified.
|
|
59
|
-
*/
|
|
60
|
-
export function withInterruption(samples, interruption) {
|
|
61
|
-
const kept = Array.isArray(samples) ? samples : [];
|
|
62
|
-
if (!Number.isFinite(interruption?.waitedMs) || !Number.isFinite(interruption?.at)) {
|
|
63
|
-
return kept;
|
|
64
|
-
}
|
|
65
|
-
const next = [...kept, { waitedMs: Math.max(0, interruption.waitedMs), at: interruption.at }];
|
|
66
|
-
return next.length > SAMPLE_LIMIT ? next.slice(next.length - SAMPLE_LIMIT) : next;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* @param {number[]} values
|
|
71
|
-
* @returns {number}
|
|
72
|
-
*/
|
|
73
|
-
function median(values) {
|
|
74
|
-
if (values.length === 0) {
|
|
75
|
-
return 0;
|
|
76
|
-
}
|
|
77
|
-
const sorted = [...values].sort((left, right) => left - right);
|
|
78
|
-
return sorted[Math.floor(sorted.length / 2)];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* What the recent interruptions amount to.
|
|
83
|
-
*
|
|
84
|
-
* The interval between interruptions is measured between the readings
|
|
85
|
-
* themselves, so a file that is read steadily and rarely blocked reports a long
|
|
86
|
-
* interval and asks little of the step.
|
|
87
|
-
*
|
|
88
|
-
* @param {Interruption[]} samples
|
|
89
|
-
* @returns {{ samples: number, medianWaitSeconds: number, worstWaitSeconds: number, medianGapSeconds: number }}
|
|
90
|
-
*/
|
|
91
|
-
export function summariseInterruptions(samples) {
|
|
92
|
-
const readings = Array.isArray(samples) ? samples : [];
|
|
93
|
-
if (readings.length === 0) {
|
|
94
|
-
return { samples: 0, medianWaitSeconds: 0, worstWaitSeconds: 0, medianGapSeconds: 0 };
|
|
95
|
-
}
|
|
96
|
-
const waits = readings.map((entry) => entry.waitedMs / 1000);
|
|
97
|
-
const gaps = [];
|
|
98
|
-
for (let index = 1; index < readings.length; index += 1) {
|
|
99
|
-
const gap = (readings[index].at - readings[index - 1].at) / 1000;
|
|
100
|
-
if (gap > 0) {
|
|
101
|
-
gaps.push(gap);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
samples: readings.length,
|
|
106
|
-
medianWaitSeconds: median(waits),
|
|
107
|
-
worstWaitSeconds: Math.max(...waits),
|
|
108
|
-
medianGapSeconds: median(gaps)
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The speed a step must run at to survive this supply, or null when the supply
|
|
114
|
-
* has not interrupted often enough to say.
|
|
115
|
-
*
|
|
116
|
-
* Null is a real answer and must not be replaced by a number: with one reading
|
|
117
|
-
* there is no interval at all, and inventing one is how a margin comes to be
|
|
118
|
-
* chosen by hand again. A caller with no figure keeps whatever it used before
|
|
119
|
-
* and says so.
|
|
120
|
-
*
|
|
121
|
-
* The worst wait is used rather than the median, because a step that only
|
|
122
|
-
* survives the typical interruption stalls on the others — and a stall is what
|
|
123
|
-
* the viewer sees, not an average.
|
|
124
|
-
*
|
|
125
|
-
* @param {{ samples: number, worstWaitSeconds: number, medianGapSeconds: number }} summary
|
|
126
|
-
* @returns {number | null}
|
|
127
|
-
*/
|
|
128
|
-
export function requiredSpeedFrom(summary) {
|
|
129
|
-
if (!summary || summary.samples < 2 || !(summary.medianGapSeconds > 0)) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
return 1 + summary.worstWaitSeconds / summary.medianGapSeconds;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The smallest buffer at which no spinner appears: the segment being played,
|
|
137
|
-
* whole, plus the worst interruption that can arrive before it can be refilled.
|
|
138
|
-
*
|
|
139
|
-
* Each term is the worst OBSERVED over a recent window, and a term nobody has
|
|
140
|
-
* measured contributes nothing rather than a guess.
|
|
141
|
-
*
|
|
142
|
-
* @param {{ segmentSeconds: number, supplySeconds?: number, productionSeconds?: number, transferSeconds?: number }} terms
|
|
143
|
-
* @returns {number}
|
|
144
|
-
*/
|
|
145
|
-
export function minimumBufferSeconds(terms) {
|
|
146
|
-
const segment = Number.isFinite(terms?.segmentSeconds) && terms.segmentSeconds > 0
|
|
147
|
-
? terms.segmentSeconds
|
|
148
|
-
: 0;
|
|
149
|
-
const worst = Math.max(
|
|
150
|
-
Number.isFinite(terms?.supplySeconds) ? terms.supplySeconds : 0,
|
|
151
|
-
Number.isFinite(terms?.productionSeconds) ? terms.productionSeconds : 0,
|
|
152
|
-
Number.isFinite(terms?.transferSeconds) ? terms.transferSeconds : 0
|
|
153
|
-
);
|
|
154
|
-
return segment + Math.max(0, worst);
|
|
155
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The speed a step needs, and the buffer a viewer needs, from what the
|
|
3
|
-
* supply actually did.
|
|
4
|
-
*
|
|
5
|
-
* The field numbers these are checked against (2026-08-17, one torrent, one
|
|
6
|
-
* session): waits of 1.49 s median and 3.16 s worst, one every 2.22 s. The
|
|
7
|
-
* margin chosen by hand was 1.5; the arithmetic says 1.67; the step measured
|
|
8
|
-
* 1.05 and stalled.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import test from "node:test";
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
minimumBufferSeconds,
|
|
16
|
-
requiredSpeedFrom,
|
|
17
|
-
summariseInterruptions,
|
|
18
|
-
withInterruption
|
|
19
|
-
} from "../services/torrent-worker/supply-interruptions.js";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Interruptions of `waitMs`, one every `gapMs`.
|
|
23
|
-
*
|
|
24
|
-
* @param {number[]} waitsMs
|
|
25
|
-
* @param {number} gapMs
|
|
26
|
-
* @returns {Array<{ waitedMs: number, at: number }>}
|
|
27
|
-
*/
|
|
28
|
-
function series(waitsMs, gapMs) {
|
|
29
|
-
let samples = [];
|
|
30
|
-
let at = 1_000_000;
|
|
31
|
-
for (const waitedMs of waitsMs) {
|
|
32
|
-
samples = withInterruption(samples, { waitedMs, at });
|
|
33
|
-
at += gapMs;
|
|
34
|
-
}
|
|
35
|
-
return samples;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
test("the field session's numbers produce the field session's answer", () => {
|
|
39
|
-
// Waits around 1.49 s with a worst of 3.16 s, one every 2.22 s.
|
|
40
|
-
const samples = series([1490, 1490, 3160, 1490, 1490], 2220);
|
|
41
|
-
const summary = summariseInterruptions(samples);
|
|
42
|
-
|
|
43
|
-
assert.equal(summary.worstWaitSeconds, 3.16);
|
|
44
|
-
assert.equal(summary.medianGapSeconds, 2.22);
|
|
45
|
-
|
|
46
|
-
const required = requiredSpeedFrom(summary);
|
|
47
|
-
assert.ok(required !== null);
|
|
48
|
-
assert.ok(
|
|
49
|
-
Math.abs(required - 2.42) < 0.01,
|
|
50
|
-
`1 + 3.16/2.22 = ${required?.toFixed(2)} — the supply asks for it, nobody chose it`
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("a supply that rarely interrupts asks for almost nothing", () => {
|
|
55
|
-
// One short wait every couple of minutes: a step barely faster than realtime
|
|
56
|
-
// rebuilds the cushion long before the next one.
|
|
57
|
-
const summary = summariseInterruptions(series([200, 200, 200], 120_000));
|
|
58
|
-
const required = requiredSpeedFrom(summary);
|
|
59
|
-
assert.ok(required !== null && required < 1.01, `asked ${required}`);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("a supply that interrupts constantly asks for a great deal", () => {
|
|
63
|
-
const summary = summariseInterruptions(series([4000, 4000, 4000], 1000));
|
|
64
|
-
assert.equal(requiredSpeedFrom(summary), 5);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("too little evidence is answered with nothing, never with a number", () => {
|
|
68
|
-
// The whole point of deriving the margin is that it stops being invented. One
|
|
69
|
-
// reading has no interval at all, and a caller must keep what it had.
|
|
70
|
-
assert.equal(requiredSpeedFrom(summariseInterruptions([])), null);
|
|
71
|
-
assert.equal(requiredSpeedFrom(summariseInterruptions(series([1000], 0))), null);
|
|
72
|
-
assert.equal(requiredSpeedFrom(null), null);
|
|
73
|
-
assert.equal(requiredSpeedFrom({ samples: 9, worstWaitSeconds: 3, medianGapSeconds: 0 }), null);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("the worst wait decides, not the typical one", () => {
|
|
77
|
-
// A step that survives the median interruption still stalls on the others,
|
|
78
|
-
// and a stall is what the viewer sees.
|
|
79
|
-
const summary = summariseInterruptions(series([100, 100, 5000, 100], 1000));
|
|
80
|
-
assert.equal(summary.medianWaitSeconds, 0.1);
|
|
81
|
-
assert.equal(summary.worstWaitSeconds, 5);
|
|
82
|
-
assert.equal(requiredSpeedFrom(summary), 6);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("only the recent interruptions are judged", () => {
|
|
86
|
-
// A swarm that has recovered must not be sentenced by how it behaved ten
|
|
87
|
-
// minutes ago, so the record is bounded.
|
|
88
|
-
let samples = series(Array.from({ length: 40 }, () => 9000), 1000);
|
|
89
|
-
samples = withInterruption(samples, { waitedMs: 10, at: 2_000_000 });
|
|
90
|
-
assert.ok(samples.length <= 24, `kept ${samples.length}`);
|
|
91
|
-
assert.equal(samples[samples.length - 1].waitedMs, 10);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("a reading without a time or a duration is not a reading", () => {
|
|
95
|
-
const samples = withInterruption([], { waitedMs: Number.NaN, at: 1 });
|
|
96
|
-
assert.deepEqual(samples, []);
|
|
97
|
-
assert.deepEqual(withInterruption([], { waitedMs: 100 }), []);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("the minimum buffer is one segment plus the worst interruption", () => {
|
|
101
|
-
// The field torrent: 4 s segments, a worst supply wait of 3.16 s, production
|
|
102
|
-
// gaps within the segment length, transfer measured in milliseconds.
|
|
103
|
-
const seconds = minimumBufferSeconds({
|
|
104
|
-
segmentSeconds: 4,
|
|
105
|
-
supplySeconds: 3.16,
|
|
106
|
-
productionSeconds: 1.2,
|
|
107
|
-
transferSeconds: 0.066
|
|
108
|
-
});
|
|
109
|
-
assert.ok(Math.abs(seconds - 7.16) < 0.001, `${seconds}s against the 25 s chosen by hand`);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("whichever source is worst is the one that sizes the buffer", () => {
|
|
113
|
-
// A step at 1.0x makes production the binding term even on a swarm that never
|
|
114
|
-
// stalls, which is exactly the case a supply-only figure would miss.
|
|
115
|
-
assert.equal(
|
|
116
|
-
minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: 0.2, productionSeconds: 6 }),
|
|
117
|
-
10
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("a term nobody measured contributes nothing, not a guess", () => {
|
|
122
|
-
assert.equal(minimumBufferSeconds({ segmentSeconds: 4 }), 4);
|
|
123
|
-
assert.equal(minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: Number.NaN }), 4);
|
|
124
|
-
assert.equal(minimumBufferSeconds({}), 0);
|
|
125
|
-
});
|