@torrent-tv/proxy 2.30.1 → 2.31.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 CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.31.0
2
+
3
+ - **Chore**: The encoder run's two status strings are gone; both are now outputs of the state table shipped in 2.23.0. `session.progress.state` was maintained by hand at seven sites and `session.state` at nine, and neither could answer on its own — the warm-up test had to read both under an `||`, because one said "starting" from the first spawn until something overwrote it while the other said it again on its own schedule. What the browser is told is computed where it is sent (`wireState(runState)`), and `session.state` is reduced to the session's own lifetime: it exists, or it has been disposed. That deletes the line in the spawn path that read `state === "disposed" ? "disposed" : "starting"` — two lifetimes in one variable, which is what it was there to paper over. Verified before the change that nothing in the browser reads the wire string, so the value set is unchanged and unobserved either way; the four values it can take are the same four as before.
4
+
5
+ ## 2.30.2
6
+
7
+ - **Fix**: The cut-time shift of 2.28.0 is reverted — the field measured it and it moved the cuts OFF the source's keyframes rather than onto them. Of 75 pieces the picture produced afterwards, only **nine** began at a time the container's own table names, against **70 of 75** for the soundtrack, which the change never touched; the median distance from the playlist went from 0.04 s to 4.33 s. Before it, every piece began exactly on a named keyframe and it was the playlist that disagreed with them — which is the correction path's business, not the cut list's. The reasoning that produced the shift (that the muxer decides its cuts before the output is relabelled) was argued from ffmpeg's semantics rather than measured, and the measurement says otherwise.
8
+ - **Fix**: The steering line compared two different things. `steered onto N of M holders` summed the successes over every attempt of a wait while taking M from the last attempt alone, which is how the log came to read `steered onto 12 of 6 holders`. Both halves are now totals over the same attempts: `steered onto N of M asks (K peers held it)`.
9
+
1
10
  ## 2.30.1
2
11
 
3
12
  - **Fix**: A seek was undone a second after it was made. Measured 2026-08-17: the viewer jumped to 2083.4 s, both runs restarted at segment #373 — correctly — and then a request for #371, issued by the player BEFORE the jump and reissued a second later, dragged the encoder back to #370. The viewer sat at #374 waiting for it to return. Two things let that happen, and both are fixed. The behind-head repair refuses a request that is behind the position the VIEWER themselves reported: its existing guard only holds while a seek is still settling, which by then it was not. And a segment request may no longer move the recorded viewer position BACKWARDS past a reported seek — playback only ever moves forward from one, so nothing legitimate is lost, while a stale request can no longer rewrite the viewer's own statement, which is how the repair came to believe it. A reported seek is the viewer stating where they are; a request is evidence about where the player is reading, and evidence may refine a statement forward, never contradict it backwards. Pinned by `test/stale-request-after-seek.test.js`, whose control case shows the same traffic still repairing a genuinely misplaced run when the viewer has said nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.30.1",
3
+ "version": "2.31.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -25,7 +25,8 @@ import {
25
25
  ENCODE_RUN_STATE,
26
26
  INITIAL_RUN_STATE,
27
27
  nextState,
28
- processCanBeSignalled
28
+ processCanBeSignalled,
29
+ wireState
29
30
  } from "./encode-run-state.js";
30
31
  import { ENCODE_EXIT, classifyEncodeExit } from "./encode-exit.js";
31
32
 
