@mark1russell7/harness 0.7.0 → 0.9.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/build/index.js CHANGED
@@ -20,6 +20,25 @@ const requestWords = (outcome) => `${outcome.connection}${outcome.retried ? ", a
20
20
  * the harness, the kernel or Docker.
21
21
  */
22
22
  const NOISE = quantity(4, "ms");
23
+ /**
24
+ * This time is how late the injector can commit or lift a fault before the run is void. Stage 0
25
+ * measures the commit apart from δ_abs, and the commit is longer. On a hosted runner it took up to
26
+ * 2.75 ms at 4 environments, where δ_abs is 1 ms. Thus a cell's δ_abs does not change it.
27
+ */
28
+ const INJECT = NOISE;
29
+ /** This function gives the δ_abs of a cell: its own, or `NOISE` for a cell that gives none. */
30
+ const noiseOf = (cell) => cell.noise ?? NOISE;
31
+ /**
32
+ * This function tells if a timer firing came late enough to void a run. A firing two jiffies or
33
+ * more behind its wheel slot came late because the host stopped a vCPU. With the HZ of the run,
34
+ * the rule is in time: the delay must also reach δ_abs, because each band already includes that
35
+ * noise. At HZ 250 two jiffies are 8 ms, more than a δ_abs of 4 ms. At HZ 1000 they are 2 ms.
36
+ */
37
+ const delayVoids = (behind, hz, noise) => {
38
+ if (behind < 2) return false;
39
+ if (hz === void 0 || !(hz > 0)) return true;
40
+ return behind / hz >= Math.max(2 / hz, valueIn(noise, "s"));
41
+ };
23
42
  /** This fraction, δ_rel, is how far the median of a cell can be outside the prediction and stay equivalent. */
24
43
  const RELATIVE = .05;
25
44
  /** These are the run counts of a cell: 11, then 22 and 44 while its median crosses the edge of the band. */
@@ -116,14 +135,52 @@ const middle = (sorted) => {
116
135
  * finding. The distribution test (a DKW band) needs a predicted distribution from a simulator,
117
136
  * which does not exist at this time.
118
137
  */
119
- const verdictOf = (prediction, runs) => {
120
- const band = verdictOfBand(prediction, runs);
138
+ const verdictOf = (prediction, runs, noise = NOISE) => {
139
+ const band = verdictOfBand(prediction, runs, noise);
121
140
  const counted = runs.filter((run) => run.void === null);
122
- const verdict = counted.some((run) => run.request !== void 0) ? withRequests(band, counted) : band;
141
+ const asked = counted.some((run) => run.request !== void 0) ? withRequests(band, counted) : band;
142
+ const verdict = withProgram(asked, counted);
123
143
  if (prediction.group === void 0 && !counted.some((run) => run.group !== void 0)) return verdict;
124
144
  return {
125
145
  ...verdict,
126
- group: groupVerdictOf(prediction, counted)
146
+ group: groupVerdictOf(prediction, counted, noise)
147
+ };
148
+ };
149
+ /**
150
+ * This function adds the reconnect of the client and the outcomes of its call in flight to a
151
+ * verdict. Its reason gets a sentence for each. A verdict whose runs have neither stays as it is.
152
+ */
153
+ const withProgram = (verdict, counted) => {
154
+ const reconnects = counted.flatMap((run) => run.reconnect === void 0 ? [] : [run.reconnect]);
155
+ const calls = counted.flatMap((run) => run.call === void 0 ? [] : [run.call]);
156
+ const lefts = counted.flatMap((run) => run.left === void 0 ? [] : [run.left]);
157
+ if (reconnects.length === 0 && calls.length === 0 && lefts.length === 0) return verdict;
158
+ const ms = (spread) => spread === null ? "none" : `${Math.round(valueIn(spread.median, "s") * 1e6) / 1e3} ms`;
159
+ const tries = Math.max(0, ...reconnects.map((each) => each.waits.length));
160
+ const reconnect = reconnects.length === 0 ? void 0 : {
161
+ waits: Array.from({ length: tries }, (_, at) => spreadOf(reconnects.flatMap((each) => each.waits[at] === void 0 ? [] : [each.waits[at]]))),
162
+ syn: spreadOf(reconnects.flatMap((each) => each.syn === null ? [] : [each.syn])),
163
+ established: spreadOf(reconnects.flatMap((each) => each.established === null ? [] : [each.established])),
164
+ connect: spreadOf(reconnects.flatMap((each) => each.connects))
165
+ };
166
+ const outcomes = {};
167
+ for (const call of calls) outcomes[call.outcome] = (outcomes[call.outcome] ?? 0) + 1;
168
+ const gone = lefts.flatMap((each) => each === null ? [] : [each]);
169
+ const left = lefts.length === 0 ? void 0 : {
170
+ spread: spreadOf(gone),
171
+ stayed: lefts.length - gone.length
172
+ };
173
+ const said = [
174
+ ...left === void 0 ? [] : [`After the drop, the program closed its socket in ${gone.length} runs, median ${ms(left.spread)} after its drop line${left.stayed === 0 ? "" : `, and it did not close it in ${left.stayed}`}.`],
175
+ ...reconnect === void 0 ? [] : [`Reconnect: the waits before each try, median ${reconnect.waits.map(ms).join(", ")}; the first SYN median ${ms(reconnect.syn)} and ESTABLISHED median ${ms(reconnect.established)} after the detection.`],
176
+ ...calls.length === 0 ? [] : [`Calls in flight: ${Object.entries(outcomes).map(([outcome, n]) => `${n} ${outcome}`).join("; ")}.`]
177
+ ];
178
+ return {
179
+ ...verdict,
180
+ ...reconnect === void 0 ? {} : { reconnect },
181
+ ...calls.length === 0 ? {} : { calls: outcomes },
182
+ ...left === void 0 ? {} : { left },
183
+ why: `${verdict.why.endsWith(".") ? verdict.why : `${verdict.why}.`} ${said.join(" ")}`
127
184
  };
128
185
  };
129
186
  /**
@@ -146,8 +203,8 @@ const withRequests = (verdict, counted) => {
146
203
  /** This function gives the median of some counts, or null for none. */
147
204
  const medianCount = (values) => values.length === 0 ? null : middle([...values].sort((a, b) => a - b));
148
205
  /** This function sums what the group of a group cell did in its counted runs, and tests each stage that has a band. */
149
- const groupVerdictOf = (prediction, counted) => {
150
- const noise = valueIn(NOISE, "s");
206
+ const groupVerdictOf = (prediction, counted, delta = NOISE) => {
207
+ const noise = valueIn(delta, "s");
151
208
  const stamps = prediction.group?.stamps === void 0 ? 0 : valueIn(prediction.group.stamps, "s");
152
209
  const stages = (prediction.group?.stages ?? []).map((band) => {
153
210
  const interval = {
@@ -223,7 +280,7 @@ const groupVerdictOf = (prediction, counted) => {
223
280
  };
224
281
  };
225
282
  /** This function gives the verdict of the band of a cell: its containment and the equivalence of its median. */
226
- const verdictOfBand = (prediction, runs) => {
283
+ const verdictOfBand = (prediction, runs, delta = NOISE) => {
227
284
  const counted = runs.filter((run) => run.void === null);
228
285
  const voided = runs.length - counted.length;
229
286
  const outside = counted.filter((run) => run.finding !== null).map((run) => ({
@@ -317,7 +374,7 @@ const verdictOfBand = (prediction, runs) => {
317
374
  };
318
375
  }
319
376
  const timed = prediction.detects ? prediction : prediction.recovers ?? prediction.replies;
320
- const noise = valueIn(NOISE, "s");
377
+ const noise = valueIn(delta, "s");
321
378
  const low = valueIn(timed.low, "s");
322
379
  const high = valueIn(timed.high, "s");
323
380
  const band = {
@@ -383,6 +440,150 @@ const verdictOfBand = (prediction, runs) => {
383
440
  };
384
441
  };
385
442
  //#endregion
443
+ //#region src/application.ts
444
+ /**
445
+ * This module reads the acts of the program of the observer: its heartbeat, its drop of the
446
+ * connection, its reconnect and its call in flight. The kernel has no report of a detection by
447
+ * a program. Thus the program writes a line before each of its own acts, on CLOCK_MONOTONIC:
448
+ * - `ping` before each ping of its heartbeat, and `drop` before it closes the connection by itself.
449
+ * - `reconnecting` when it starts its reconnect timer, and `dial` before each new connection.
450
+ *
451
+ * The kernel then stamps each write of the program on the socket (`tx`) and each change of the
452
+ * state of the socket. A line of the client names its socket by its local address (`local`), and
453
+ * a line of a server names it by its peer (`peer`).
454
+ */
455
+ /** This function gives the interval between two instants on the one clock, in seconds to the microsecond. */
456
+ const between$3 = (from, to) => quantity(Math.round((to - from) / 1e3) / 1e6, "s");
457
+ /** This function gives the state of a state record after the change, and before it. TCP_ESTABLISHED is 1, TCP_SYN_SENT 2. */
458
+ const newState$2 = (record) => record.new;
459
+ const oldState$2 = (record) => record.old;
460
+ /** This function gives the lines of the program of the observer: the client, or the target for the server. A proxy has no lines. */
461
+ const programLines = (cell, run) => {
462
+ const observer = cell.observer ?? "client";
463
+ if (observer === "client") return run.events;
464
+ if (observer === "server") return run.server ?? [];
465
+ return [];
466
+ };
467
+ /**
468
+ * This function tells if a line of the program is about the connection of the run. The client
469
+ * names it by its local address, and a server by its peer. A line with neither is about the one
470
+ * connection of its program. A line whose address is null is about a socket that did not open:
471
+ * a try that failed.
472
+ */
473
+ const onConnection = (line, local) => {
474
+ if (!("local" in line) && !("peer" in line)) return true;
475
+ return (line["local"] ?? line["peer"]) === local;
476
+ };
477
+ /** This function gives the first record of a socket that matches, at the stamp of a line or in δ_abs after it. */
478
+ const kernelAt = (socket, line, matches, delta) => {
479
+ const noise = valueIn(delta, "s") * 1e9;
480
+ return socket.find((record) => record.mono >= line.t && record.mono <= line.t + noise && matches(record));
481
+ };
482
+ const eventOf = (socket, line, matches, delta = NOISE) => {
483
+ const record = kernelAt(socket, line, matches, delta);
484
+ return {
485
+ line,
486
+ at: record?.mono ?? line.t,
487
+ kernel: record !== void 0,
488
+ record: record === void 0 ? null : record.ev === "tx" ? "write" : "state"
489
+ };
490
+ };
491
+ /**
492
+ * This function gives the time from a line of the program to the close of the socket by its own
493
+ * side. That close is its change from ESTABLISHED to FIN_WAIT1, or to CLOSE (an abort). A FIN of
494
+ * the peer (CLOSE_WAIT) is not such a close. It is null when the socket did not close itself.
495
+ */
496
+ const leftAfter = (socket, line) => {
497
+ const left = socket.find((record) => record.ev === "state" && oldState$2(record) === 1 && (newState$2(record) === 4 || newState$2(record) === 7) && record.mono >= line.t);
498
+ return left === void 0 ? null : between$3(line.t, left.mono);
499
+ };
500
+ /**
501
+ * This function tells how a drop differs from the way that its prediction gives, or gives null.
502
+ * A close closes the socket in δ_abs. A close frame is a write, and the program does not close
503
+ * the socket at the drop. A peer that answers the close frame can close it, and that is no finding.
504
+ */
505
+ const dropFinding = (expected, drop, left, delta = NOISE) => {
506
+ const noise = valueIn(delta, "s");
507
+ const soon = left !== null && valueIn(left, "s") <= noise;
508
+ if (expected === "close") {
509
+ if (left === null) return "the program did not close its socket after the drop, where a close is at once";
510
+ return soon ? null : `the program closed its socket ${left.value} s after the drop, not at once`;
511
+ }
512
+ if (drop.record !== "write") return "the drop wrote no close frame on its socket";
513
+ return soon ? "the program closed its socket at the drop, where a close frame leaves it open" : null;
514
+ };
515
+ const isWrite = (record) => record.ev === "tx";
516
+ /** This function gives each ping of the program on the connection of the run, with the kernel's stamp of its write. */
517
+ const pingsOf = (lines, socket, local, delta = NOISE) => lines.filter((line) => line.ev === "ping" && onConnection(line, local)).map((line) => eventOf(socket, line, isWrite, delta));
518
+ /**
519
+ * This function gives the first ping that got no answer: no segment with data reached the socket
520
+ * after its write, up to `until`. Its write starts the wait of the heartbeat that the fault met.
521
+ */
522
+ const firstUnanswered = (pings, socket, until = Number.POSITIVE_INFINITY) => pings.filter((ping) => ping.at <= until).find((ping) => !socket.some((record) => record.ev === "rx" && (record.seg?.len.value ?? 0) > 0 && record.mono > ping.at && record.mono <= until));
523
+ /**
524
+ * This function gives the first drop of the connection by its program. The kernel stamps it as the
525
+ * write of a close frame. Or it stamps the change of the socket out of ESTABLISHED: a close or an
526
+ * abort.
527
+ */
528
+ const dropOf = (lines, socket, local, delta = NOISE) => {
529
+ const line = lines.find((each) => each.ev === "drop" && onConnection(each, local));
530
+ return line === void 0 ? void 0 : eventOf(socket, line, (record) => isWrite(record) || record.ev === "state" && oldState$2(record) === 1, delta);
531
+ };
532
+ /**
533
+ * This function reads the reconnect of the client after the detection at `detected`. The socket of
534
+ * the run has the cookie `cookie`. The lines of the program count from `from`: the drop line of a
535
+ * drop, which comes before the kernel's stamp of it.
536
+ */
537
+ const reconnectOf = (run, detected, cookie, from = detected) => {
538
+ const after = run.events.filter((line) => line.t > from);
539
+ const starts = after.filter((line) => line.ev === "reconnecting");
540
+ const dials = after.filter((line) => line.ev === "dial");
541
+ const waits = starts.flatMap((start, at) => {
542
+ const next = starts[at + 1]?.t ?? Number.POSITIVE_INFINITY;
543
+ const dial = dials.find((each) => each.t >= start.t && each.t < next);
544
+ return dial === void 0 ? [] : [between$3(start.t, dial.t)];
545
+ });
546
+ const fresh = run.kernel.filter((record) => record.netns === run.netns && record.cookie !== cookie && record.ev === "state" && record.mono > detected);
547
+ const syns = fresh.filter((record) => newState$2(record) === 2);
548
+ const established = fresh.find((record) => newState$2(record) === 1);
549
+ const connects = dials.flatMap((dial) => {
550
+ const syn = syns.find((record) => record.mono >= dial.t);
551
+ return syn === void 0 ? [] : [between$3(dial.t, syn.mono)];
552
+ });
553
+ return {
554
+ waits,
555
+ syn: syns[0] === void 0 ? null : between$3(detected, syns[0].mono),
556
+ established: established === void 0 ? null : between$3(detected, established.mono),
557
+ connects
558
+ };
559
+ };
560
+ /** This function tells how the waits of a reconnect differ from their bands, each widened by δ_abs, or gives null. */
561
+ const reconnectFinding = (expected, read, delta = NOISE) => {
562
+ const noise = valueIn(delta, "s");
563
+ const round = (value) => Math.round(value * 1e6) / 1e6;
564
+ const found = expected.waits.flatMap((band, at) => {
565
+ const wait = read.waits[at];
566
+ if (wait === void 0) return [`the client made no try ${at + 1} after the detection`];
567
+ const value = valueIn(wait, "s");
568
+ const low = round(valueIn(band.low, "s") - noise);
569
+ const high = round(valueIn(band.high, "s") + noise);
570
+ return value < low || value > high ? [`its wait before try ${at + 1} was ${value} s, outside [${low}, ${high}] s`] : [];
571
+ });
572
+ return found.length === 0 ? null : found.join(". ");
573
+ };
574
+ /** This function reads the call that the client sent into the fault: its `call-error` or `data` line after the request. */
575
+ const callOf = (events, request, detected) => {
576
+ const end = events.find((line) => (line.ev === "call-error" || line.ev === "data") && line.t > request.t);
577
+ if (end === void 0) return {
578
+ outcome: "pending",
579
+ after: null
580
+ };
581
+ return {
582
+ outcome: end.ev === "data" ? "a reply" : String(end["message"] ?? end["code"] ?? "an error"),
583
+ after: detected === void 0 ? null : between$3(detected, end.t)
584
+ };
585
+ };
586
+ //#endregion
386
587
  //#region src/driver.ts
387
588
  /** These are the errno numbers that the kernel reports to a socket, by the names that predictions use. */
388
589
  const ERRNO$1 = {
@@ -547,9 +748,9 @@ const settled = (cell, run) => {
547
748
  * that found the socket held by its owner put itself off by HZ/20. Either way, the run measured
548
749
  * something other than the timer.
549
750
  */
550
- const delayed = (place, from, to) => {
751
+ const delayed = (place, from, to, hz, noise) => {
551
752
  const firings = place.records.filter((record) => record.ev === "timer" && record.what === "keepalive" && record.mono >= from && record.mono <= to);
552
- const late = firings.find((record) => (record.behind?.value ?? 0) >= 2);
753
+ const late = firings.find((record) => delayVoids(record.behind?.value ?? 0, hz, noise));
553
754
  if (late !== void 0) return `its keepalive timer ran ${late.behind.value} jiffies behind its wheel slot: the VM delayed it, not the kernel`;
554
755
  return firings.find((record) => record.owned === true) === void 0 ? null : "its keepalive timer found the socket held by its owner and put itself off by HZ/20: the run measured the owner, not the timer";
555
756
  };
@@ -623,13 +824,13 @@ const measureDriver = (cell, run) => {
623
824
  if (heal === null) voided = "the fault never lifted";
624
825
  else {
625
826
  const late = (heal.t0 - run.fault.t0) / 1e9 - valueIn(cell.hold, "s");
626
- if (late > valueIn(NOISE, "s")) voided = `the lift came ${Math.round(late * 1e4) / 10} ms after its hold: the injector was late, not the client`;
827
+ if (late > valueIn(INJECT, "s")) voided = `the lift came ${Math.round(late * 1e4) / 10} ms after its hold: the injector was late, not the client`;
627
828
  }
628
829
  }
629
830
  const assumed = prediction.rtt;
630
831
  if (voided === null && assumed !== void 0 && rtt !== null && valueIn(rtt, "ms") > valueIn(assumed.high, "ms")) voided = `rtt-over-assumption: its first round trip was ${rtt.value} ms, above the ${valueIn(assumed.high, "ms")} ms that the prediction assumes`;
631
832
  const findings = [];
632
- const noise = valueIn(NOISE, "s");
833
+ const noise = valueIn(noiseOf(cell), "s");
633
834
  const until = prediction.detects ? Infinity : after + valueIn(prediction.window, "s") * 1e9;
634
835
  for (const match of driver.quiet ?? []) {
635
836
  const line = events.find((event) => event.t > after && event.t <= until && matches(event, match));
@@ -719,7 +920,7 @@ const measureDriver = (cell, run) => {
719
920
  void: voided,
720
921
  finding: `it ended without its reference event (${prediction.from} on the ${driver.socket} socket)`
721
922
  };
722
- if (driver.ends === void 0 && prediction.from === "last-received" && place !== void 0) voided ??= delayed(place, reference, ended);
923
+ if (driver.ends === void 0 && prediction.from === "last-received" && place !== void 0) voided ??= delayed(place, reference, ended, run.hz, noiseOf(cell));
723
924
  const name = outcome.kind === "error" ? outcome.errno : outcome.kind === "eof" ? "EOF" : "silent";
724
925
  if (prediction.errno !== "OK" && name !== prediction.errno) findings.unshift(`it ended with ${name}, not ${prediction.errno}`);
725
926
  const seen = report === void 0 ? void 0 : events.find((event) => event.t >= report.at && (event.ev === "error" || event.ev === "socket-error"));
@@ -1042,14 +1243,14 @@ const measureGroup = (cell, run) => {
1042
1243
  if (heal === null) voided = "the fault never lifted";
1043
1244
  else {
1044
1245
  const late = (heal.t0 - t0) / 1e9 - valueIn(cell.hold, "s");
1045
- if (late > valueIn(NOISE, "s")) voided = `the lift came ${Math.round(late * 1e4) / 10} ms after its hold: the injector was late, not the group`;
1246
+ if (late > valueIn(INJECT, "s")) voided = `the lift came ${Math.round(late * 1e4) / 10} ms after its hold: the injector was late, not the group`;
1046
1247
  }
1047
1248
  }
1048
1249
  const assumed = prediction.detects === null ? void 0 : prediction.rtt;
1049
1250
  const first = kernel.find((record) => record.netns === run.netns && record.dst === `${run.group?.members[primary]?.ip}:${(cell.group ?? cell.replicaSet)?.port}` && record.tcp?.srtt !== void 0);
1050
1251
  const rtt = first?.tcp?.srtt === void 0 ? null : valueIn(first.tcp.srtt, "us") / 1e3;
1051
1252
  if (voided === null && assumed !== void 0 && rtt !== null && rtt > valueIn(assumed.high, "ms")) voided = `rtt-over-assumption: its first round trip was ${rtt} ms, above the ${valueIn(assumed.high, "ms")} ms that the prediction assumes`;
1052
- if (voided === null && prediction.detects === true && prediction.from === "fault" && (run.fault.t1 - run.fault.t0) / 1e6 > valueIn(NOISE, "ms")) voided = `the commit of the fault took ${Math.round((run.fault.t1 - run.fault.t0) / 1e5) / 10} ms, longer than the noise of the pipeline: its start is not known`;
1253
+ if (voided === null && prediction.detects === true && prediction.from === "fault" && (run.fault.t1 - run.fault.t0) / 1e6 > valueIn(INJECT, "ms")) voided = `the commit of the fault took ${Math.round((run.fault.t1 - run.fault.t0) / 1e5) / 10} ms, longer than the noise of the pipeline: its start is not known`;
1053
1254
  let reference = t0;
1054
1255
  if (prediction.detects === false && prediction.recovers !== void 0) {
1055
1256
  const into = events.find((event) => event.ev === "sent" && event.t > run.fault.t1);
@@ -1071,7 +1272,7 @@ const measureGroup = (cell, run) => {
1071
1272
  if (accepted !== void 0 && lands === "same" && to !== primary) findings.push(`the client wrote to ${to ?? String(accepted["at"])}, where it should stay on ${primary}`);
1072
1273
  for (const error of expected?.errors ?? []) {
1073
1274
  const met = group.errors.find((each) => each.name === error.name);
1074
- const noise = valueIn(NOISE, "s");
1275
+ const noise = valueIn(noiseOf(cell), "s");
1075
1276
  if (met === void 0) findings.push(`the client never met ${error.name}`);
1076
1277
  else if (met.first.value < valueIn(error.low, "s") - noise || met.first.value > valueIn(error.high, "s") + noise) findings.push(`the client met ${error.name} ${met.first.value} s after the fault, outside [${valueIn(error.low, "s")}, ${valueIn(error.high, "s")}] s`);
1077
1278
  }
@@ -1118,9 +1319,10 @@ const newState = (record) => record.new;
1118
1319
  const count = (event, field) => event === void 0 ? 0 : Number(event[field] ?? 0);
1119
1320
  /** This function gives the view of the client on the same interval: TCP_INFO, with a sample each 2 ms. */
1120
1321
  const socketVantage = (cell, events, ready, request) => {
1121
- if (!cell.prediction.detects || cell.prediction.from === "peer-closed" || cell.prediction.from === "fault") return null;
1322
+ const { from } = cell.prediction.detects === true ? cell.prediction : { from: void 0 };
1323
+ if (from === void 0 || from === "peer-closed" || from === "fault" || from === "ping-sent") return null;
1122
1324
  const ended = events.find((event) => event.ev === "error" || event.ev === "eof");
1123
- const reference = cell.prediction.from === "last-received" ? ready : cell.prediction.from === "request-sent" ? request : events.find((event) => event.ev === "kernel" && event.t >= request.t && count(event, "retransmits") > 0);
1325
+ const reference = from === "last-received" ? ready : from === "request-sent" ? request : events.find((event) => event.ev === "kernel" && event.t >= request.t && count(event, "retransmits") > 0);
1124
1326
  return ended === void 0 || reference === void 0 ? null : between(reference.t, ended.t);
1125
1327
  };
1126
1328
  const ipOf = (address) => address.split(":")[0];
@@ -1354,7 +1556,7 @@ const measureSocket = (cell, run) => {
1354
1556
  */
1355
1557
  const delayed = (from, to, timer) => {
1356
1558
  const firings = socket.filter((record) => record.ev === "timer" && record.what === timer && record.mono >= from && record.mono <= to);
1357
- const late = firings.find((record) => (record.behind?.value ?? 0) >= 2);
1559
+ const late = firings.find((record) => delayVoids(record.behind?.value ?? 0, run.hz, noiseOf(cell)));
1358
1560
  if (late !== void 0) return `its ${timer} timer ran ${late.behind.value} jiffies behind its wheel slot: the VM delayed it, not the kernel`;
1359
1561
  return firings.find((record) => timer === "keepalive" && record.owned === true) === void 0 ? null : "its keepalive timer found the socket held by its owner and put itself off by HZ/20: the run measured the owner, not the timer";
1360
1562
  };
@@ -1417,22 +1619,29 @@ const measureSocket = (cell, run) => {
1417
1619
  const reported = socket.find((record) => record.ev === "error" && (record.err ?? 0) !== 0);
1418
1620
  const seen = remote ? void 0 : events.find((event) => event.ev === "error" || event.ev === "eof");
1419
1621
  const closeWait = socket.find((record) => record.ev === "state" && newState(record) === 8);
1420
- const own = cell.prediction.detects === true && cell.prediction.errno === "CLOSED" ? socket.find((record) => record.ev === "state" && oldState(record) === 1 && newState(record) === 4) : void 0;
1421
- const outcome = reported !== void 0 ? {
1622
+ const own = cell.prediction.detects === true && cell.prediction.errno === "CLOSED" || cell.prediction.detects === null && cell.prediction.ends === "own-close" ? socket.find((record) => record.ev === "state" && oldState(record) === 1 && newState(record) === 4) : void 0;
1623
+ const lines = programLines(cell, run);
1624
+ const kernelEnded = reported?.mono ?? closeWait?.mono ?? own?.mono ?? seen?.t;
1625
+ const first = dropOf(lines, socket, local, noiseOf(cell));
1626
+ const drop = first !== void 0 && (kernelEnded === void 0 || first.at <= kernelEnded) ? first : void 0;
1627
+ const dropped = drop !== void 0;
1628
+ const outcome = dropped ? { kind: "dropped" } : reported !== void 0 ? {
1422
1629
  kind: "error",
1423
1630
  errno: ERRNO[reported.err] ?? `E${reported.err}`
1424
1631
  } : closeWait !== void 0 || seen?.ev === "eof" ? { kind: "eof" } : own !== void 0 ? { kind: "closed" } : seen !== void 0 ? {
1425
1632
  kind: "error",
1426
1633
  errno: String(seen["errno"])
1427
1634
  } : { kind: "silent" };
1428
- const ended = reported?.mono ?? closeWait?.mono ?? own?.mono ?? seen?.t;
1429
- const noticed = reported !== void 0 && seen !== void 0 ? between(reported.mono, seen.t) : null;
1635
+ const ended = drop?.at ?? kernelEnded;
1636
+ const noticed = reported !== void 0 && seen !== void 0 && !dropped ? between(reported.mono, seen.t) : null;
1430
1637
  const { prediction } = cell;
1638
+ const pings = pingsOf(lines, socket, local, noiseOf(cell));
1431
1639
  const before = run.fault?.t1 ?? ready.t;
1432
1640
  const referenceAt = (from) => {
1433
1641
  if (from === "request-sent") return request.t;
1434
1642
  if (from === "fault") return run.fault?.t0;
1435
1643
  if (from === "client-start") return (events.find((event) => event.ev === "connecting") ?? events[0])?.t;
1644
+ if (from === "ping-sent") return firstUnanswered(pings, socket, ended)?.at;
1436
1645
  return (from === "last-received" ? socket.filter((each) => each.ev === "rx" && each.mono <= before).at(-1) : from === "first-retransmission" ? retransmitted.find((each) => each.why === "rto") : kernel.find((each) => each.netns === peerNetns && each.cookie === peerCookie && each.ev === "state" && oldState(each) === 1))?.mono;
1437
1646
  };
1438
1647
  const lastRx = kernel.filter((each) => each.netns === run.netns && each.src === local && each.ev === "rx" && each.mono <= before).at(-1)?.mono ?? ready.t;
@@ -1481,21 +1690,22 @@ const measureSocket = (cell, run) => {
1481
1690
  }
1482
1691
  if (!prediction.detects && prediction.recovers !== void 0) {
1483
1692
  const { recovers } = prediction;
1693
+ const start = cell.probe.mode === "idle" ? lines.find((line) => line.ev === "ping" && line.t >= (run.fault?.t1 ?? 0)) ?? request : request;
1484
1694
  const heal = run.heal ?? null;
1485
1695
  if (voided === null && heal === null) voided = "the stall never ended";
1486
1696
  else if (voided === null && heal !== null) {
1487
- const from = between(request.t, heal.t0);
1488
- const to = between(request.t, heal.t1);
1697
+ const from = between(start.t, heal.t0);
1698
+ const to = between(start.t, heal.t1);
1489
1699
  if (valueIn(from, "s") <= valueIn(recovers.heal.low, "s") || valueIn(to, "s") >= valueIn(recovers.heal.high, "s")) voided = `the stall ended ${from.value}–${to.value} s after the send, outside the ${recovers.heal.low.value}–${recovers.heal.high.value} s its prediction assumed`;
1490
1700
  }
1491
- const steps = retransmitted.filter((record) => record.mono > request.t).map((record) => between(request.t, record.mono));
1701
+ const steps = retransmitted.filter((record) => record.mono > start.t).map((record) => between(start.t, record.mono));
1492
1702
  if (ended !== void 0) return {
1493
1703
  ...base,
1494
1704
  ...nothing,
1495
1705
  outcome,
1496
1706
  steps,
1497
1707
  void: voided,
1498
- finding: `detected after ${between(request.t, ended).value} s, where it should recover`
1708
+ finding: `detected after ${between(start.t, ended).value} s, where it should recover`
1499
1709
  };
1500
1710
  const answer = heal === null ? void 0 : socket.find((record) => record.ev === "rx" && record.mono > heal.t0);
1501
1711
  if (answer === void 0) return {
@@ -1505,13 +1715,13 @@ const measureSocket = (cell, run) => {
1505
1715
  void: voided,
1506
1716
  finding: "it never moved again: nothing came back after the stall ended"
1507
1717
  };
1508
- const reply = events.find((event) => event.ev === "data" && event.t > request.t);
1509
- voided ??= delayed(request.t, answer.mono, "retransmit");
1718
+ const reply = events.find((event) => event.ev === "data" && event.t > start.t);
1719
+ voided ??= delayed(start.t, answer.mono, "retransmit");
1510
1720
  return {
1511
1721
  ...base,
1512
1722
  outcome: { kind: "answered" },
1513
- elapsed: between(request.t, answer.mono),
1514
- socket: reply === void 0 ? null : between(request.t, reply.t),
1723
+ elapsed: between(start.t, answer.mono),
1724
+ socket: reply === void 0 ? null : between(start.t, reply.t),
1515
1725
  noticed: reply === void 0 ? null : between(answer.mono, reply.t),
1516
1726
  steps,
1517
1727
  void: voided,
@@ -1561,7 +1771,7 @@ const measureSocket = (cell, run) => {
1561
1771
  if (after !== null) finding = `a router forgot the flow ${after.value} s after the last segment, where it should keep it`;
1562
1772
  } else if (after === null) finding = "no router forgot the flow in the window, where it should forget it";
1563
1773
  else {
1564
- const noise = valueIn(NOISE, "s");
1774
+ const noise = valueIn(noiseOf(cell), "s");
1565
1775
  const low = valueIn(expected.low, "s") - noise;
1566
1776
  const high = valueIn(expected.high, "s") + noise;
1567
1777
  if (after.value < low || after.value > high) finding = `a router forgot the flow ${after.value} s after the last segment, outside [${Math.round(low * 1e6) / 1e6}, ${Math.round(high * 1e6) / 1e6}] s`;
@@ -1601,23 +1811,41 @@ const measureSocket = (cell, run) => {
1601
1811
  void: voided,
1602
1812
  finding: `it ended without its reference event (${prediction.from})`
1603
1813
  };
1604
- const steps = (prediction.from === "last-received" ? keepalives : prediction.from === "first-retransmission" ? retransmitted.filter((record) => record.why === "rto" && record.mono > reference) : []).map((record) => between(reference, record.mono));
1605
- const finding = (prediction.errno === "EOF" ? outcome.kind === "eof" : prediction.errno === "CLOSED" ? outcome.kind === "closed" : outcome.kind === "error" && outcome.errno === prediction.errno) ? null : `it ended with ${outcome.kind === "error" ? outcome.errno : outcome.kind === "closed" ? "its own close" : "end of file"}, not ${prediction.errno}`;
1814
+ const steps = prediction.from === "ping-sent" ? pings.filter((ping) => ping.at > reference && ping.at <= ended).map((ping) => between(reference, ping.at)) : (prediction.from === "last-received" ? keepalives : prediction.from === "first-retransmission" ? retransmitted.filter((record) => record.why === "rto" && record.mono > reference) : []).map((record) => between(reference, record.mono));
1815
+ const matches = prediction.errno === "EOF" ? outcome.kind === "eof" : prediction.errno === "CLOSED" ? outcome.kind === "closed" : prediction.errno === "DROPPED" ? outcome.kind === "dropped" : outcome.kind === "error" && outcome.errno === prediction.errno;
1816
+ const words = outcome.kind === "error" ? outcome.errno : outcome.kind === "closed" ? "its own close" : outcome.kind === "dropped" ? "a drop by its own program" : "end of file";
1817
+ const reconnect = prediction.reconnects === void 0 ? void 0 : reconnectOf(run, ended, cookie, drop?.line.t ?? ended);
1818
+ const late = reconnect === void 0 || prediction.reconnects === void 0 ? null : reconnectFinding(prediction.reconnects, reconnect, noiseOf(cell));
1819
+ const left = prediction.drop === void 0 || drop === void 0 ? void 0 : leftAfter(socket, drop.line);
1820
+ const how = prediction.drop === void 0 || drop === void 0 || left === void 0 ? null : dropFinding(prediction.drop, drop, left, noiseOf(cell));
1821
+ const found = [
1822
+ matches ? null : `it ended with ${words}, not ${prediction.errno}`,
1823
+ late,
1824
+ how
1825
+ ].filter((each) => each !== null);
1826
+ const finding = found.length === 0 ? null : found.join(". ");
1606
1827
  const programTimer = cell.probe.http !== void 0 && prediction.from === "last-received" && prediction.timer !== "keepalive";
1607
1828
  if (prediction.from === "last-received" && !programTimer || prediction.from === "first-retransmission") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
1829
+ if (prediction.from === "ping-sent") voided ??= delayed(reference, ended, "retransmit");
1608
1830
  const lag = (valueIn(prediction.high, "s") - valueIn(prediction.low, "s")) * 1e9;
1609
1831
  const slow = prediction.timer.startsWith("expire_") ? (run.middlebox ?? []).find((line) => line.ev === "pump-slow" && line.t <= ended && line.t + Number(line["took"]) * 1e9 >= ended - lag) : void 0;
1610
1832
  if (slow !== void 0) voided ??= `a listing of the router took ${Math.round(Number(slow["took"]) * 1e4) / 10} ms, longer than the bound of its prediction`;
1611
1833
  const commit = run.fault === null ? 0 : (run.fault.t1 - run.fault.t0) / 1e6;
1612
- if (prediction.from === "fault" && commit > valueIn(NOISE, "ms")) voided ??= `the commit of the fault took ${Math.round(commit * 10) / 10} ms, longer than the noise of the pipeline: its start is not known`;
1834
+ if (prediction.from === "fault" && commit > valueIn(INJECT, "ms")) voided ??= `the commit of the fault took ${Math.round(commit * 10) / 10} ms, longer than the noise of the pipeline: its start is not known`;
1835
+ const unanswered = prediction.from === "ping-sent" ? firstUnanswered(pings, socket, ended) : void 0;
1836
+ const endLine = drop?.line ?? seen;
1837
+ const view = unanswered !== void 0 ? endLine === void 0 ? null : between(unanswered.line.t, endLine.t) : remote ? null : socketVantage(cell, events, ready, request);
1613
1838
  return {
1614
1839
  ...base,
1615
1840
  ...chain,
1616
1841
  outcome,
1617
1842
  elapsed: between(reference, ended),
1618
- socket: remote ? null : socketVantage(cell, events, ready, request),
1843
+ socket: view,
1619
1844
  noticed,
1620
1845
  steps,
1846
+ ...reconnect === void 0 ? {} : { reconnect },
1847
+ ...left === void 0 ? {} : { left },
1848
+ ...cell.probe.ws !== void 0 && inflight ? { call: callOf(events, request, ended) } : {},
1621
1849
  void: voided,
1622
1850
  finding
1623
1851
  };
@@ -1679,6 +1907,12 @@ const MONGO = {
1679
1907
  dockerfile: "harness/image/mongo/Dockerfile",
1680
1908
  from: ["harness/image/mongo"]
1681
1909
  };
1910
+ /** This image holds `@procedures/client` 0.3.0 on Node 24.21.0: its WebSocket client as a client of the harness, and the script of its server. */
1911
+ const PROCEDURES = {
1912
+ name: "loom-harness-procedures",
1913
+ dockerfile: "harness/image/procedures/Dockerfile",
1914
+ from: ["harness/image/procedures"]
1915
+ };
1682
1916
  /** This image is the router of a middlebox chain: NAT and conntrack, with a form for a forgotten flow. */
1683
1917
  const ROUTER = {
1684
1918
  name: "loom-harness-router",
@@ -1751,6 +1985,10 @@ const goHttpImage = () => build(GO_HTTP);
1751
1985
  const nodeHttpImage = () => build(NODE_HTTP);
1752
1986
  /** This function gives the image of the MongoDB client. */
1753
1987
  const mongoClientImage = () => build(MONGO);
1988
+ /** This function gives the image of `@procedures/client`: its WebSocket client and its server. */
1989
+ const proceduresImage = () => build(PROCEDURES);
1990
+ /** This function gives the image that a target operates: its stock image, or an image of the harness (`Target.harness`). */
1991
+ const targetImageOf = (target) => target.harness === "procedures" ? proceduresImage() : Promise.resolve(target.image);
1754
1992
  /**
1755
1993
  * This time is the interval of the listing of a router, when its hop does not set one. A listing
1756
1994
  * evicts each expired flow that it walks past, and the DESTROY event of that flow follows.
@@ -1869,6 +2107,42 @@ const endpointAfter = (hops, element) => {
1869
2107
  return at;
1870
2108
  };
1871
2109
  const STOP = { timeout: 0 };
2110
+ /** These are the values of CONFIG_HZ that Linux offers. */
2111
+ const RATES = [
2112
+ 100,
2113
+ 250,
2114
+ 300,
2115
+ 1e3
2116
+ ];
2117
+ /**
2118
+ * This function gives HZ from two readings of /proc/timer_list, one second apart: its `now at`
2119
+ * time in ns and its jiffies. It gives the nearest rate that Linux offers, or null when the
2120
+ * measurement is more than 5 % from each of them.
2121
+ */
2122
+ const hzOf = (first, second) => {
2123
+ const seconds = (second.now - first.now) / 1e9;
2124
+ if (!(seconds > 0)) return null;
2125
+ const rate = (second.jiffies - first.jiffies) / seconds;
2126
+ const nearest = RATES.reduce((best, each) => Math.abs(each - rate) < Math.abs(best - rate) ? each : best);
2127
+ return Math.abs(nearest - rate) / nearest <= .05 ? String(nearest) : null;
2128
+ };
2129
+ /** This function measures HZ in a privileged container: jiffies against the time of the kernel, one second apart. */
2130
+ const measuredHz = async (container) => {
2131
+ const read = "awk '/^now at/ {now=$3} /^jiffies:/ {print now, $2; exit}' /proc/timer_list";
2132
+ const values = (await output(container, [
2133
+ "sh",
2134
+ "-c",
2135
+ `${read}; sleep 1; ${read}`
2136
+ ]).catch(() => "")).split(/\s+/).filter((each) => each !== "").map(Number);
2137
+ if (values.length !== 4 || values.some((each) => !Number.isFinite(each))) return null;
2138
+ return hzOf({
2139
+ now: values[0],
2140
+ jiffies: values[1]
2141
+ }, {
2142
+ now: values[2],
2143
+ jiffies: values[3]
2144
+ });
2145
+ };
1872
2146
  const output = async (container, argv) => {
1873
2147
  const result = await container.exec(argv);
1874
2148
  if (result.exitCode !== 0) throw new Error(`${argv.join(" ")} exited ${result.exitCode}: ${result.output.trim()}`);
@@ -1908,6 +2182,11 @@ const clientOf = async (kind, image) => {
1908
2182
  command: ["node", "/opt/harness/mongo/client-mongo.mjs"],
1909
2183
  clientKind: kind
1910
2184
  };
2185
+ if (kind === "procedures-ws") return {
2186
+ clientImage: await proceduresImage(),
2187
+ command: ["node", "/opt/harness/procedures/client-ws.mjs"],
2188
+ clientKind: kind
2189
+ };
1911
2190
  return {
1912
2191
  clientImage: image,
1913
2192
  command: ["/opt/harness/client"],
@@ -1968,7 +2247,8 @@ const openLab = async (target, kind = "tcp", topology) => {
1968
2247
  try {
1969
2248
  for (let at = 0; at <= hops.length; at += 1) networks.push(await new Network().start());
1970
2249
  const last = networks[hops.length];
1971
- let stock = new GenericContainer(target.image).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
2250
+ const targetImage = await targetImageOf(target);
2251
+ let stock = new GenericContainer(targetImage).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
1972
2252
  if (target.files !== void 0) stock = stock.withCopyContentToContainer(Object.entries(target.files).map(([path, content]) => ({
1973
2253
  content,
1974
2254
  target: path
@@ -2498,11 +2778,26 @@ const openLab = async (target, kind = "tcp", topology) => {
2498
2778
  return {
2499
2779
  kernel: await output(client, ["uname", "-r"]),
2500
2780
  architecture: await output(client, ["uname", "-m"]),
2501
- hz: /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1] ?? null,
2781
+ ...await (async () => {
2782
+ const configured = /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1];
2783
+ if (configured !== void 0) return {
2784
+ hz: configured,
2785
+ hzFrom: "config"
2786
+ };
2787
+ const measured = await measuredHz(admin);
2788
+ return measured === null ? { hz: null } : {
2789
+ hz: measured,
2790
+ hzFrom: "measured"
2791
+ };
2792
+ })(),
2502
2793
  boot_id: await output(client, ["cat", "/proc/sys/kernel/random/boot_id"]),
2503
2794
  docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
2504
2795
  target: {
2505
- image: target.image,
2796
+ image: targetImage,
2797
+ ...target.harness === void 0 ? {} : {
2798
+ harness: target.harness,
2799
+ base: target.image
2800
+ },
2506
2801
  id: (await inspect(server)).Image,
2507
2802
  settings,
2508
2803
  sysctls: targetSysctls,
@@ -3839,7 +4134,7 @@ const judge = (cell, runs) => {
3839
4134
  const measured = runs.filter((run) => run.warmup !== true).map((run) => measure(cell, run));
3840
4135
  return {
3841
4136
  measured,
3842
- verdict: verdictOf(cell.prediction, measured)
4137
+ verdict: verdictOf(cell.prediction, measured, noiseOf(cell))
3843
4138
  };
3844
4139
  };
3845
4140
  /** This function gives the count of lost records in the latest clock line of the prober, since it began. */
@@ -3857,6 +4152,20 @@ const probeArgs = (cell, lab) => {
3857
4152
  "--mode",
3858
4153
  probe.mode
3859
4154
  ];
4155
+ if (probe.ws !== void 0) {
4156
+ const { ws } = probe;
4157
+ const waits = (prediction.detects === true ? prediction.reconnects?.waits ?? [] : []).reduce((sum, wait) => sum + valueIn(wait.high, "s"), 0);
4158
+ args.push("--websocket", ws.socket);
4159
+ args.push("--heartbeat", ws.heartbeat === null ? "off" : "on");
4160
+ if (ws.heartbeat !== null) args.push("--heartbeat-interval-ms", String(valueIn(ws.heartbeat.interval, "ms")), "--heartbeat-timeout-ms", String(valueIn(ws.heartbeat.timeout, "ms")), "--heartbeat-misses", String(ws.heartbeat.misses));
4161
+ args.push("--reconnect", ws.reconnect === null ? "off" : "on");
4162
+ if (ws.reconnect !== null) args.push("--reconnect-initial-ms", String(valueIn(ws.reconnect.initialDelay, "ms")), "--reconnect-multiplier", String(ws.reconnect.multiplier), "--reconnect-max-ms", String(valueIn(ws.reconnect.maxDelay, "ms")), "--reconnect-attempts", String(ws.reconnect.attempts), "--reconnect-jitter", String(ws.reconnect.jitter));
4163
+ args.push("--request-timeout-ms", String(valueIn(ws.requestTimeout, "ms")));
4164
+ args.push("--connection-timeout-ms", String(valueIn(ws.connectionTimeout, "ms")));
4165
+ if (ws.hold !== void 0) args.push("--hold-ms", String(valueIn(ws.hold, "ms")));
4166
+ args.push("--max-s", String(Math.ceil((longest + waits) * 2 + 10)));
4167
+ return args;
4168
+ }
3860
4169
  if (probe.http !== void 0) {
3861
4170
  const { http } = probe;
3862
4171
  const gap = http.gap === void 0 ? 0 : valueIn(http.gap, "s");
@@ -3894,14 +4203,15 @@ const probeArgs = (cell, lab) => {
3894
4203
  /**
3895
4204
  * This function waits for an error on the socket of the observer, or for its end of file
3896
4205
  * (CLOSE_WAIT, state 8). The prober records both. A cell whose observer is the server, or a proxy
3897
- * of a topology, waits for that. The promise resolves after `within` ms at the latest.
4206
+ * of a topology, waits for that. A target with its own detector can also drop the connection
4207
+ * itself (`dropped`). The promise resolves after `within` ms at the latest.
3898
4208
  */
3899
- const observerError = (cell, partial, within) => new Promise((resolve) => {
4209
+ const observerError = (cell, partial, within, dropped = () => false) => new Promise((resolve) => {
3900
4210
  const started = Date.now();
3901
4211
  const look = () => {
3902
4212
  const run = partial();
3903
4213
  const at = socketOf(cell, run);
3904
- if (at !== void 0 && run.kernel.some((record) => record.netns === at.netns && record.cookie === at.cookie && (record.ev === "error" && (record.err ?? 0) !== 0 || record.ev === "state" && record.new === 8)) || Date.now() - started > within) resolve();
4214
+ if (at !== void 0 && run.kernel.some((record) => record.netns === at.netns && record.cookie === at.cookie && (record.ev === "error" && (record.err ?? 0) !== 0 || record.ev === "state" && record.new === 8)) || dropped() || Date.now() - started > within) resolve();
3905
4215
  else setTimeout(look, 50);
3906
4216
  };
3907
4217
  look();
@@ -4000,6 +4310,31 @@ const beforeOnce = async (lab, cell, environment, index) => {
4000
4310
  }
4001
4311
  };
4002
4312
  };
4313
+ /** This function waits for the first ping of the heartbeat of the client at the instant `after` or later: its first ping into a stall. */
4314
+ const pingSent = (probe, after, within) => new Promise((resolve, reject) => {
4315
+ const started = Date.now();
4316
+ const look = () => {
4317
+ const ping = probe.events.find((event) => event.ev === "ping" && event.t >= after);
4318
+ if (ping !== void 0) resolve(ping);
4319
+ else if (Date.now() - started > within) reject(/* @__PURE__ */ new Error(`the client sent no ping into the stall within ${within} ms`));
4320
+ else setTimeout(look, 1);
4321
+ };
4322
+ look();
4323
+ });
4324
+ /**
4325
+ * This function waits until the client wrote `count` lines `dial`, one before each connection
4326
+ * that it tries, or until its deadline. It resolves after `within` ms at the latest.
4327
+ */
4328
+ const dials = (probe, count, within) => new Promise((resolve) => {
4329
+ const started = Date.now();
4330
+ const look = () => {
4331
+ const tried = probe.events.filter((event) => event.ev === "dial").length;
4332
+ const ended = probe.events.some((event) => event.ev === "deadline");
4333
+ if (tried >= count || ended || Date.now() - started > within) resolve();
4334
+ else setTimeout(look, 20);
4335
+ };
4336
+ look();
4337
+ });
4003
4338
  /** This function gives the netem specification of a cell: its latency, then the netem of its fault when it has one. */
4004
4339
  const netemSpec = (cell, fault) => {
4005
4340
  const parts = [...cell.latency === void 0 ? [] : [`delay ${valueIn(cell.latency, "ms")}ms`], ...fault === void 0 ? [] : [fault]];
@@ -4162,13 +4497,14 @@ const once = async (lab, cell, environment, index, dry = false) => {
4162
4497
  await probe.until(HTTP_DONE, (gap + window) * 2e3 + 1e4).catch(() => void 0);
4163
4498
  } else if (cell.fault === "stall") {
4164
4499
  if (cell.stall === void 0) throw new Error(`${cell.name} says no stall's length`);
