@torrent-tv/proxy 2.55.14 → 2.57.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 +1169 -1150
- package/bin/cli.js +494 -478
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +13 -4
- package/services/container-index/matroska-subtitles.js +6 -0
- package/services/container-index/mp4-subtitles.js +25 -1
- package/services/data-channel-handler.js +1133 -949
- package/services/delivery-probe.js +338 -255
- package/services/packet-witness.js +784 -406
- package/services/torrent-worker/client.js +2 -1
- package/services/torrent-worker/subtitle-cues.js +182 -21
- package/services/torrent-worker/worker.js +28 -2
- package/test/delivery-probe.test.js +124 -78
- package/test/packet-witness-ring.test.js +236 -0
- package/test/packet-witness.test.js +148 -120
- package/test/subtitle-track-numbering.test.js +262 -0
- package/test/wedge-certainty.test.js +131 -0
|
@@ -194,7 +194,8 @@ export class TorrentWorkerClient {
|
|
|
194
194
|
fileIndex: message.fileIndex,
|
|
195
195
|
trackIndex: message.trackIndex,
|
|
196
196
|
cues: message.cues,
|
|
197
|
-
language: message.language
|
|
197
|
+
language: message.language,
|
|
198
|
+
cursor: message.cursor
|
|
198
199
|
});
|
|
199
200
|
break;
|
|
200
201
|
default:
|
|
@@ -30,6 +30,8 @@ const CLUSTER_HEADER_PROBE = 64;
|
|
|
30
30
|
* reading it would be a large read for nothing.
|
|
31
31
|
*/
|
|
32
32
|
const MAX_CLUSTER_BYTES = 32 * 1024 * 1024;
|
|
33
|
+
/** How long a read of already-held bytes may take before it is given up. */
|
|
34
|
+
const READ_ABANDON_MS = 30_000;
|
|
33
35
|
|
|
34
36
|
/** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
|
|
35
37
|
const byFile = new Map();
|
|
@@ -78,9 +80,38 @@ function readHeld(file, start, end) {
|
|
|
78
80
|
resolve(null);
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
83
|
+
let settled = false;
|
|
84
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
85
|
+
let abandon = null;
|
|
86
|
+
const settle = (value) => {
|
|
87
|
+
if (settled) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
settled = true;
|
|
91
|
+
if (abandon !== null) {
|
|
92
|
+
clearTimeout(abandon);
|
|
93
|
+
}
|
|
94
|
+
if (value === null) {
|
|
95
|
+
stream.destroy?.();
|
|
96
|
+
}
|
|
97
|
+
resolve(value);
|
|
98
|
+
};
|
|
99
|
+
// A read of bytes the torrent already holds either answers or it does not.
|
|
100
|
+
// This is not a measurement of anything and no figure is derived from it:
|
|
101
|
+
// it is the point past which such a read is presumed lost, so that one
|
|
102
|
+
// stream which never ends cannot hold this file's walk — and with it the
|
|
103
|
+
// browser's own request for its subtitles — for the rest of the session.
|
|
104
|
+
abandon = setTimeout(() => {
|
|
105
|
+
logger.info(
|
|
106
|
+
`subtitles: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
|
|
107
|
+
`did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
|
|
108
|
+
);
|
|
109
|
+
settle(null);
|
|
110
|
+
}, READ_ABANDON_MS);
|
|
111
|
+
abandon.unref?.();
|
|
81
112
|
stream.on("data", (chunk) => chunks.push(chunk));
|
|
82
|
-
stream.on("end", () =>
|
|
83
|
-
stream.on("error", () =>
|
|
113
|
+
stream.on("end", () => settle(Buffer.concat(chunks)));
|
|
114
|
+
stream.on("error", () => settle(null));
|
|
84
115
|
});
|
|
85
116
|
}
|
|
86
117
|
|
|
@@ -97,10 +128,41 @@ function readHeld(file, start, end) {
|
|
|
97
128
|
* @returns {Promise<object | null>}
|
|
98
129
|
*/
|
|
99
130
|
async function planFor(torrent, fileIndex, key) {
|
|
131
|
+
const state = stateFor(key);
|
|
132
|
+
if (state.plan !== null) {
|
|
133
|
+
return state.plan;
|
|
134
|
+
}
|
|
135
|
+
// The head and the Cues table are two reads that DO wait on the swarm, so two
|
|
136
|
+
// callers arriving together would both make them. One promise, awaited by
|
|
137
|
+
// whoever asks while it is in flight.
|
|
138
|
+
if (!state.planPromise) {
|
|
139
|
+
state.planPromise = readPlan(torrent, fileIndex, state).finally(() => {
|
|
140
|
+
state.planPromise = null;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return state.planPromise;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The state kept for one file, created on first use.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} key - `sourceKey:fileIndex`.
|
|
150
|
+
* @returns {object}
|
|
151
|
+
*/
|
|
152
|
+
function stateFor(key) {
|
|
100
153
|
let state = byFile.get(key);
|
|
154
|
+
// A state that has been forgotten is not handed out again, even in the moment
|
|
155
|
+
// between the call and the walk that was still running finishing.
|
|
156
|
+
if (state?.forgotten === true) {
|
|
157
|
+
state = undefined;
|
|
158
|
+
}
|
|
101
159
|
if (!state) {
|
|
102
160
|
state = {
|
|
103
161
|
plan: null,
|
|
162
|
+
planPromise: null,
|
|
163
|
+
forgotten: false,
|
|
164
|
+
// One walk of a file at a time — see `serialize`.
|
|
165
|
+
chain: Promise.resolve(),
|
|
104
166
|
harvested: new Map(),
|
|
105
167
|
cues: new Map(),
|
|
106
168
|
seq: new Map(),
|
|
@@ -112,9 +174,45 @@ async function planFor(torrent, fileIndex, key) {
|
|
|
112
174
|
};
|
|
113
175
|
byFile.set(key, state);
|
|
114
176
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
177
|
+
return state;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run `work` after every walk of this file already started, and before any
|
|
182
|
+
* started after it.
|
|
183
|
+
*
|
|
184
|
+
* Both entry points here — a browser's own pull and the warmup that runs ahead
|
|
185
|
+
* of it — mark a cluster as walked only AFTER reading and parsing it, which is
|
|
186
|
+
* two suspension points later. Until 2.56.0 nothing stopped a second call
|
|
187
|
+
* arriving in between: `warmActiveFiles` runs on every verified piece AND on a
|
|
188
|
+
* 3 s timer, so on a fast download the same cluster was read and parsed several
|
|
189
|
+
* times over and the same line could be pushed twice under different `seq`
|
|
190
|
+
* numbers. Each of those reads is a WebTorrent file stream, which selects and
|
|
191
|
+
* deselects its pieces, so the repetition reached the piece picker as well.
|
|
192
|
+
*
|
|
193
|
+
* @template T
|
|
194
|
+
* @param {object} state
|
|
195
|
+
* @param {() => Promise<T>} work
|
|
196
|
+
* @returns {Promise<T>}
|
|
197
|
+
*/
|
|
198
|
+
function serialize(state, work) {
|
|
199
|
+
const run = state.chain.then(work, work);
|
|
200
|
+
// The queue must survive a failed walk, so what is chained is the settled
|
|
201
|
+
// form; the caller still sees the rejection.
|
|
202
|
+
state.chain = run.then(() => undefined, () => undefined);
|
|
203
|
+
return run;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read one file's subtitle plan — the tracks it declares and where the clusters
|
|
208
|
+
* holding them are. Called once per file; see `planFor`.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} torrent
|
|
211
|
+
* @param {number} fileIndex
|
|
212
|
+
* @param {object} state
|
|
213
|
+
* @returns {Promise<object>}
|
|
214
|
+
*/
|
|
215
|
+
async function readPlan(torrent, fileIndex, state) {
|
|
118
216
|
const file = torrent?.files?.[fileIndex];
|
|
119
217
|
// `declared` is what the container itself says about its subtitle tracks, in
|
|
120
218
|
// its own order. Empty means the container said nothing — which is a real
|
|
@@ -139,6 +237,7 @@ async function planFor(torrent, fileIndex, key) {
|
|
|
139
237
|
...empty,
|
|
140
238
|
tracks: mp4.tracks.map((track, order) => ({
|
|
141
239
|
trackNumber: track.trackId,
|
|
240
|
+
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
142
241
|
codecId: track.format,
|
|
143
242
|
language: track.language,
|
|
144
243
|
name: "",
|
|
@@ -159,10 +258,13 @@ async function planFor(torrent, fileIndex, key) {
|
|
|
159
258
|
state.plan = plan ?? empty;
|
|
160
259
|
if (state.plan.tracks.length > 0) {
|
|
161
260
|
logger.info(
|
|
162
|
-
`subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s)
|
|
261
|
+
`subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
|
|
262
|
+
`of ${state.plan.declared.length} declared — ` +
|
|
163
263
|
state.plan.tracks
|
|
164
|
-
|
|
165
|
-
|
|
264
|
+
// `s:N` is the number the browser names (ffmpeg's own), and it differs
|
|
265
|
+
// from the file's track number whenever a picture track sits among them.
|
|
266
|
+
.map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
|
|
267
|
+
`${track.name ? `/${track.name}` : ""}(${track.clusterPositions.length} indexed)`)
|
|
166
268
|
.join(" ")
|
|
167
269
|
);
|
|
168
270
|
}
|
|
@@ -206,11 +308,27 @@ function nextSeq(state, trackNumber) {
|
|
|
206
308
|
export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
207
309
|
const key = `${sourceKey}:${fileIndex}`;
|
|
208
310
|
const plan = await planFor(torrent, fileIndex, key);
|
|
209
|
-
const state =
|
|
311
|
+
const state = stateFor(key);
|
|
210
312
|
const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
|
|
211
313
|
if (!track) {
|
|
212
314
|
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
213
315
|
}
|
|
316
|
+
return serialize(state, () => walkFor(torrent, fileIndex, state, plan, track, trackNumber));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
|
|
321
|
+
* one file to one walk at a time.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} torrent
|
|
324
|
+
* @param {number} fileIndex
|
|
325
|
+
* @param {object} state
|
|
326
|
+
* @param {object} plan
|
|
327
|
+
* @param {object} track
|
|
328
|
+
* @param {number} trackNumber
|
|
329
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
330
|
+
*/
|
|
331
|
+
async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
|
|
214
332
|
const file = torrent.files[fileIndex];
|
|
215
333
|
let harvested = state.harvested.get(trackNumber);
|
|
216
334
|
if (!harvested) {
|
|
@@ -343,18 +461,20 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
|
343
461
|
* @param {string} sourceKey
|
|
344
462
|
* @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
|
|
345
463
|
* One entry per track that gained at least one cue since the last call.
|
|
346
|
-
* `trackIndex` is the track's position among
|
|
347
|
-
*
|
|
348
|
-
* container's own track number,
|
|
464
|
+
* `trackIndex` is `declaredIndex` — the track's position among ALL the file's
|
|
465
|
+
* subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
|
|
466
|
+
* knows. NOT the container's own track number, and not the position among the
|
|
467
|
+
* readable tracks either: counting those alone puts every text track after a
|
|
468
|
+
* picture-based one in the wrong place.
|
|
349
469
|
*/
|
|
350
470
|
export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
|
|
351
471
|
const key = `${sourceKey}:${fileIndex}`;
|
|
352
472
|
const plan = await planFor(torrent, fileIndex, key);
|
|
353
|
-
const state =
|
|
473
|
+
const state = stateFor(key);
|
|
354
474
|
const fresh = [];
|
|
355
475
|
const tracks = plan?.tracks ?? [];
|
|
356
|
-
for (let
|
|
357
|
-
const track = tracks[
|
|
476
|
+
for (let order = 0; order < tracks.length; order += 1) {
|
|
477
|
+
const track = tracks[order];
|
|
358
478
|
const held = await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
|
|
359
479
|
const since = state.pushed.get(track.trackNumber) ?? 0;
|
|
360
480
|
const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
|
|
@@ -363,10 +483,21 @@ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
|
|
|
363
483
|
}
|
|
364
484
|
const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
|
|
365
485
|
state.pushed.set(track.trackNumber, highest);
|
|
486
|
+
const cues = finalizeCues(newCues, held.track?.codecId ?? track.codecId);
|
|
366
487
|
fresh.push({
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
488
|
+
// ffmpeg's own numbering, which is the only one the browser knows.
|
|
489
|
+
trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
490
|
+
cues,
|
|
491
|
+
language: held.track?.language ?? "",
|
|
492
|
+
// Where the browser should resume from if it has to ask again — after a
|
|
493
|
+
// reconnect, which loses the subscription these pushes ride on.
|
|
494
|
+
cursor: highest,
|
|
495
|
+
// What this batch is ABOUT, in film time, so a log can be read against
|
|
496
|
+
// the position being played.
|
|
497
|
+
spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
|
|
498
|
+
spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
|
|
499
|
+
walkedClusters: held.coveredClusters ?? 0,
|
|
500
|
+
indexedClusters: held.indexedClusters ?? 0
|
|
370
501
|
});
|
|
371
502
|
}
|
|
372
503
|
return fresh;
|
|
@@ -430,8 +561,9 @@ function assDialogueToText(raw) {
|
|
|
430
561
|
*/
|
|
431
562
|
export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
|
|
432
563
|
const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
|
|
433
|
-
return (plan?.tracks ?? []).map((track) => ({
|
|
564
|
+
return (plan?.tracks ?? []).map((track, order) => ({
|
|
434
565
|
trackNumber: track.trackNumber,
|
|
566
|
+
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
435
567
|
codecId: track.codecId,
|
|
436
568
|
language: track.language,
|
|
437
569
|
name: track.name,
|
|
@@ -471,10 +603,39 @@ export function forgetSubtitles(sourceKey, fileIndex) {
|
|
|
471
603
|
if (fileIndex === undefined) {
|
|
472
604
|
for (const key of [...byFile.keys()]) {
|
|
473
605
|
if (key.startsWith(`${sourceKey}:`)) {
|
|
474
|
-
|
|
606
|
+
forgetOne(key);
|
|
475
607
|
}
|
|
476
608
|
}
|
|
477
609
|
return;
|
|
478
610
|
}
|
|
479
|
-
|
|
611
|
+
forgetOne(`${sourceKey}:${fileIndex}`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Drop one file's state, but not while a walk of it is still running: the
|
|
616
|
+
* record of which clusters have been read lives in that state, and a walk left
|
|
617
|
+
* writing into a discarded copy while a new one starts beside it is the one
|
|
618
|
+
* path that defeats the serialization above.
|
|
619
|
+
*
|
|
620
|
+
* @param {string} key
|
|
621
|
+
* @returns {void}
|
|
622
|
+
*/
|
|
623
|
+
function forgetOne(key) {
|
|
624
|
+
const state = byFile.get(key);
|
|
625
|
+
if (!state) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
// Held, so that a walk started before this call is not left orphaned; the
|
|
629
|
+
// entry is dropped the moment the queue empties, and nothing is handed this
|
|
630
|
+
// state in the meantime.
|
|
631
|
+
state.forgotten = true;
|
|
632
|
+
void state.chain.then(() => {
|
|
633
|
+
if (byFile.get(key) === state) {
|
|
634
|
+
byFile.delete(key);
|
|
635
|
+
}
|
|
636
|
+
}, () => {
|
|
637
|
+
if (byFile.get(key) === state) {
|
|
638
|
+
byFile.delete(key);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
480
641
|
}
|
|
@@ -522,12 +522,27 @@ function warmActiveFiles(sourceKey, torrent) {
|
|
|
522
522
|
return;
|
|
523
523
|
}
|
|
524
524
|
for (const fileIndex of usage.keys()) {
|
|
525
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
526
|
+
// A trigger that arrives while the previous pass is still walking is
|
|
527
|
+
// dropped, not queued. `verified` fires per piece, so on a fast download
|
|
528
|
+
// these arrive many times a second; the walk is serialized per file anyway,
|
|
529
|
+
// and a queue of identical passes would only postpone the one that has
|
|
530
|
+
// something new to find.
|
|
531
|
+
if (warmupInFlight.has(key)) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
warmupInFlight.add(key);
|
|
525
535
|
warmSubtitleCues(torrent, fileIndex, sourceKey)
|
|
526
536
|
.then((fresh) => {
|
|
527
537
|
for (const entry of fresh) {
|
|
538
|
+
const span = entry.spanStartSeconds === null
|
|
539
|
+
? "empty"
|
|
540
|
+
: `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
|
|
528
541
|
log(
|
|
529
542
|
`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
|
|
530
|
-
`${entry.cues.length} new cue(s)
|
|
543
|
+
`${entry.cues.length} new cue(s) covering ${span}, ` +
|
|
544
|
+
`clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}, ` +
|
|
545
|
+
"posting to main thread"
|
|
531
546
|
);
|
|
532
547
|
parentPort.postMessage({
|
|
533
548
|
type: Event.SUBTITLE_CUES_READY,
|
|
@@ -535,16 +550,27 @@ function warmActiveFiles(sourceKey, torrent) {
|
|
|
535
550
|
fileIndex,
|
|
536
551
|
trackIndex: entry.trackIndex,
|
|
537
552
|
cues: entry.cues,
|
|
538
|
-
language: entry.language
|
|
553
|
+
language: entry.language,
|
|
554
|
+
cursor: entry.cursor
|
|
539
555
|
});
|
|
540
556
|
}
|
|
541
557
|
})
|
|
542
558
|
.catch((error) => {
|
|
543
559
|
log(`subtitle warmup ${sourceKey}:${fileIndex} failed: ${error instanceof Error ? error.message : error}`);
|
|
560
|
+
})
|
|
561
|
+
.finally(() => {
|
|
562
|
+
warmupInFlight.delete(key);
|
|
544
563
|
});
|
|
545
564
|
}
|
|
546
565
|
}
|
|
547
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Files whose warmup pass has not finished yet, by `sourceKey:fileIndex`.
|
|
569
|
+
*
|
|
570
|
+
* @type {Set<string>}
|
|
571
|
+
*/
|
|
572
|
+
const warmupInFlight = new Set();
|
|
573
|
+
|
|
548
574
|
/**
|
|
549
575
|
* Torrents already wired to warm their subtitle cues the moment a piece
|
|
550
576
|
* verifies, so the same torrent is not listened to twice.
|
|
@@ -1,78 +1,124 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
|
|
4
|
-
import { readProbeState,
|
|
5
|
-
|
|
6
|
-
const ORDERED = ["proxy", "proxy-control"];
|
|
7
|
-
const ALL = [...ORDERED, UNRELIABLE_LABEL];
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @param {Record<string, number>} seen
|
|
11
|
-
* @param {object} [overrides]
|
|
12
|
-
*/
|
|
13
|
-
function state(seen, overrides = {}) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
test("
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
assert.equal(verdict, "
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("
|
|
73
|
-
const { verdict
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { allowedGap, readProbeState, PROBE_INTERVAL_MS, UNRELIABLE_LABEL } from "../services/delivery-probe.js";
|
|
5
|
+
|
|
6
|
+
const ORDERED = ["proxy", "proxy-control"];
|
|
7
|
+
const ALL = [...ORDERED, UNRELIABLE_LABEL];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Record<string, number>} seen
|
|
11
|
+
* @param {object} [overrides]
|
|
12
|
+
*/
|
|
13
|
+
function state(seen, overrides = {}) {
|
|
14
|
+
const allowed = {};
|
|
15
|
+
for (const label of overrides.labels ?? ALL) {
|
|
16
|
+
allowed[label] = 3;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
seq: 100,
|
|
20
|
+
seen,
|
|
21
|
+
labels: ALL,
|
|
22
|
+
echoes: 5,
|
|
23
|
+
echoAgeMs: 400,
|
|
24
|
+
allowed,
|
|
25
|
+
...overrides
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("every channel current reads as flowing", () => {
|
|
30
|
+
const { verdict } = readProbeState(state({ proxy: 100, "proxy-control": 99, "proxy-fast": 100 }));
|
|
31
|
+
assert.equal(verdict, "flowing");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a lag shorter than the verdict window is still flowing", () => {
|
|
35
|
+
const behind = 100 - 3;
|
|
36
|
+
const { verdict } = readProbeState(
|
|
37
|
+
state({ proxy: behind, "proxy-control": behind, "proxy-fast": 100 })
|
|
38
|
+
);
|
|
39
|
+
assert.equal(verdict, "flowing");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("ordered channels behind while the unordered one keeps up names a stuck stream", () => {
|
|
43
|
+
const { verdict, detail } = readProbeState(
|
|
44
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 100 })
|
|
45
|
+
);
|
|
46
|
+
assert.equal(verdict, "stream-stuck");
|
|
47
|
+
// The numbers that produced the verdict must be in the line beside it.
|
|
48
|
+
assert.match(detail, /proxy=40\(gap 60 of 3\)/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("every channel behind names the association", () => {
|
|
52
|
+
const { verdict } = readProbeState(
|
|
53
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 })
|
|
54
|
+
);
|
|
55
|
+
assert.equal(verdict, "association-stopped");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("without the unordered channel the verdict says it cannot compare", () => {
|
|
59
|
+
const { verdict } = readProbeState(
|
|
60
|
+
state({ proxy: 40, "proxy-control": 41 }, { labels: ORDERED })
|
|
61
|
+
);
|
|
62
|
+
assert.equal(verdict, "ordered-behind-no-comparison");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a stale echo means the reverse direction went too", () => {
|
|
66
|
+
const { verdict } = readProbeState(
|
|
67
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { echoAgeMs: 30_000 })
|
|
68
|
+
);
|
|
69
|
+
assert.equal(verdict, "reverse-direction-gone");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("before the first echo nothing is claimed", () => {
|
|
73
|
+
const { verdict } = readProbeState(state({}, { echoes: 0, echoAgeMs: null }));
|
|
74
|
+
assert.equal(verdict, "no-echo-yet");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a channel that has never reported counts as behind, not as unknown", () => {
|
|
78
|
+
const { verdict, detail } = readProbeState(
|
|
79
|
+
state({ "proxy-fast": 100 })
|
|
80
|
+
);
|
|
81
|
+
assert.equal(verdict, "stream-stuck");
|
|
82
|
+
assert.match(detail, /proxy=\?\(gap \? of 3\)/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the allowance is the queue's own drain time, not a chosen number", () => {
|
|
86
|
+
// 8 MB queued at 8 MB/s is one second of draining; probes go twice a second,
|
|
87
|
+
// so two of them may legitimately be outstanding, plus the round trip.
|
|
88
|
+
assert.equal(
|
|
89
|
+
allowedGap({ queuedBytes: 8 * 1024 * 1024, bytesPerSecond: 8 * 1024 * 1024, rttMs: 0 }),
|
|
90
|
+
Math.ceil(1000 / PROBE_INTERVAL_MS)
|
|
91
|
+
);
|
|
92
|
+
// An empty queue still allows the one probe that is always in flight.
|
|
93
|
+
assert.equal(allowedGap({ queuedBytes: 0, bytesPerSecond: 8 * 1024 * 1024, rttMs: 0 }), 1);
|
|
94
|
+
// The round trip counts: the echo has to come back too.
|
|
95
|
+
assert.ok(
|
|
96
|
+
allowedGap({ queuedBytes: 0, bytesPerSecond: 1024, rttMs: 2000 }) >
|
|
97
|
+
allowedGap({ queuedBytes: 0, bytesPerSecond: 1024, rttMs: 0 })
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("with no rate measured nothing is claimed", () => {
|
|
102
|
+
assert.equal(allowedGap({ queuedBytes: 1024, bytesPerSecond: 0, rttMs: 10 }), null);
|
|
103
|
+
const { verdict } = readProbeState(
|
|
104
|
+
state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { allowed: {} })
|
|
105
|
+
);
|
|
106
|
+
assert.equal(verdict, "no-rate-yet");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a burst big enough to explain the lag is not called a stopped association", () => {
|
|
110
|
+
// The 2026-08-26 false positive: all three channels at gap 7 while 150 Mbps
|
|
111
|
+
// crossed the association. 64 MB queued at 18 MB/s is three and a half
|
|
112
|
+
// seconds of draining, which is seven probe intervals.
|
|
113
|
+
const allowance = allowedGap({
|
|
114
|
+
queuedBytes: 64 * 1024 * 1024,
|
|
115
|
+
bytesPerSecond: 18 * 1024 * 1024,
|
|
116
|
+
rttMs: 16
|
|
117
|
+
});
|
|
118
|
+
assert.ok(allowance >= 7);
|
|
119
|
+
const allowed = Object.fromEntries(ALL.map((label) => [label, allowance]));
|
|
120
|
+
const { verdict } = readProbeState(
|
|
121
|
+
state({ proxy: 93, "proxy-control": 93, "proxy-fast": 93 }, { allowed })
|
|
122
|
+
);
|
|
123
|
+
assert.equal(verdict, "flowing");
|
|
124
|
+
});
|