@@ -1854,17 +1855,23 @@ export class HlsSessionManager {
1854
1855
  sourceMapKey,
1855
1856
  fileName: logName,
1856
1857
  dirPath: sessionDir,
1857
- state: "starting",
1858
+ // The SESSION's own lifetime, and nothing else: it exists, or it has been
1859
+ // disposed. It used to carry the encoder run's status as well, which is
1860
+ // why one line in the spawn path read `state === "disposed" ? "disposed"
1861
+ // : "starting"` — two lifetimes in one variable. The run's status lives
1862
+ // in `runState`.
1863
+ state: "live",
1858
1864
  startedAt: Date.now(),
1859
1865
  lastAccessedAt: Date.now(),
1860
1866
  ffmpeg: null,
1861
1867
  encodeRunGeneration: 0,
1862
1868
  // What the ENCODER RUN is doing, as one control state from the table in
1863
- // `encode-run-state.js`. Written at every event today and read by nothing
1864
- // yet: a refused pair in the log is the model disagreeing with reality,
1865
- // and that disagreement is the measurement this release exists to take.
1866
- // The fields it replaces`state`, `progress.state`
1867
- // and the repeated liveness checks keep their current writes meanwhile.
1869
+ // `encode-run-state.js`. Every question about the run is now answered
1870
+ // from here: whether a process can be signalled, whether anything is
1871
+ // reading the input, what a missing segment is answered with, and what
1872
+ // the browser is told. The three representations it replaceda status
1873
+ // string, a second status string on the wire, and a child-process handle
1874
+ // consulted at ten sites — could disagree with each other, and did.
1868
1875
  runState: INITIAL_RUN_STATE,
1869
1876
  lastError: "",
1870
1877
  // Cold-start timing: entry timestamp + a once-guard so the first servable
@@ -2007,7 +2014,10 @@ export class HlsSessionManager {
2007
2014
  seekFailureTarget: -1,
2008
2015
  seekFailureCount: 0,
2009
2016
  progress: {
2010
- state: "starting",
2017
+ // No `state` here. What the browser is told is `wireState(runState)`,
2018
+ // computed where it is sent — a Moore output rather than a field
2019
+ // maintained by hand at seven sites, which is how it came to be read
2020
+ // together with `session.state` under an `||`.
2011
2021
  processedSeconds: 0,
2012
2022
  startPositionSeconds: 0,
2013
2023
  totalSeconds: hasDuration ? durationSeconds : null,
@@ -2099,7 +2109,7 @@ export class HlsSessionManager {
2099
2109
  await this.waitUntilReady(session);
2100
2110
  return session;
2101
2111
  } catch (error) {
2102
- if (session.state === "failed") {
2112
+ if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
2103
2113
  await this.disposeSession(session.id);
2104
2114
  throw error;
2105
2115
  }
@@ -3253,7 +3263,7 @@ export class HlsSessionManager {
3253
3263
  if (
3254
3264
  !session ||
3255
3265
  session.state === "disposed" ||
3256
- session.state === "failed" ||
3266
+ session.runState === ENCODE_RUN_STATE.ENDED_FAILED ||
3257
3267
  !session.transcodeVideo ||
3258
3268
  // Nothing is encoding, so there is no speed to judge. A variant the
3259
3269
  // viewer has switched away from is left in exactly this state, and its
@@ -3567,30 +3577,22 @@ export class HlsSessionManager {
3567
3577
  const gridCutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
3568
3578
  ? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
3569
3579
  : null;
3570
- // On the COPY branch the muxer decides its cuts against the source's own
3571
- // timestamps, not against the labels we ask it to write. That branch keeps
3572
- // the source's timestamps (`-copyts`) and re-labels the output 0-based with
3573
- // `-output_ts_offset -sourceStartTime`; the cut list, being applied before
3574
- // that relabelling, must therefore be stated in the SOURCE's terms.
3580
+ // Cut times are stated on the grid, for both branches.
3575
3581
  //
3576
- // Measured 2026-08-17, and this is the whole of the trouble: asked to cut
3577
- // at 808.808 s on the 0-based grid, ffmpeg cut at 806.806 s — exactly
3578
- // `sourceStartTime` (2.002 s) early, and 806.806 s is itself a keyframe the
3579
- // container's table names, which is why every "disagreement" landed on
3580
- // another real keyframe. The soundtrack, which is re-encoded and takes the
3581
- // other branch, cut where it was asked. The two then told the shared
3582
- // boundary table different things and corrected each other back and forth
3583
- // for the whole session (#202: 808.808 806.806 808.750 → …), so the
3584
- // playlist and the media drifted apart by a whole segment and the player
3585
- // refetched what it could not place.
3582
+ // 2.28.0 added `sourceStartTime` to them on the copy branch, reasoning that
3583
+ // the muxer decides its cuts before the output is relabelled. The field
3584
+ // measured it the next session and the reasoning was wrong: of 75 pieces
3585
+ // the picture produced, only NINE began at a time the container's own
3586
+ // keyframe table names (the soundtrack, untouched by the change, scored 70
3587
+ // of 75). Before it, every piece began exactly on a named keyframe and it
3588
+ // was the PLAYLIST that disagreed with them. So the shift moved the cuts
3589
+ // OFF the keyframes rather than onto them, and it is gone.
3586
3590
  //
3587
- // Nothing here is a guess about ffmpeg's semantics: the shift is the same
3588
- // one the seek already applies on this branch (`seekSeconds = startSeconds
3589
- // + sourceStartTime`), and the field measurement above is what says the
3590
- // cuts needed it too.
3591
- const cutTimes = gridCutTimes && onKeyframeGridFor(session) && sourceStartTime !== 0
3592
- ? gridCutTimes.map((time) => Number((time + sourceStartTime).toFixed(6)))
3593
- : gridCutTimes;
3591
+ // What remains true, and is what that measurement is really about: the
3592
+ // picture cuts where the source's keyframes are, and the playlist must be
3593
+ // built from those same times. That is the correction path's job, not the
3594
+ // cut list's.
3595
+ const cutTimes = gridCutTimes;
3594
3596
 
3595
3597
  // A second chance for a predecessor that survived the escalation above —
3596
3598
  // the first block is the one that does the work. Its exit is ignored
@@ -3820,8 +3822,6 @@ export class HlsSessionManager {
3820
3822
  session.encodeStartIndex = safeIndex;
3821
3823
  session.pendingRestartIndex = -1;
3822
3824
  session.lastRestartAt = Date.now();
3823
- session.state = session.state === "disposed" ? "disposed" : "starting";
3824
- session.progress.state = "running";
3825
3825
  session.progress.processedSeconds = startSeconds;
3826
3826
  session.progress.startPositionSeconds = startSeconds;
3827
3827
  session.progress.updatedAt = Date.now();
@@ -3920,7 +3920,6 @@ export class HlsSessionManager {
3920
3920
  } else if (key === "speed") {
3921
3921
  session.progress.speed = value;
3922
3922
  } else if (key === "progress") {
3923
- session.progress.state = value === "end" ? "ready" : "running";
3924
3923
  }
3925
3924
  const metrics = computeProgressMetrics(
3926
3925
  session.progress.processedSeconds,
@@ -3956,9 +3955,7 @@ export class HlsSessionManager {
3956
3955
  if (!this.#isCurrentRun(session, ffmpeg)) {
3957
3956
  return;
3958
3957
  }
3959
- session.state = "failed";
3960
3958
  session.lastError = error instanceof Error ? error.message : String(error);
3961
- session.progress.state = "failed";
3962
3959
  session.progress.updatedAt = Date.now();
3963
3960
  this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_FAILED);
3964
3961
  logger.error(`ffmpeg ${session.id} process error: ${session.lastError}`);
@@ -3997,9 +3994,7 @@ export class HlsSessionManager {
3997
3994
  // segment nobody was making. So the claim is checked against the
3998
3995
  // playlist we published, and a run that stopped short is a FAILURE that
3999
3996
  // can be restarted, not a finished file.
4000
- session.state = "failed";
4001
- session.progress.state = "failed";
4002
- session.progress.updatedAt = Date.now();
3997
+ session.progress.updatedAt = Date.now();
4003
3998
  session.lastError =
4004
3999
  `input ended after segment #${producedThrough} of ${expectedLast} — ` +
4005
4000
  "the source stopped delivering data";
@@ -4011,8 +4006,6 @@ export class HlsSessionManager {
4011
4006
  return;
4012
4007
  }
4013
4008
  if (outcome === ENCODE_EXIT.COMPLETE) {
4014
- session.state = "ready";
4015
- session.progress.state = "ready";
4016
4009
  session.progress.updatedAt = Date.now();
4017
4010
  this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_COMPLETE);
4018
4011
  logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
@@ -4074,11 +4067,9 @@ export class HlsSessionManager {
4074
4067
  // stays for what it was built for, a target that genuinely cannot be
4075
4068
  // encoded; it must not condemn a session whose data merely went away.
4076
4069
  if (outcome === ENCODE_EXIT.INPUT_LOST) {
4077
- session.state = "recovering";
4078
4070
  // On the wire it is simply "not ready yet" — a state the browser has
4079
4071
  // always known how to wait through. Only the proxy needs the
4080
4072
  // distinction between waiting for data and having given up.
4081
- session.progress.state = "starting";
4082
4073
  session.progress.updatedAt = Date.now();
4083
4074
  this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_INPUT_LOST);
4084
4075
  session.inputRetryCount = (session.inputRetryCount ?? 0) + 1;
@@ -4093,7 +4084,7 @@ export class HlsSessionManager {
4093
4084
  );
4094
4085
  session.inputRetryTimer = setTimeout(() => {
4095
4086
  session.inputRetryTimer = null;
4096
- if (session.state !== "recovering") {
4087
+ if (session.runState !== ENCODE_RUN_STATE.RETRY_WAIT) {
4097
4088
  return;
4098
4089
  }
4099
4090
  this.#transitionRun(session, ENCODE_RUN_EVENT.RETRY_DUE);
@@ -4105,8 +4096,6 @@ export class HlsSessionManager {
4105
4096
  session.inputRetryTimer.unref?.();
4106
4097
  return;
4107
4098
  }
4108
- session.state = "failed";
4109
- session.progress.state = "failed";
4110
4099
  session.progress.updatedAt = Date.now();
4111
4100
  this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_FAILED);
4112
4101
  logger.error(
@@ -4587,10 +4576,9 @@ export class HlsSessionManager {
4587
4576
  // Individual segments are long-polled by the segment route as ffmpeg
4588
4577
  // produces them.
4589
4578
  if (session.useSyntheticPlaylist) {
4590
- if (session.state === "failed") {
4579
+ if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
4591
4580
  throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
4592
4581
  }
4593
- session.state = "ready";
4594
4582
  return;
4595
4583
  }
4596
4584
 
@@ -4598,15 +4586,14 @@ export class HlsSessionManager {
4598
4586
  const deadline = Date.now() + this.startupWaitMs;
4599
4587
 
4600
4588
  while (Date.now() < deadline) {
4601
- if (session.state === "failed") {
4589
+ if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
4602
4590
  throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
4603
4591
  }
4604
4592
  try {
4605
4593
  await access(playlistPath);
4606
4594
  const text = await readFile(playlistPath, "utf8");
4607
4595
  if (text.includes("#EXTM3U")) {
4608
- session.state = "ready";
4609
- return;
4596
+ return;
4610
4597
  }
4611
4598
  } catch (_error) {
4612
4599
  // Playlist is not ready yet.
@@ -5122,7 +5109,14 @@ export class HlsSessionManager {
5122
5109
  const median = deviations.length > 0 ? deviations[Math.floor(deviations.length / 2)] : 0;
5123
5110
  const landed = check.landedOnAnotherKeyframe ?? 0;
5124
5111
  logger.info(
5125
- `keyframe-index ${session.containerFormat || "unknown"} "${session.fileName}": ` +
5112
+ // The session id, because without it this line cannot be attributed. A
5113
+ // family produces one summary per member — the picture and each
5114
+ // soundtrack — and on 2026-08-17 the picture's was read as the sound's,
5115
+ // from a neighbouring log line, and a roadmap item was written against
5116
+ // the wrong half of the stream. The id is the only thing that says whose
5117
+ // reading this is.
5118
+ `keyframe-index ${session.id.slice(0, 8)} ${session.audioOnly === true ? "sound" : "picture"} ` +
5119
+ `${session.containerFormat || "unknown"} "${session.fileName}": ` +
5126
5120
  `${check.disagreed} of ${check.checked} produced segments started away from the playlist, ` +
5127
5121
  `median ${median.toFixed(3)}s worst ${check.maxDeviationSec.toFixed(3)}s` +
5128
5122
  (check.firstDisagreementIndex >= 0 ? ` (first at #${check.firstDisagreementIndex})` : "") +
@@ -5382,7 +5376,7 @@ export class HlsSessionManager {
5382
5376
  if (
5383
5377
  !session ||
5384
5378
  session.state === "disposed" ||
5385
- session.state === "failed" ||
5379
+ session.runState === ENCODE_RUN_STATE.ENDED_FAILED ||
5386
5380
  !session.ffmpeg ||
5387
5381
  session.runState === ENCODE_RUN_STATE.SUSPENDED
5388
5382
  ) {
@@ -6931,13 +6925,13 @@ export class HlsSessionManager {
6931
6925
  if (!session || !isSafeFileName(fileName, session.segmentFormat)) {
6932
6926
  return { kind: "not-found" };
6933
6927
  }
6934
- if (session.state === "recovering") {
6928
+ if (session.runState === ENCODE_RUN_STATE.RETRY_WAIT) {
6935
6929
  // The data went away and is being fetched again. Holding the request is
6936
6930
  // the truthful answer: nothing is broken and there is nothing for the
6937
6931
  // viewer to retry.
6938
6932
  return { kind: "warming-up" };
6939
6933
  }
6940
- if (session.state === "failed") {
6934
+ if (session.runState === ENCODE_RUN_STATE.ENDED_FAILED) {
6941
6935
  return {
6942
6936
  kind: "failed",
6943
6937
  message: session.lastError || "ffmpeg failed for this transcode session."
@@ -7504,7 +7498,11 @@ export class HlsSessionManager {
7504
7498
  session.lastAccessedAt = Date.now();
7505
7499
  const warmupTotalSeconds = this.startupWaitMs / 1000;
7506
7500
  const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
7507
- const isWarmupPhase = session.state === "starting" || session.progress.state === "starting";
7501
+ // One question, one answer. This used to read BOTH strings with `||`
7502
+ // because neither could answer alone: `session.state` said "starting" from
7503
+ // the first spawn until something else overwrote it, and `progress.state`
7504
+ // said it again on its own schedule.
7505
+ const isWarmupPhase = wireState(session.runState) === "starting";
7508
7506
  const warmupPercent = isWarmupPhase
7509
7507
  ? Math.max(0, Math.min(100, (warmupElapsedSeconds / warmupTotalSeconds) * 100))
7510
7508
  : null;
@@ -7522,7 +7520,7 @@ export class HlsSessionManager {
7522
7520
  // The id the caller asked about, not the variant it was answered from —
7523
7521
  // the browser tracks its sessions by the id it was given.
7524
7522
  sessionId: named.id,
7525
- state: session.progress.state,
7523
+ state: wireState(session.runState),
7526
7524
  processedSeconds: session.progress.processedSeconds,
7527
7525
  startPositionSeconds: session.progress.startPositionSeconds ?? 0,
7528
7526
  totalSeconds: session.progress.totalSeconds,
@@ -7562,7 +7560,7 @@ export class HlsSessionManager {
7562
7560
  expectedSessionCreateMs: this.expectedSessionCreateMs(),
7563
7561
  expectedFirstSegmentMs: this.expectedFirstSegmentMs(),
7564
7562
  updatedAt: session.progress.updatedAt,
7565
- error: session.state === "failed" ? session.lastError : ""
7563
+ error: session.runState === ENCODE_RUN_STATE.ENDED_FAILED ? session.lastError : ""
7566
7564
  };
7567
7565
  }
7568
7566
 
@@ -87,7 +87,7 @@ function speedOf(wire) {
87
87
  * @param {object} torrent
88
88
  * @param {number} pieceIndex
89
89
  * @param {number} [limit] - How many wires to push it onto.
90
- * @returns {{ asked: number, considered: number, fastestBytesPerSecond: number }}
90
+ * @returns {{ asked: number, attempted: number, considered: number, fastestBytesPerSecond: number }}
91
91
  * `asked` counts requests the library actually placed: it refuses when a
92
92
  * wire's pipeline is full or when nothing can be reserved even with hotswap,
93
93
  * and that refusal is information — a piece nobody can be asked for is
@@ -95,7 +95,7 @@ function speedOf(wire) {
95
95
  */
96
96
  export function askFastestWiresFor(torrent, pieceIndex, limit = 3) {
97
97
  if (!canPlaceRequests(torrent) || !Number.isInteger(pieceIndex) || pieceIndex < 0) {
98
- return { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
98
+ return { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
99
99
  }
100
100
  const candidates = wiresForPiece(torrent, pieceIndex);
101
101
  let asked = 0;
@@ -116,6 +116,13 @@ export function askFastestWiresFor(torrent, pieceIndex, limit = 3) {
116
116
  return {
117
117
  asked,
118
118
  considered: candidates.length,
119
+ // How many of the asks the library placed, against how many it was asked
120
+ // for. The caller sums these over the whole wait, and summing `asked`
121
+ // against a `considered` taken from the LAST attempt is how the field log
122
+ // came to read "steered onto 12 of 6 holders" — a ratio of two different
123
+ // things. Both halves are returned per attempt so the caller can add each
124
+ // to its own total.
125
+ attempted: Math.min(candidates.length, Math.max(1, limit)),
119
126
  fastestBytesPerSecond: candidates.length > 0 ? speedOf(candidates[0]) : 0
120
127
  };
121
128
  }
@@ -517,12 +517,15 @@ export async function* readFragments({
517
517
  // holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
518
518
  // of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
519
519
  // minutes, on pieces five peers already had.
520
- let pushed = { asked: 0, considered: 0, fastestBytesPerSecond: 0 };
520
+ let pushed = { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
521
521
  const pushToFastest = () => {
522
522
  try {
523
523
  const result = askFastestWiresFor(torrent, pieceIndex);
524
524
  pushed = {
525
525
  asked: pushed.asked + result.asked,
526
+ // Summed like the successes, so the line compares two totals over
527
+ // the same attempts instead of a total against a snapshot.
528
+ attempted: (pushed.attempted ?? 0) + result.attempted,
526
529
  considered: result.considered,
527
530
  fastestBytesPerSecond: result.fastestBytesPerSecond
528
531
  };
@@ -592,7 +595,7 @@ export async function* readFragments({
592
595
  // What WE did about it, so the next session says whether steering
593
596
  // the piece onto faster holders shortens the tail — by number
594
597
  // rather than by impression.
595
- `; steered onto ${pushed.asked} of ${pushed.considered} holders` +
598
+ `; steered onto ${pushed.asked} of ${pushed.attempted} asks (${pushed.considered} peers held it)` +
596
599
  (pushed.fastestBytesPerSecond > 0
597
600
  ? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
598
601
  : "")
@@ -97,7 +97,7 @@ test("a build without the request entry is reported, not silently skipped", () =
97
97
  assert.equal(canPlaceRequests({ wires: [] }), false);
98
98
  assert.equal(canPlaceRequests({ wires: [], _request: () => true }), true);
99
99
  const result = askFastestWiresFor({ wires: [wire({ speed: 1 })] }, 1);
100
- assert.deepEqual(result, { asked: 0, considered: 0, fastestBytesPerSecond: 0 });
100
+ assert.deepEqual(result, { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 });
101
101
  });
102
102
 
103
103
  test("a wire that cannot say how fast it is ranks last rather than throwing", () => {