4165
- const sent = await requestSent(probe, 5e3);
4166
- heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
4167
4500
  const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
4501
+ const sent = cell.probe.mode === "idle" ? await pingSent(probe, fault?.t1 ?? 0, window * 1e3) : await requestSent(probe, 5e3);
4502
+ heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
4168
4503
  await probe.until([
4169
4504
  "data",
4170
4505
  "error",
4171
- "eof"
4506
+ "eof",
4507
+ "drop"
4172
4508
  ], window * 1e3).catch(() => void 0);
4173
4509
  } else if (prediction.detects === null) {
4174
4510
  const window = valueIn(prediction.window, "s") * 1e3;
@@ -4180,12 +4516,13 @@ const once = async (lab, cell, environment, index, dry = false) => {
4180
4516
  ], window).catch(() => void 0);
4181
4517
  } else if (prediction.detects) {
4182
4518
  const within = valueIn(prediction.high, "s") * 2e3 + 1e4;
4183
- if (cell.observer !== void 0 && cell.observer !== "client") await observerError(cell, partial, within);
4519
+ if (cell.observer !== void 0 && cell.observer !== "client") await observerError(cell, partial, within, () => lab.server.slice(serverFrom).some((line) => line.ev === "drop"));
4184
4520
  else if (http && cell.probe.mode !== "idle") await probe.until(HTTP_DONE, within).catch(() => void 0);
4185
4521
  else await probe.until([
4186
4522
  "error",
4187
4523
  "eof",
4188
4524
  "deadline",
4525
+ "drop",
4189
4526
  ...http ? ["closed"] : []
4190
4527
  ], within).catch(() => void 0);
4191
4528
  } else await sleep(valueIn(prediction.window, "s") * 1e3);
