@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.
@@ -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", () => resolve(Buffer.concat(chunks)));
83
- stream.on("error", () => resolve(null));
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
- if (state.plan !== null) {
116
- return state.plan;
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
- .map((track) => `${track.trackNumber}:${track.language || "?"}${track.name ? `/${track.name}` : ""}` +
165
- `(${track.clusterPositions.length} indexed)`)
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 = byFile.get(key);
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 `plan.tracks` the same
347
- * indexing `/api/subtitles?trackIndex=` and the browser's menu use, NOT the
348
- * container's own track number, which the browser never sees.
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 = byFile.get(key);
473
+ const state = stateFor(key);
354
474
  const fresh = [];
355
475
  const tracks = plan?.tracks ?? [];
356
- for (let trackIndex = 0; trackIndex < tracks.length; trackIndex += 1) {
357
- const track = tracks[trackIndex];
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
- trackIndex,
368
- cues: finalizeCues(newCues, held.track?.codecId ?? track.codecId),
369
- language: held.track?.language ?? ""
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
- byFile.delete(key);
606
+ forgetOne(key);
475
607
  }
476
608
  }
477
609
  return;
478
610
  }
479
- byFile.delete(`${sourceKey}:${fileIndex}`);
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) found, posting to main thread`
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, MISSES_FOR_VERDICT, 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
- return {
15
- seq: 100,
16
- seen,
17
- labels: ALL,
18
- echoes: 5,
19
- echoAgeMs: 400,
20
- ...overrides
21
- };
22
- }
23
-
24
- test("every channel current reads as flowing", () => {
25
- const { verdict } = readProbeState(state({ proxy: 100, "proxy-control": 99, "proxy-fast": 100 }));
26
- assert.equal(verdict, "flowing");
27
- });
28
-
29
- test("a lag shorter than the verdict window is still flowing", () => {
30
- const behind = 100 - (MISSES_FOR_VERDICT - 1);
31
- const { verdict } = readProbeState(
32
- state({ proxy: behind, "proxy-control": behind, "proxy-fast": 100 })
33
- );
34
- assert.equal(verdict, "flowing");
35
- });
36
-
37
- test("ordered channels behind while the unordered one keeps up names a stuck stream", () => {
38
- const { verdict, detail } = readProbeState(
39
- state({ proxy: 40, "proxy-control": 41, "proxy-fast": 100 })
40
- );
41
- assert.equal(verdict, "stream-stuck");
42
- // The numbers that produced the verdict must be in the line beside it.
43
- assert.match(detail, /proxy=40\(gap 60\)/);
44
- });
45
-
46
- test("every channel behind names the association", () => {
47
- const { verdict } = readProbeState(
48
- state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 })
49
- );
50
- assert.equal(verdict, "association-stopped");
51
- });
52
-
53
- test("without the unordered channel the verdict says it cannot compare", () => {
54
- const { verdict } = readProbeState(
55
- state({ proxy: 40, "proxy-control": 41 }, { labels: ORDERED })
56
- );
57
- assert.equal(verdict, "ordered-behind-no-comparison");
58
- });
59
-
60
- test("a stale echo means the reverse direction went too", () => {
61
- const { verdict } = readProbeState(
62
- state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { echoAgeMs: 30_000 })
63
- );
64
- assert.equal(verdict, "reverse-direction-gone");
65
- });
66
-
67
- test("before the first echo nothing is claimed", () => {
68
- const { verdict } = readProbeState(state({}, { echoes: 0, echoAgeMs: null }));
69
- assert.equal(verdict, "no-echo-yet");
70
- });
71
-
72
- test("a channel that has never reported counts as behind, not as unknown", () => {
73
- const { verdict, detail } = readProbeState(
74
- state({ "proxy-fast": 100 })
75
- );
76
- assert.equal(verdict, "stream-stuck");
77
- assert.match(detail, /proxy=\?\(gap \?\)/);
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
+ });