@torrent-tv/proxy 2.28.0 → 2.30.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 +8 -0
- package/package.json +1 -1
- package/services/supply-margin.js +159 -0
- package/services/torrent-worker/fastest-wires.js +121 -0
- package/services/torrent-worker/piece-reader.js +114 -1
- package/services/torrent-worker/supply-interruptions.js +155 -0
- package/test/fastest-wires.test.js +121 -0
- package/test/supply-interruptions.test.js +125 -0
- package/test/supply-margin.test.js +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.30.0
|
|
2
|
+
|
|
3
|
+
- **New**: The speed a step must sustain, and the smallest buffer that hides an interruption, are now COMPUTED from the supply's own behaviour instead of being chosen by hand — printed first, used later. A step producing at `v` gains `v - 1` seconds of cushion per second and an interruption of `W` seconds costs `W`, so it survives its own supply only while `(v - 1) × T > W`, that is `v > 1 + W / T`, with `W` the worst recent wait for a piece and `T` the median interval between such waits. On the field torrent of 2026-08-17 that is **2.42x**, against the 1.5 assumed today and the 1.05 measured on the step that stalled; on the same file's copied stream it is 1.31 against 8x measured, which is why a copy never stalls. The buffer follows from the same readings: one whole segment — the one being played — plus the worst interruption that can arrive before it refills, whichever source it comes from, which was **7-9 s** where the browser waits for 25. Both figures are logged per file every half minute, so the next session says whether the arithmetic describes reality BEFORE anything is decided by it. The arithmetic is a pure module with the field session's own numbers as its tests (`services/supply-margin.js`).
|
|
4
|
+
|
|
5
|
+
## 2.29.0
|
|
6
|
+
|
|
7
|
+
- **New**: A piece a reader is blocked on is handed to the fastest peers that hold it. Measured 2026-08-17: the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s — a fivefold surplus — and the reader still blocked 47 times in two minutes, 1.0-4.5 s each, on pieces a median of five peers already had. A block belongs to exactly one wire, so the read ends when the SLOWEST holder delivers, and `critical()` only lets the library take a block from a slow wire when its own picker happens to visit an idle one. This asks for it deliberately: when the wait starts, and again on the sampling tick that already runs while it lasts, the piece is pushed onto the three fastest unchoked holders through the library's own request entry with hotswap enabled. Nothing is duplicated — the library moves a block to a wire at least twice as fast, which bounds how often it can move at all. A refusal is counted rather than ignored (a full pipeline, or nothing reservable even with hotswap, means the piece waits on the wire and not on the picker), and a build that offers no such entry says so instead of failing silently. The wait line now reports `steered onto N of M holders`, so the next session says by number whether the tail shortened.
|
|
8
|
+
|
|
1
9
|
## 2.28.0
|
|
2
10
|
|
|
3
11
|
- **Fix**: The playlist and the media agree again, and the container's keyframe table was never at fault. On a file whose first timestamp is 2.002 s, the copied picture was asked to cut at 808.808 s on the 0-based grid and cut at 806.806 s — exactly the container's start time early, because that branch keeps the source's own timestamps and re-labels the output afterwards, so a cut list stated in 0-based terms is applied 2 s away from where it means. The soundtrack, re-encoded and on the other branch, cut where it was asked. The two then wrote different values into the shared boundary table and corrected each other for the whole session (#202: 808.808 → 806.806 → 808.750 → …), the playlist drifted a whole segment from the media, and the player refetched fragments it could not place. The cut list is now stated in the source's terms on that branch, which is the same shift the seek on it already applies.
|
package/package.json
CHANGED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What speed a step must run at, and how much buffer a viewer needs —
|
|
3
|
+
* both derived from the supply's own interruptions rather than chosen by hand.
|
|
4
|
+
*
|
|
5
|
+
* The two numbers this replaces were guesses. The speed margin was 1.5, and on
|
|
6
|
+
* the field session of 2026-08-17 a step admitted by it ran at 1.05x and
|
|
7
|
+
* stalled; the pre-buffer target was 25 s, which is sixteen seconds of waiting
|
|
8
|
+
* before the picture starts that nobody had shown to be necessary.
|
|
9
|
+
*
|
|
10
|
+
* Both follow from the same two measured quantities, and from nothing else:
|
|
11
|
+
*
|
|
12
|
+
* W — how long a read waits for a piece it needs (the worst recent one);
|
|
13
|
+
* T — how long there is between such waits (the median recent interval).
|
|
14
|
+
*
|
|
15
|
+
* **The margin.** A step producing at speed `v` gains `v - 1` seconds of
|
|
16
|
+
* cushion for every second it runs, and an interruption of `W` seconds costs
|
|
17
|
+
* `W`. It therefore survives its own supply only if what it gains between
|
|
18
|
+
* interruptions exceeds what one costs:
|
|
19
|
+
*
|
|
20
|
+
* (v - 1) × T > W i.e. v > 1 + W / T
|
|
21
|
+
*
|
|
22
|
+
* On the field torrent: waits every 2.22 s, worst 3.16 s, so the honest bar is
|
|
23
|
+
* 2.42 — against the 1.5 that was assumed, and the 1.05 that was measured. The
|
|
24
|
+
* same arithmetic explains why a copied stream never stalls: at 8x it gains
|
|
25
|
+
* 15.5 s between interruptions and loses at most 4.8 s.
|
|
26
|
+
*
|
|
27
|
+
* **The buffer.** It must cover the worst interruption that can arrive before
|
|
28
|
+
* it can be refilled, whichever source that interruption comes from, plus the
|
|
29
|
+
* segment being played — which must be whole:
|
|
30
|
+
*
|
|
31
|
+
* B = segment duration + max(W_supply, D_production, T_transfer)
|
|
32
|
+
*
|
|
33
|
+
* On the same session that is 7-9 s rather than 25. It rises by itself when a
|
|
34
|
+
* session's interruptions grow, which is what makes it safe to lower: the
|
|
35
|
+
* figure is continuously measured, not chosen once.
|
|
36
|
+
*
|
|
37
|
+
* Every quantity here is measured. Nothing in this file is a coefficient, and
|
|
38
|
+
* nothing smooths, weights or decays anything.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One measured interruption: how long a read waited, and when the wait ended.
|
|
43
|
+
*
|
|
44
|
+
* @typedef {object} SupplyWait
|
|
45
|
+
* @property {number} waitedMs - How long the read was blocked.
|
|
46
|
+
* @property {number} at - Wall-clock ms when the wait ended.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The speed a step must sustain to survive this file's supply on this swarm.
|
|
51
|
+
*
|
|
52
|
+
* Returns null when the evidence does not exist yet — fewer than two waits
|
|
53
|
+
* means no interval has been observed, and an interval invented from one point
|
|
54
|
+
* would be exactly the kind of number this file exists to remove. A caller with
|
|
55
|
+
* null must say it does not know, never substitute a default.
|
|
56
|
+
*
|
|
57
|
+
* @param {SupplyWait[]} waits - Recent waits, in any order.
|
|
58
|
+
* @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number } | null}
|
|
59
|
+
*/
|
|
60
|
+
export function requiredSpeedFrom(waits) {
|
|
61
|
+
const ordered = usableWaits(waits);
|
|
62
|
+
if (ordered.length < 2) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const worstWaitSec = Math.max(...ordered.map((wait) => wait.waitedMs)) / 1000;
|
|
66
|
+
const intervals = [];
|
|
67
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
68
|
+
const gapMs = ordered[index].at - ordered[index - 1].at;
|
|
69
|
+
if (gapMs > 0) {
|
|
70
|
+
intervals.push(gapMs / 1000);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (intervals.length === 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const medianIntervalSec = median(intervals);
|
|
77
|
+
if (!(medianIntervalSec > 0)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
requiredSpeed: 1 + worstWaitSec / medianIntervalSec,
|
|
82
|
+
worstWaitSec,
|
|
83
|
+
medianIntervalSec,
|
|
84
|
+
samples: ordered.length
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The smallest buffer at which no interruption reaches the viewer.
|
|
90
|
+
*
|
|
91
|
+
* Each term is the worst OBSERVED value over a recent window, and a term with
|
|
92
|
+
* nothing observed contributes nothing rather than a guess. The segment is
|
|
93
|
+
* always included: the one being played has to be whole.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} observed
|
|
96
|
+
* @param {number} observed.segmentSeconds - The session's segment duration.
|
|
97
|
+
* @param {number} [observed.worstSupplyWaitSec] - Longest wait for a piece.
|
|
98
|
+
* @param {number} [observed.worstProductionGapSec] - Longest gap between
|
|
99
|
+
* consecutive segments beyond their own length: what a step at 1.0x costs.
|
|
100
|
+
* @param {number} [observed.worstTransferSec] - Longest time to move one
|
|
101
|
+
* segment over the channel to the viewer.
|
|
102
|
+
* @returns {{ seconds: number, from: string } | null} Null when the segment
|
|
103
|
+
* duration is unknown, since then nothing here can be stated.
|
|
104
|
+
*/
|
|
105
|
+
export function minimumBufferFrom(observed = {}) {
|
|
106
|
+
const segmentSeconds = Number(observed.segmentSeconds);
|
|
107
|
+
if (!Number.isFinite(segmentSeconds) || segmentSeconds <= 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const terms = [
|
|
111
|
+
{ name: "supply", seconds: positiveOrZero(observed.worstSupplyWaitSec) },
|
|
112
|
+
{ name: "production", seconds: positiveOrZero(observed.worstProductionGapSec) },
|
|
113
|
+
{ name: "transfer", seconds: positiveOrZero(observed.worstTransferSec) }
|
|
114
|
+
];
|
|
115
|
+
const worst = terms.reduce(
|
|
116
|
+
(largest, term) => (term.seconds > largest.seconds ? term : largest),
|
|
117
|
+
{ name: "none", seconds: 0 }
|
|
118
|
+
);
|
|
119
|
+
return {
|
|
120
|
+
seconds: segmentSeconds + worst.seconds,
|
|
121
|
+
// Which interruption sets the figure, so a session's log says what the
|
|
122
|
+
// viewer is actually waiting for rather than only how long.
|
|
123
|
+
from: worst.name
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Waits that can be reasoned about, oldest first.
|
|
129
|
+
*
|
|
130
|
+
* @param {SupplyWait[]} waits
|
|
131
|
+
* @returns {SupplyWait[]}
|
|
132
|
+
*/
|
|
133
|
+
function usableWaits(waits) {
|
|
134
|
+
if (!Array.isArray(waits)) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
return waits
|
|
138
|
+
.filter((wait) => Number.isFinite(wait?.waitedMs) && wait.waitedMs > 0 && Number.isFinite(wait?.at))
|
|
139
|
+
.sort((left, right) => left.at - right.at);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {number[]} values - Non-empty.
|
|
144
|
+
* @returns {number}
|
|
145
|
+
*/
|
|
146
|
+
function median(values) {
|
|
147
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
148
|
+
const middle = Math.floor(sorted.length / 2);
|
|
149
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {unknown} value
|
|
154
|
+
* @returns {number}
|
|
155
|
+
*/
|
|
156
|
+
function positiveOrZero(value) {
|
|
157
|
+
const numeric = Number(value);
|
|
158
|
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
|
|
159
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Put the piece a reader is blocked on onto the fastest wires that hold
|
|
3
|
+
* it.
|
|
4
|
+
*
|
|
5
|
+
* Measured 2026-08-17: the swarm delivered 5.1-5.9 MB/s against a film consumed
|
|
6
|
+
* at about 1 MB/s — a fivefold surplus — and the reader still blocked 47 times
|
|
7
|
+
* in two minutes, 1.0-4.5 s each. So the shortage is not bandwidth. The piece
|
|
8
|
+
* that blocked had been requested from a median of five peers, and what set the
|
|
9
|
+
* tail was WHICH wire held the last outstanding block: a block is reserved for
|
|
10
|
+
* exactly one wire, and the read finishes when the slowest holder delivers.
|
|
11
|
+
*
|
|
12
|
+
* What the library already does, and what it does not: `torrent.critical()`
|
|
13
|
+
* (which the reader already sets over its window) enables HOTSWAP — an idle
|
|
14
|
+
* wire may take a block away from the SLOWEST holder. It does not duplicate:
|
|
15
|
+
* `piece.reserve()` returns -1 once every block is spoken for, and the
|
|
16
|
+
* end-game that would ask a second peer for the same block is commented out in
|
|
17
|
+
* `webtorrent/lib/torrent.js`. Hotswap therefore fires only when the library's
|
|
18
|
+
* own picker happens to visit an idle wire while our piece is critical.
|
|
19
|
+
*
|
|
20
|
+
* This asks for it on purpose, and chooses who: the wires that are unchoked,
|
|
21
|
+
* hold the piece and are measurably fastest are handed the piece through the
|
|
22
|
+
* same entry the library's picker uses, with hotswap enabled. Nothing is
|
|
23
|
+
* duplicated and no protocol rule is bent — the block moves to a faster holder
|
|
24
|
+
* instead of staying with whoever got it first.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The library's own request entry. Internal, so its absence must be noticed
|
|
29
|
+
* rather than swallowed: without it this lever silently does nothing.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} torrent
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function canPlaceRequests(torrent) {
|
|
35
|
+
return typeof torrent?._request === "function";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wires that could deliver this piece right now, fastest first.
|
|
40
|
+
*
|
|
41
|
+
* Excluded, each for its own reason: a wire that is choking us cannot be asked
|
|
42
|
+
* at all; one that does not hold the piece has nothing to give; a destroyed one
|
|
43
|
+
* is a corpse. Speed is the library's own measurement over its own window, so
|
|
44
|
+
* nothing here needs a history of its own.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} torrent
|
|
47
|
+
* @param {number} pieceIndex
|
|
48
|
+
* @returns {object[]}
|
|
49
|
+
*/
|
|
50
|
+
export function wiresForPiece(torrent, pieceIndex) {
|
|
51
|
+
const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
|
|
52
|
+
const usable = [];
|
|
53
|
+
for (const wire of wires) {
|
|
54
|
+
if (!wire || wire.destroyed || wire.peerChoking) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (wire.peerPieces?.get?.(pieceIndex) !== true) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
usable.push(wire);
|
|
61
|
+
}
|
|
62
|
+
return usable.sort((left, right) => speedOf(right) - speedOf(left));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A wire's measured download speed in bytes per second, or 0 when it has not
|
|
67
|
+
* been measured. Wrapped because `downloadSpeed` is a method on the wire and a
|
|
68
|
+
* throw from a destroyed one must not take the caller with it.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} wire
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
function speedOf(wire) {
|
|
74
|
+
try {
|
|
75
|
+
const speed = wire?.downloadSpeed?.();
|
|
76
|
+
return Number.isFinite(speed) ? speed : 0;
|
|
77
|
+
} catch {
|
|
78
|
+
// A wire that cannot say how fast it is ranks last, which is the honest
|
|
79
|
+
// answer — and the caller's own log line reports how many were asked.
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ask the fastest holders of `pieceIndex` for it.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} torrent
|
|
88
|
+
* @param {number} pieceIndex
|
|
89
|
+
* @param {number} [limit] - How many wires to push it onto.
|
|
90
|
+
* @returns {{ asked: number, considered: number, fastestBytesPerSecond: number }}
|
|
91
|
+
* `asked` counts requests the library actually placed: it refuses when a
|
|
92
|
+
* wire's pipeline is full or when nothing can be reserved even with hotswap,
|
|
93
|
+
* and that refusal is information — a piece nobody can be asked for is
|
|
94
|
+
* waiting on the wire, not on the picker.
|
|
95
|
+
*/
|
|
96
|
+
export function askFastestWiresFor(torrent, pieceIndex, limit = 3) {
|
|
97
|
+
if (!canPlaceRequests(torrent) || !Number.isInteger(pieceIndex) || pieceIndex < 0) {
|
|
98
|
+
return { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
|
|
99
|
+
}
|
|
100
|
+
const candidates = wiresForPiece(torrent, pieceIndex);
|
|
101
|
+
let asked = 0;
|
|
102
|
+
for (const wire of candidates.slice(0, Math.max(1, limit))) {
|
|
103
|
+
try {
|
|
104
|
+
// `true` is hotswap: if every block is reserved, take one from the
|
|
105
|
+
// slowest holder. That is the whole point — the reader is blocked
|
|
106
|
+
// precisely because a slow holder has one.
|
|
107
|
+
if (torrent._request(wire, pieceIndex, true) === true) {
|
|
108
|
+
asked += 1;
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Internal call: report it once per attempt rather than letting the lever
|
|
112
|
+
// fail in silence.
|
|
113
|
+
throw new Error(`could not place a request for piece ${pieceIndex}: ${error?.message ?? error}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
asked,
|
|
118
|
+
considered: candidates.length,
|
|
119
|
+
fastestBytesPerSecond: candidates.length > 0 ? speedOf(candidates[0]) : 0
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
import { findSharedStore } from "../piece-store/shared-piece-store.js";
|
|
24
24
|
import { logger } from "../../utils/logger.js";
|
|
25
|
+
import { askFastestWiresFor, canPlaceRequests } from "./fastest-wires.js";
|
|
26
|
+
import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
|
|
25
27
|
|
|
26
28
|
/** Only waits at least this long are reported; sequential reading stays silent. */
|
|
27
29
|
const PIECE_WAIT_LOG_MS = 1_000;
|
|
@@ -303,6 +305,77 @@ function whenPieceReady(torrent, index, cancellation) {
|
|
|
303
305
|
* the media's byte rate should size it in seconds of playback instead.
|
|
304
306
|
* @returns {AsyncGenerator<PieceFragment>}
|
|
305
307
|
*/
|
|
308
|
+
/**
|
|
309
|
+
* The last interruptions this file's readers met, newest last.
|
|
310
|
+
*
|
|
311
|
+
* Bounded and per file, because both figures derived from it describe THIS
|
|
312
|
+
* file on THIS swarm: a piece is 8 MiB here and 512 KiB elsewhere, and a swarm
|
|
313
|
+
* that answers in 200 ms today may not tomorrow. Nothing is stored beyond the
|
|
314
|
+
* process — a restart starts from no evidence, which is the honest state.
|
|
315
|
+
*
|
|
316
|
+
* @type {Map<string, Array<{ waitedMs: number, at: number }>>}
|
|
317
|
+
*/
|
|
318
|
+
const supplyWaits = new Map();
|
|
319
|
+
|
|
320
|
+
/** How many interruptions are kept per file. */
|
|
321
|
+
const SUPPLY_WAIT_HISTORY = 40;
|
|
322
|
+
|
|
323
|
+
/** How often the derived figures are printed, at most. */
|
|
324
|
+
const SUPPLY_REPORT_INTERVAL_MS = 30_000;
|
|
325
|
+
|
|
326
|
+
/** When each file's figures were last printed. */
|
|
327
|
+
const supplyReportedAt = new Map();
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Record one interruption and, at most twice a minute, say what it implies.
|
|
331
|
+
*
|
|
332
|
+
* The two figures are the whole of roadmap item 3: the speed a step must
|
|
333
|
+
* sustain to survive this supply (`1 + worst wait / median interval`), and the
|
|
334
|
+
* smallest buffer that hides an interruption from the viewer. Both are printed
|
|
335
|
+
* before either is USED, so the field says whether the arithmetic describes
|
|
336
|
+
* reality before anything is decided by it.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} key - Something stable per file.
|
|
339
|
+
* @param {string} label - What to call it in the log.
|
|
340
|
+
* @param {number} waitedMs
|
|
341
|
+
* @returns {void}
|
|
342
|
+
*/
|
|
343
|
+
function noteSupplyWait(key, label, waitedMs) {
|
|
344
|
+
const history = supplyWaits.get(key) ?? [];
|
|
345
|
+
history.push({ waitedMs, at: Date.now() });
|
|
346
|
+
while (history.length > SUPPLY_WAIT_HISTORY) {
|
|
347
|
+
history.shift();
|
|
348
|
+
}
|
|
349
|
+
supplyWaits.set(key, history);
|
|
350
|
+
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
if (now - (supplyReportedAt.get(key) ?? 0) < SUPPLY_REPORT_INTERVAL_MS) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const demand = requiredSpeedFrom(history);
|
|
356
|
+
if (!demand) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
supplyReportedAt.set(key, now);
|
|
360
|
+
const buffer = minimumBufferFrom({
|
|
361
|
+
segmentSeconds: SEGMENT_SECONDS_FOR_BUFFER,
|
|
362
|
+
worstSupplyWaitSec: demand.worstWaitSec
|
|
363
|
+
});
|
|
364
|
+
logger.info(
|
|
365
|
+
`supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
|
|
366
|
+
`to survive this swarm (worst wait ${demand.worstWaitSec.toFixed(2)}s, one every ` +
|
|
367
|
+
`${demand.medianIntervalSec.toFixed(2)}s, ${demand.samples} measured) — ` +
|
|
368
|
+
`and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The segment length the buffer figure is stated against. The reader does not
|
|
374
|
+
* know the session's own, and this is a REPORT rather than a decision — the
|
|
375
|
+
* decision, when it is made, will use the session's real one.
|
|
376
|
+
*/
|
|
377
|
+
const SEGMENT_SECONDS_FOR_BUFFER = 4;
|
|
378
|
+
|
|
306
379
|
export async function* readFragments({
|
|
307
380
|
torrent,
|
|
308
381
|
fileIndex,
|
|
@@ -438,6 +511,35 @@ export async function* readFragments({
|
|
|
438
511
|
}
|
|
439
512
|
|
|
440
513
|
const waitStartedAt = Date.now();
|
|
514
|
+
// The reader is blocked, so this piece is now the only thing that matters
|
|
515
|
+
// on this torrent: hand it to the fastest wires that hold it. A block is
|
|
516
|
+
// reserved for exactly one wire, and the read ends when the slowest
|
|
517
|
+
// holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
|
|
518
|
+
// of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
|
|
519
|
+
// minutes, on pieces five peers already had.
|
|
520
|
+
let pushed = { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
|
|
521
|
+
const pushToFastest = () => {
|
|
522
|
+
try {
|
|
523
|
+
const result = askFastestWiresFor(torrent, pieceIndex);
|
|
524
|
+
pushed = {
|
|
525
|
+
asked: pushed.asked + result.asked,
|
|
526
|
+
considered: result.considered,
|
|
527
|
+
fastestBytesPerSecond: result.fastestBytesPerSecond
|
|
528
|
+
};
|
|
529
|
+
} catch (error) {
|
|
530
|
+
// The entry is internal to the library; if a version changes it, this
|
|
531
|
+
// lever stops working and that must be visible rather than silent.
|
|
532
|
+
logger.warn(`piece-reader: could not steer piece ${pieceIndex} — ${error?.message ?? error}`);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
if (canPlaceRequests(torrent)) {
|
|
536
|
+
pushToFastest();
|
|
537
|
+
} else {
|
|
538
|
+
logger.warn(
|
|
539
|
+
"piece-reader: this webtorrent build offers no way to place a request; " +
|
|
540
|
+
"the blocked piece cannot be steered onto a faster peer"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
441
543
|
// Sampled while waiting rather than after: once the piece lands, nothing
|
|
442
544
|
// is outstanding on it any more and every count reads zero.
|
|
443
545
|
let supply = null;
|
|
@@ -446,6 +548,9 @@ export async function* readFragments({
|
|
|
446
548
|
if (!supply || sample.blocks > supply.blocks) {
|
|
447
549
|
supply = sample;
|
|
448
550
|
}
|
|
551
|
+
// Wires come and go, and their speeds change: a holder that was slow a
|
|
552
|
+
// moment ago may now be the fastest one available.
|
|
553
|
+
pushToFastest();
|
|
449
554
|
}, 500);
|
|
450
555
|
try {
|
|
451
556
|
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
@@ -462,6 +567,7 @@ export async function* readFragments({
|
|
|
462
567
|
// short, an immediate hit means it is longer than it needs to be. Applied
|
|
463
568
|
// before the logging below so the line reports the window the next piece
|
|
464
569
|
// will actually use.
|
|
570
|
+
noteSupplyWait(`${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`, file?.name ?? "", waitedMs);
|
|
465
571
|
const widened = nextWindowPieces({
|
|
466
572
|
current: windowPieces,
|
|
467
573
|
base: basePieces,
|
|
@@ -482,7 +588,14 @@ export async function* readFragments({
|
|
|
482
588
|
(supply
|
|
483
589
|
? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
|
|
484
590
|
`${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
|
|
485
|
-
: "no sample taken")
|
|
591
|
+
: "no sample taken") +
|
|
592
|
+
// What WE did about it, so the next session says whether steering
|
|
593
|
+
// the piece onto faster holders shortens the tail — by number
|
|
594
|
+
// rather than by impression.
|
|
595
|
+
`; steered onto ${pushed.asked} of ${pushed.considered} holders` +
|
|
596
|
+
(pushed.fastestBytesPerSecond > 0
|
|
597
|
+
? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
|
|
598
|
+
: "")
|
|
486
599
|
);
|
|
487
600
|
}
|
|
488
601
|
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Choosing who is asked for the piece a reader is blocked on.
|
|
3
|
+
*
|
|
4
|
+
* The measurement behind it (2026-08-17): a fivefold bandwidth surplus and 47
|
|
5
|
+
* blocking waits in two minutes, on pieces a median of five peers already had.
|
|
6
|
+
* A block belongs to one wire, so the read ends when the SLOWEST holder
|
|
7
|
+
* delivers — which makes "who holds it" the thing worth deciding.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
|
|
13
|
+
import { askFastestWiresFor, canPlaceRequests, wiresForPiece } from "../services/torrent-worker/fastest-wires.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ speed: number, has?: boolean, choking?: boolean, destroyed?: boolean }} options
|
|
17
|
+
* @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
function wire({ speed, has = true, choking = false, destroyed = false }) {
|
|
20
|
+
return {
|
|
21
|
+
destroyed,
|
|
22
|
+
peerChoking: choking,
|
|
23
|
+
peerPieces: { get: () => has },
|
|
24
|
+
downloadSpeed: () => speed
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object[]} wires
|
|
30
|
+
* @returns {{ torrent: object, asked: Array<{ speed: number, hotswap: boolean }> }}
|
|
31
|
+
*/
|
|
32
|
+
function torrentWith(wires, { refuse = false } = {}) {
|
|
33
|
+
/** @type {Array<{ speed: number, hotswap: boolean }>} */
|
|
34
|
+
const asked = [];
|
|
35
|
+
const torrent = {
|
|
36
|
+
wires,
|
|
37
|
+
_request(target, _index, hotswap) {
|
|
38
|
+
asked.push({ speed: target.downloadSpeed(), hotswap });
|
|
39
|
+
return !refuse;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return { torrent, asked };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("the fastest holders are asked first", () => {
|
|
46
|
+
const { torrent, asked } = torrentWith([
|
|
47
|
+
wire({ speed: 100_000 }),
|
|
48
|
+
wire({ speed: 900_000 }),
|
|
49
|
+
wire({ speed: 400_000 })
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const result = askFastestWiresFor(torrent, 42);
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(
|
|
55
|
+
asked.map((entry) => entry.speed),
|
|
56
|
+
[900_000, 400_000, 100_000],
|
|
57
|
+
"fastest first — the read ends when the slowest holder delivers"
|
|
58
|
+
);
|
|
59
|
+
assert.equal(result.asked, 3);
|
|
60
|
+
assert.equal(result.fastestBytesPerSecond, 900_000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("hotswap is always on, because that is the whole point", () => {
|
|
64
|
+
// The reader is blocked precisely because every block is reserved and one of
|
|
65
|
+
// them sits with a slow wire. Without hotswap the library answers "nothing to
|
|
66
|
+
// reserve" and the lever does nothing at all.
|
|
67
|
+
const { torrent, asked } = torrentWith([wire({ speed: 10 })]);
|
|
68
|
+
askFastestWiresFor(torrent, 7);
|
|
69
|
+
assert.deepEqual(asked.map((entry) => entry.hotswap), [true]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a wire that cannot deliver is not asked", () => {
|
|
73
|
+
const { torrent, asked } = torrentWith([
|
|
74
|
+
wire({ speed: 900_000, choking: true }),
|
|
75
|
+
wire({ speed: 800_000, has: false }),
|
|
76
|
+
wire({ speed: 700_000, destroyed: true }),
|
|
77
|
+
wire({ speed: 1_000 })
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const result = askFastestWiresFor(torrent, 3);
|
|
81
|
+
|
|
82
|
+
assert.equal(asked.length, 1, "a choking, an absent and a dead wire are all unusable");
|
|
83
|
+
assert.equal(result.considered, 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a refusal is counted as a refusal", () => {
|
|
87
|
+
// The library refuses when a wire's pipeline is full or nothing can be
|
|
88
|
+
// reserved even with hotswap. That is information: the piece is waiting on
|
|
89
|
+
// the wire, not on the picker.
|
|
90
|
+
const { torrent } = torrentWith([wire({ speed: 500 }), wire({ speed: 400 })], { refuse: true });
|
|
91
|
+
const result = askFastestWiresFor(torrent, 11);
|
|
92
|
+
assert.equal(result.asked, 0);
|
|
93
|
+
assert.equal(result.considered, 2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a build without the request entry is reported, not silently skipped", () => {
|
|
97
|
+
assert.equal(canPlaceRequests({ wires: [] }), false);
|
|
98
|
+
assert.equal(canPlaceRequests({ wires: [], _request: () => true }), true);
|
|
99
|
+
const result = askFastestWiresFor({ wires: [wire({ speed: 1 })] }, 1);
|
|
100
|
+
assert.deepEqual(result, { asked: 0, considered: 0, fastestBytesPerSecond: 0 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a wire that cannot say how fast it is ranks last rather than throwing", () => {
|
|
104
|
+
const mute = wire({ speed: 0 });
|
|
105
|
+
mute.downloadSpeed = () => {
|
|
106
|
+
throw new Error("destroyed");
|
|
107
|
+
};
|
|
108
|
+
const ranked = wiresForPiece({ wires: [mute, wire({ speed: 5 })] }, 0);
|
|
109
|
+
assert.equal(ranked.length, 2);
|
|
110
|
+
assert.equal(ranked[0].downloadSpeed(), 5);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a throw from the library is raised, never swallowed", () => {
|
|
114
|
+
const torrent = {
|
|
115
|
+
wires: [wire({ speed: 1 })],
|
|
116
|
+
_request() {
|
|
117
|
+
throw new Error("internal");
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
assert.throws(() => askFastestWiresFor(torrent, 5), /could not place a request for piece 5/);
|
|
121
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The margin and the buffer, checked against the session they were
|
|
3
|
+
* derived from.
|
|
4
|
+
*
|
|
5
|
+
* The figures in these tests are the field measurements of 2026-08-17, so a
|
|
6
|
+
* change that breaks the arithmetic fails against reality rather than against
|
|
7
|
+
* an example someone invented.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
|
|
13
|
+
import { minimumBufferFrom, requiredSpeedFrom } from "../services/supply-margin.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Waits spaced by `intervalSec`, each lasting `waitSec`.
|
|
17
|
+
*
|
|
18
|
+
* @param {number} count
|
|
19
|
+
* @param {number} intervalSec
|
|
20
|
+
* @param {number} waitSec
|
|
21
|
+
* @returns {Array<{ waitedMs: number, at: number }>}
|
|
22
|
+
*/
|
|
23
|
+
function evenlySpaced(count, intervalSec, waitSec) {
|
|
24
|
+
const waits = [];
|
|
25
|
+
for (let index = 0; index < count; index += 1) {
|
|
26
|
+
waits.push({ waitedMs: waitSec * 1000, at: 1_000_000 + index * intervalSec * 1000 });
|
|
27
|
+
}
|
|
28
|
+
return waits;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("the margin is what the supply's own interruptions demand", () => {
|
|
32
|
+
// The field torrent: a wait every 2.22 s, the worst of them 3.16 s.
|
|
33
|
+
const waits = evenlySpaced(10, 2.22, 1.49);
|
|
34
|
+
waits[4].waitedMs = 3160;
|
|
35
|
+
|
|
36
|
+
const answer = requiredSpeedFrom(waits);
|
|
37
|
+
|
|
38
|
+
assert.ok(answer);
|
|
39
|
+
assert.equal(answer.worstWaitSec, 3.16);
|
|
40
|
+
assert.equal(answer.medianIntervalSec, 2.22);
|
|
41
|
+
// 1 + 3.16 / 2.22 = 2.42. The step that was admitted by the hand-chosen 1.5
|
|
42
|
+
// ran at 1.05x and stalled; this is the bar it should have been held to.
|
|
43
|
+
assert.ok(Math.abs(answer.requiredSpeed - 2.4234) < 0.001, `got ${answer.requiredSpeed}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a copy at 8x clears its own supply with room to spare", () => {
|
|
47
|
+
// The same file's copied stream: waits up to 4.82 s, and it never stalled.
|
|
48
|
+
const waits = evenlySpaced(6, 15.5, 4.82);
|
|
49
|
+
const answer = requiredSpeedFrom(waits);
|
|
50
|
+
assert.ok(answer);
|
|
51
|
+
// 1 + 4.82/15.5 = 1.31, against 8x measured. Which is why a copy is the step
|
|
52
|
+
// a stranded viewer is always able to return to.
|
|
53
|
+
assert.ok(answer.requiredSpeed < 1.35, `got ${answer.requiredSpeed}`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("with too little evidence it says so instead of inventing a number", () => {
|
|
57
|
+
assert.equal(requiredSpeedFrom([]), null);
|
|
58
|
+
assert.equal(requiredSpeedFrom([{ waitedMs: 1500, at: 1 }]), null, "one wait shows no interval");
|
|
59
|
+
assert.equal(requiredSpeedFrom(null), null);
|
|
60
|
+
// Every wait at the same instant: no interval was observed, so no interval
|
|
61
|
+
// may be stated.
|
|
62
|
+
assert.equal(
|
|
63
|
+
requiredSpeedFrom([
|
|
64
|
+
{ waitedMs: 1000, at: 5 },
|
|
65
|
+
{ waitedMs: 1000, at: 5 }
|
|
66
|
+
]),
|
|
67
|
+
null
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("readings that are not measurements are ignored, not averaged in", () => {
|
|
72
|
+
const answer = requiredSpeedFrom([
|
|
73
|
+
{ waitedMs: 0, at: 1000 },
|
|
74
|
+
{ waitedMs: Number.NaN, at: 2000 },
|
|
75
|
+
{ waitedMs: 1000, at: 3000 },
|
|
76
|
+
{ waitedMs: 2000, at: 5000 },
|
|
77
|
+
{ waitedMs: 1500, at: 7000 }
|
|
78
|
+
]);
|
|
79
|
+
assert.ok(answer);
|
|
80
|
+
assert.equal(answer.samples, 3, "only the three real waits count");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the buffer is one segment plus the worst interruption, and names which", () => {
|
|
84
|
+
const answer = minimumBufferFrom({
|
|
85
|
+
segmentSeconds: 4,
|
|
86
|
+
worstSupplyWaitSec: 3.16,
|
|
87
|
+
worstProductionGapSec: 1.2,
|
|
88
|
+
worstTransferSec: 0.066
|
|
89
|
+
});
|
|
90
|
+
assert.ok(answer);
|
|
91
|
+
assert.equal(answer.seconds, 7.16, "7.16 s against the 25 s that was chosen by hand");
|
|
92
|
+
assert.equal(answer.from, "supply");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a step that cannot keep up sets the buffer itself", () => {
|
|
96
|
+
const answer = minimumBufferFrom({
|
|
97
|
+
segmentSeconds: 4,
|
|
98
|
+
worstSupplyWaitSec: 1.4,
|
|
99
|
+
worstProductionGapSec: 6.5,
|
|
100
|
+
worstTransferSec: 0.05
|
|
101
|
+
});
|
|
102
|
+
assert.equal(answer.seconds, 10.5);
|
|
103
|
+
assert.equal(answer.from, "production", "the encoder, not the swarm, is what the viewer is waiting for");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a term with nothing observed contributes nothing", () => {
|
|
107
|
+
const answer = minimumBufferFrom({ segmentSeconds: 4 });
|
|
108
|
+
assert.equal(answer.seconds, 4, "one whole segment is the floor: the one being played");
|
|
109
|
+
assert.equal(answer.from, "none");
|
|
110
|
+
assert.equal(minimumBufferFrom({}), null, "without a segment duration nothing can be said");
|
|
111
|
+
assert.equal(minimumBufferFrom({ segmentSeconds: 0 }), null);
|
|
112
|
+
});
|