@@ -4193,7 +4530,12 @@ const once = async (lab, cell, environment, index, dry = false) => {
4193
4530
  await lift();
4194
4531
  if (toxicFirst) await lab.toxic(null);
4195
4532
  }
4196
- if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
4533
+ if (cell.fault === "freeze-process" && !server && cell.probe.mode !== "idle") await probe.until(["data"], 5e3).catch(() => void 0);
4534
+ const expected = cell.prediction.detects === true ? cell.prediction.reconnects : void 0;
4535
+ if (expected !== void 0) {
4536
+ const waits = expected.waits.reduce((sum, wait) => sum + valueIn(wait.high, "s"), 0);
4537
+ await dials(probe, 1 + expected.waits.length, waits * 2e3 + 5e3);
4538
+ }
4197
4539
  }
4198
4540
  } finally {
4199
4541
  await probe.stop();
@@ -4348,8 +4690,13 @@ const runCell = async (cell, say = () => {}) => {
4348
4690
  const done = await Promise.all(batch.map(({ count, env }) => inEnvironment(cell, env, count, say)));
4349
4691
  for (const each of done) {
4350
4692
  manifests.push(each.manifest);
4351
- runs.push(...each.runs);
4352
- warmups.push(each.warmup);
4693
+ const hz = each.manifest.hz === null ? void 0 : Number(each.manifest.hz);
4694
+ const withHz = (run) => hz === void 0 ? run : {
4695
+ ...run,
4696
+ hz
4697
+ };
4698
+ runs.push(...each.runs.map(withHz));
4699
+ warmups.push(withHz(each.warmup));
4353
4700
  }
4354
4701
  }
4355
4702
  const { measured, verdict } = judge(cell, runs);
@@ -4370,6 +4717,6 @@ const runCell = async (cell, say = () => {}) => {
4370
4717
  }
4371
4718
  };
4372
4719
  //#endregion
4373
- export { CONVERGE_MS, KEPT, LINGER_MS, LISTING, MEMBER_MARKS, NOISE, PUMP, QUIET_MS, RELATIVE, REPLICA_SET_STAGES, STAGES, STATUS_SCRIPT, STEPS, STEP_DOWN, clockOffsetOf, crossingsOf, cutOf, deadlineOf, deviceOf, endpointAfter, errorsOf, fieldOf, fieldsOf, goHttpImage, groupOnce, groupVerdictOf, harnessImage, infoOf, initiateScript, isHopPlace, judge, lagsOf, matches, measure, measureDriver, measureGroup, medianInterval, memberCommand, memberConfig, memberLineOf, mongoClientImage, nodeClientImage, nodeHttpImage, onMonotonic, openGroupLab, openLab, openReplicaSetLab, peersOf, quantileOf, referenceOf, reorderingOf, replicaSetArgs, replicaSetOnce, replicaSetStagesOf, requestOf, requestWords, routerImage, routesOf, runCell, sentinelConfig, socketAt, socketOf, socketsOf, spread, spreadOf, stagesOf, statusViewOf, subscriptionsOf, templated, toxicBody, verdictOf, viewOf, writesOf };
4720
+ export { CONVERGE_MS, INJECT, KEPT, LINGER_MS, LISTING, MEMBER_MARKS, NOISE, PUMP, QUIET_MS, RELATIVE, REPLICA_SET_STAGES, STAGES, STATUS_SCRIPT, STEPS, STEP_DOWN, callOf, clockOffsetOf, crossingsOf, cutOf, deadlineOf, delayVoids, deviceOf, dropFinding, dropOf, endpointAfter, errorsOf, fieldOf, fieldsOf, firstUnanswered, goHttpImage, groupOnce, groupVerdictOf, harnessImage, hzOf, infoOf, initiateScript, isHopPlace, judge, lagsOf, leftAfter, matches, measure, measureDriver, measureGroup, medianInterval, memberCommand, memberConfig, memberLineOf, mongoClientImage, nodeClientImage, nodeHttpImage, noiseOf, onMonotonic, openGroupLab, openLab, openReplicaSetLab, peersOf, pingsOf, proceduresImage, programLines, quantileOf, reconnectFinding, reconnectOf, referenceOf, reorderingOf, replicaSetArgs, replicaSetOnce, replicaSetStagesOf, requestOf, requestWords, routerImage, routesOf, runCell, sentinelConfig, socketAt, socketOf, socketsOf, spread, spreadOf, stagesOf, statusViewOf, subscriptionsOf, targetImageOf, templated, toxicBody, verdictOf, viewOf, writesOf };
4374
4721
 
4375
4722
  //# sourceMappingURL=index.js.map