@mark1russell7/harness 0.7.0 → 0.8.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
@@ -119,7 +119,8 @@ const middle = (sorted) => {
119
119
  const verdictOf = (prediction, runs) => {
120
120
  const band = verdictOfBand(prediction, runs);
121
121
  const counted = runs.filter((run) => run.void === null);
122
- const verdict = counted.some((run) => run.request !== void 0) ? withRequests(band, counted) : band;
122
+ const asked = counted.some((run) => run.request !== void 0) ? withRequests(band, counted) : band;
123
+ const verdict = withProgram(asked, counted);
123
124
  if (prediction.group === void 0 && !counted.some((run) => run.group !== void 0)) return verdict;
124
125
  return {
125
126
  ...verdict,
@@ -127,6 +128,43 @@ const verdictOf = (prediction, runs) => {
127
128
  };
128
129
  };
129
130
  /**
131
+ * This function adds the reconnect of the client and the outcomes of its call in flight to a
132
+ * verdict. Its reason gets a sentence for each. A verdict whose runs have neither stays as it is.
133
+ */
134
+ const withProgram = (verdict, counted) => {
135
+ const reconnects = counted.flatMap((run) => run.reconnect === void 0 ? [] : [run.reconnect]);
136
+ const calls = counted.flatMap((run) => run.call === void 0 ? [] : [run.call]);
137
+ const lefts = counted.flatMap((run) => run.left === void 0 ? [] : [run.left]);
138
+ if (reconnects.length === 0 && calls.length === 0 && lefts.length === 0) return verdict;
139
+ const ms = (spread) => spread === null ? "none" : `${Math.round(valueIn(spread.median, "s") * 1e6) / 1e3} ms`;
140
+ const tries = Math.max(0, ...reconnects.map((each) => each.waits.length));
141
+ const reconnect = reconnects.length === 0 ? void 0 : {
142
+ waits: Array.from({ length: tries }, (_, at) => spreadOf(reconnects.flatMap((each) => each.waits[at] === void 0 ? [] : [each.waits[at]]))),
143
+ syn: spreadOf(reconnects.flatMap((each) => each.syn === null ? [] : [each.syn])),
144
+ established: spreadOf(reconnects.flatMap((each) => each.established === null ? [] : [each.established])),
145
+ connect: spreadOf(reconnects.flatMap((each) => each.connects))
146
+ };
147
+ const outcomes = {};
148
+ for (const call of calls) outcomes[call.outcome] = (outcomes[call.outcome] ?? 0) + 1;
149
+ const gone = lefts.flatMap((each) => each === null ? [] : [each]);
150
+ const left = lefts.length === 0 ? void 0 : {
151
+ spread: spreadOf(gone),
152
+ stayed: lefts.length - gone.length
153
+ };
154
+ const said = [
155
+ ...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}`}.`],
156
+ ...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.`],
157
+ ...calls.length === 0 ? [] : [`Calls in flight: ${Object.entries(outcomes).map(([outcome, n]) => `${n} ${outcome}`).join("; ")}.`]
158
+ ];
159
+ return {
160
+ ...verdict,
161
+ ...reconnect === void 0 ? {} : { reconnect },
162
+ ...calls.length === 0 ? {} : { calls: outcomes },
163
+ ...left === void 0 ? {} : { left },
164
+ why: `${verdict.why.endsWith(".") ? verdict.why : `${verdict.why}.`} ${said.join(" ")}`
165
+ };
166
+ };
167
+ /**
130
168
  * This function adds the outcomes of the second request of an HTTP client to a verdict. A cell
131
169
  * whose outcome is a distribution gets them in its reason too.
132
170
  */
@@ -383,6 +421,150 @@ const verdictOfBand = (prediction, runs) => {
383
421
  };
384
422
  };
385
423
  //#endregion
424
+ //#region src/application.ts
425
+ /**
426
+ * This module reads the acts of the program of the observer: its heartbeat, its drop of the
427
+ * connection, its reconnect and its call in flight. The kernel has no report of a detection by
428
+ * a program. Thus the program writes a line before each of its own acts, on CLOCK_MONOTONIC:
429
+ * - `ping` before each ping of its heartbeat, and `drop` before it closes the connection by itself.
430
+ * - `reconnecting` when it starts its reconnect timer, and `dial` before each new connection.
431
+ *
432
+ * The kernel then stamps each write of the program on the socket (`tx`) and each change of the
433
+ * state of the socket. A line of the client names its socket by its local address (`local`), and
434
+ * a line of a server names it by its peer (`peer`).
435
+ */
436
+ /** This function gives the interval between two instants on the one clock, in seconds to the microsecond. */
437
+ const between$3 = (from, to) => quantity(Math.round((to - from) / 1e3) / 1e6, "s");
438
+ /** This function gives the state of a state record after the change, and before it. TCP_ESTABLISHED is 1, TCP_SYN_SENT 2. */
439
+ const newState$2 = (record) => record.new;
440
+ const oldState$2 = (record) => record.old;
441
+ /** This function gives the lines of the program of the observer: the client, or the target for the server. A proxy has no lines. */
442
+ const programLines = (cell, run) => {
443
+ const observer = cell.observer ?? "client";
444
+ if (observer === "client") return run.events;
445
+ if (observer === "server") return run.server ?? [];
446
+ return [];
447
+ };
448
+ /**
449
+ * This function tells if a line of the program is about the connection of the run. The client
450
+ * names it by its local address, and a server by its peer. A line with neither is about the one
451
+ * connection of its program. A line whose address is null is about a socket that did not open:
452
+ * a try that failed.
453
+ */
454
+ const onConnection = (line, local) => {
455
+ if (!("local" in line) && !("peer" in line)) return true;
456
+ return (line["local"] ?? line["peer"]) === local;
457
+ };
458
+ /** This function gives the first record of a socket that matches, at the stamp of a line or in δ_abs after it. */
459
+ const kernelAt = (socket, line, matches) => {
460
+ const noise = valueIn(NOISE, "s") * 1e9;
461
+ return socket.find((record) => record.mono >= line.t && record.mono <= line.t + noise && matches(record));
462
+ };
463
+ const eventOf = (socket, line, matches) => {
464
+ const record = kernelAt(socket, line, matches);
465
+ return {
466
+ line,
467
+ at: record?.mono ?? line.t,
468
+ kernel: record !== void 0,
469
+ record: record === void 0 ? null : record.ev === "tx" ? "write" : "state"
470
+ };
471
+ };
472
+ /**
473
+ * This function gives the time from a line of the program to the close of the socket by its own
474
+ * side. That close is its change from ESTABLISHED to FIN_WAIT1, or to CLOSE (an abort). A FIN of
475
+ * the peer (CLOSE_WAIT) is not such a close. It is null when the socket did not close itself.
476
+ */
477
+ const leftAfter = (socket, line) => {
478
+ const left = socket.find((record) => record.ev === "state" && oldState$2(record) === 1 && (newState$2(record) === 4 || newState$2(record) === 7) && record.mono >= line.t);
479
+ return left === void 0 ? null : between$3(line.t, left.mono);
480
+ };
481
+ /**
482
+ * This function tells how a drop differs from the way that its prediction gives, or gives null.
483
+ * A close closes the socket in δ_abs. A close frame is a write, and the program does not close
484
+ * the socket at the drop. A peer that answers the close frame can close it, and that is no finding.
485
+ */
486
+ const dropFinding = (expected, drop, left) => {
487
+ const noise = valueIn(NOISE, "s");
488
+ const soon = left !== null && valueIn(left, "s") <= noise;
489
+ if (expected === "close") {
490
+ if (left === null) return "the program did not close its socket after the drop, where a close is at once";
491
+ return soon ? null : `the program closed its socket ${left.value} s after the drop, not at once`;
492
+ }
493
+ if (drop.record !== "write") return "the drop wrote no close frame on its socket";
494
+ return soon ? "the program closed its socket at the drop, where a close frame leaves it open" : null;
495
+ };
496
+ const isWrite = (record) => record.ev === "tx";
497
+ /** This function gives each ping of the program on the connection of the run, with the kernel's stamp of its write. */
498
+ const pingsOf = (lines, socket, local) => lines.filter((line) => line.ev === "ping" && onConnection(line, local)).map((line) => eventOf(socket, line, isWrite));
499
+ /**
500
+ * This function gives the first ping that got no answer: no segment with data reached the socket
501
+ * after its write, up to `until`. Its write starts the wait of the heartbeat that the fault met.
502
+ */
503
+ 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));
504
+ /**
505
+ * This function gives the first drop of the connection by its program. The kernel stamps it as the
506
+ * write of a close frame. Or it stamps the change of the socket out of ESTABLISHED: a close or an
507
+ * abort.
508
+ */
509
+ const dropOf = (lines, socket, local) => {
510
+ const line = lines.find((each) => each.ev === "drop" && onConnection(each, local));
511
+ return line === void 0 ? void 0 : eventOf(socket, line, (record) => isWrite(record) || record.ev === "state" && oldState$2(record) === 1);
512
+ };
513
+ /**
514
+ * This function reads the reconnect of the client after the detection at `detected`. The socket of
515
+ * the run has the cookie `cookie`. The lines of the program count from `from`: the drop line of a
516
+ * drop, which comes before the kernel's stamp of it.
517
+ */
518
+ const reconnectOf = (run, detected, cookie, from = detected) => {
519
+ const after = run.events.filter((line) => line.t > from);
520
+ const starts = after.filter((line) => line.ev === "reconnecting");
521
+ const dials = after.filter((line) => line.ev === "dial");
522
+ const waits = starts.flatMap((start, at) => {
523
+ const next = starts[at + 1]?.t ?? Number.POSITIVE_INFINITY;
524
+ const dial = dials.find((each) => each.t >= start.t && each.t < next);
525
+ return dial === void 0 ? [] : [between$3(start.t, dial.t)];
526
+ });
527
+ const fresh = run.kernel.filter((record) => record.netns === run.netns && record.cookie !== cookie && record.ev === "state" && record.mono > detected);
528
+ const syns = fresh.filter((record) => newState$2(record) === 2);
529
+ const established = fresh.find((record) => newState$2(record) === 1);
530
+ const connects = dials.flatMap((dial) => {
531
+ const syn = syns.find((record) => record.mono >= dial.t);
532
+ return syn === void 0 ? [] : [between$3(dial.t, syn.mono)];
533
+ });
534
+ return {
535
+ waits,
536
+ syn: syns[0] === void 0 ? null : between$3(detected, syns[0].mono),
537
+ established: established === void 0 ? null : between$3(detected, established.mono),
538
+ connects
539
+ };
540
+ };
541
+ /** This function tells how the waits of a reconnect differ from their bands, each widened by δ_abs, or gives null. */
542
+ const reconnectFinding = (expected, read) => {
543
+ const noise = valueIn(NOISE, "s");
544
+ const round = (value) => Math.round(value * 1e6) / 1e6;
545
+ const found = expected.waits.flatMap((band, at) => {
546
+ const wait = read.waits[at];
547
+ if (wait === void 0) return [`the client made no try ${at + 1} after the detection`];
548
+ const value = valueIn(wait, "s");
549
+ const low = round(valueIn(band.low, "s") - noise);
550
+ const high = round(valueIn(band.high, "s") + noise);
551
+ return value < low || value > high ? [`its wait before try ${at + 1} was ${value} s, outside [${low}, ${high}] s`] : [];
552
+ });
553
+ return found.length === 0 ? null : found.join(". ");
554
+ };
555
+ /** This function reads the call that the client sent into the fault: its `call-error` or `data` line after the request. */
556
+ const callOf = (events, request, detected) => {
557
+ const end = events.find((line) => (line.ev === "call-error" || line.ev === "data") && line.t > request.t);
558
+ if (end === void 0) return {
559
+ outcome: "pending",
560
+ after: null
561
+ };
562
+ return {
563
+ outcome: end.ev === "data" ? "a reply" : String(end["message"] ?? end["code"] ?? "an error"),
564
+ after: detected === void 0 ? null : between$3(detected, end.t)
565
+ };
566
+ };
567
+ //#endregion
386
568
  //#region src/driver.ts
387
569
  /** These are the errno numbers that the kernel reports to a socket, by the names that predictions use. */
388
570
  const ERRNO$1 = {
@@ -1118,9 +1300,10 @@ const newState = (record) => record.new;
1118
1300
  const count = (event, field) => event === void 0 ? 0 : Number(event[field] ?? 0);
1119
1301
  /** This function gives the view of the client on the same interval: TCP_INFO, with a sample each 2 ms. */
1120
1302
  const socketVantage = (cell, events, ready, request) => {
1121
- if (!cell.prediction.detects || cell.prediction.from === "peer-closed" || cell.prediction.from === "fault") return null;
1303
+ const { from } = cell.prediction.detects === true ? cell.prediction : { from: void 0 };
1304
+ if (from === void 0 || from === "peer-closed" || from === "fault" || from === "ping-sent") return null;
1122
1305
  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);
1306
+ 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
1307
  return ended === void 0 || reference === void 0 ? null : between(reference.t, ended.t);
1125
1308
  };
1126
1309
  const ipOf = (address) => address.split(":")[0];
@@ -1418,21 +1601,28 @@ const measureSocket = (cell, run) => {
1418
1601
  const seen = remote ? void 0 : events.find((event) => event.ev === "error" || event.ev === "eof");
1419
1602
  const closeWait = socket.find((record) => record.ev === "state" && newState(record) === 8);
1420
1603
  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 ? {
1604
+ const lines = programLines(cell, run);
1605
+ const kernelEnded = reported?.mono ?? closeWait?.mono ?? own?.mono ?? seen?.t;
1606
+ const first = dropOf(lines, socket, local);
1607
+ const drop = first !== void 0 && (kernelEnded === void 0 || first.at <= kernelEnded) ? first : void 0;
1608
+ const dropped = drop !== void 0;
1609
+ const outcome = dropped ? { kind: "dropped" } : reported !== void 0 ? {
1422
1610
  kind: "error",
1423
1611
  errno: ERRNO[reported.err] ?? `E${reported.err}`
1424
1612
  } : closeWait !== void 0 || seen?.ev === "eof" ? { kind: "eof" } : own !== void 0 ? { kind: "closed" } : seen !== void 0 ? {
1425
1613
  kind: "error",
1426
1614
  errno: String(seen["errno"])
1427
1615
  } : { 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;
1616
+ const ended = drop?.at ?? kernelEnded;
1617
+ const noticed = reported !== void 0 && seen !== void 0 && !dropped ? between(reported.mono, seen.t) : null;
1430
1618
  const { prediction } = cell;
1619
+ const pings = pingsOf(lines, socket, local);
1431
1620
  const before = run.fault?.t1 ?? ready.t;
1432
1621
  const referenceAt = (from) => {
1433
1622
  if (from === "request-sent") return request.t;
1434
1623
  if (from === "fault") return run.fault?.t0;
1435
1624
  if (from === "client-start") return (events.find((event) => event.ev === "connecting") ?? events[0])?.t;
1625
+ if (from === "ping-sent") return firstUnanswered(pings, socket, ended)?.at;
1436
1626
  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
1627
  };
1438
1628
  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 +1671,22 @@ const measureSocket = (cell, run) => {
1481
1671
  }
1482
1672
  if (!prediction.detects && prediction.recovers !== void 0) {
1483
1673
  const { recovers } = prediction;
1674
+ const start = cell.probe.mode === "idle" ? lines.find((line) => line.ev === "ping" && line.t >= (run.fault?.t1 ?? 0)) ?? request : request;
1484
1675
  const heal = run.heal ?? null;
1485
1676
  if (voided === null && heal === null) voided = "the stall never ended";
1486
1677
  else if (voided === null && heal !== null) {
1487
- const from = between(request.t, heal.t0);
1488
- const to = between(request.t, heal.t1);
1678
+ const from = between(start.t, heal.t0);
1679
+ const to = between(start.t, heal.t1);
1489
1680
  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
1681
  }
1491
- const steps = retransmitted.filter((record) => record.mono > request.t).map((record) => between(request.t, record.mono));
1682
+ const steps = retransmitted.filter((record) => record.mono > start.t).map((record) => between(start.t, record.mono));
1492
1683
  if (ended !== void 0) return {
1493
1684
  ...base,
1494
1685
  ...nothing,
1495
1686
  outcome,
1496
1687
  steps,
1497
1688
  void: voided,
1498
- finding: `detected after ${between(request.t, ended).value} s, where it should recover`
1689
+ finding: `detected after ${between(start.t, ended).value} s, where it should recover`
1499
1690
  };
1500
1691
  const answer = heal === null ? void 0 : socket.find((record) => record.ev === "rx" && record.mono > heal.t0);
1501
1692
  if (answer === void 0) return {
@@ -1505,13 +1696,13 @@ const measureSocket = (cell, run) => {
1505
1696
  void: voided,
1506
1697
  finding: "it never moved again: nothing came back after the stall ended"
1507
1698
  };
1508
- const reply = events.find((event) => event.ev === "data" && event.t > request.t);
1509
- voided ??= delayed(request.t, answer.mono, "retransmit");
1699
+ const reply = events.find((event) => event.ev === "data" && event.t > start.t);
1700
+ voided ??= delayed(start.t, answer.mono, "retransmit");
1510
1701
  return {
1511
1702
  ...base,
1512
1703
  outcome: { kind: "answered" },
1513
- elapsed: between(request.t, answer.mono),
1514
- socket: reply === void 0 ? null : between(request.t, reply.t),
1704
+ elapsed: between(start.t, answer.mono),
1705
+ socket: reply === void 0 ? null : between(start.t, reply.t),
1515
1706
  noticed: reply === void 0 ? null : between(answer.mono, reply.t),
1516
1707
  steps,
1517
1708
  void: voided,
@@ -1601,23 +1792,41 @@ const measureSocket = (cell, run) => {
1601
1792
  void: voided,
1602
1793
  finding: `it ended without its reference event (${prediction.from})`
1603
1794
  };
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}`;
1795
+ 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));
1796
+ 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;
1797
+ 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";
1798
+ const reconnect = prediction.reconnects === void 0 ? void 0 : reconnectOf(run, ended, cookie, drop?.line.t ?? ended);
1799
+ const late = reconnect === void 0 || prediction.reconnects === void 0 ? null : reconnectFinding(prediction.reconnects, reconnect);
1800
+ const left = prediction.drop === void 0 || drop === void 0 ? void 0 : leftAfter(socket, drop.line);
1801
+ const how = prediction.drop === void 0 || drop === void 0 || left === void 0 ? null : dropFinding(prediction.drop, drop, left);
1802
+ const found = [
1803
+ matches ? null : `it ended with ${words}, not ${prediction.errno}`,
1804
+ late,
1805
+ how
1806
+ ].filter((each) => each !== null);
1807
+ const finding = found.length === 0 ? null : found.join(". ");
1606
1808
  const programTimer = cell.probe.http !== void 0 && prediction.from === "last-received" && prediction.timer !== "keepalive";
1607
1809
  if (prediction.from === "last-received" && !programTimer || prediction.from === "first-retransmission") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
1810
+ if (prediction.from === "ping-sent") voided ??= delayed(reference, ended, "retransmit");
1608
1811
  const lag = (valueIn(prediction.high, "s") - valueIn(prediction.low, "s")) * 1e9;
1609
1812
  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
1813
  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
1814
  const commit = run.fault === null ? 0 : (run.fault.t1 - run.fault.t0) / 1e6;
1612
1815
  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`;
1816
+ const unanswered = prediction.from === "ping-sent" ? firstUnanswered(pings, socket, ended) : void 0;
1817
+ const endLine = drop?.line ?? seen;
1818
+ const view = unanswered !== void 0 ? endLine === void 0 ? null : between(unanswered.line.t, endLine.t) : remote ? null : socketVantage(cell, events, ready, request);
1613
1819
  return {
1614
1820
  ...base,
1615
1821
  ...chain,
1616
1822
  outcome,
1617
1823
  elapsed: between(reference, ended),
1618
- socket: remote ? null : socketVantage(cell, events, ready, request),
1824
+ socket: view,
1619
1825
  noticed,
1620
1826
  steps,
1827
+ ...reconnect === void 0 ? {} : { reconnect },
1828
+ ...left === void 0 ? {} : { left },
1829
+ ...cell.probe.ws !== void 0 && inflight ? { call: callOf(events, request, ended) } : {},
1621
1830
  void: voided,
1622
1831
  finding
1623
1832
  };
@@ -1679,6 +1888,12 @@ const MONGO = {
1679
1888
  dockerfile: "harness/image/mongo/Dockerfile",
1680
1889
  from: ["harness/image/mongo"]
1681
1890
  };
1891
+ /** 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. */
1892
+ const PROCEDURES = {
1893
+ name: "loom-harness-procedures",
1894
+ dockerfile: "harness/image/procedures/Dockerfile",
1895
+ from: ["harness/image/procedures"]
1896
+ };
1682
1897
  /** This image is the router of a middlebox chain: NAT and conntrack, with a form for a forgotten flow. */
1683
1898
  const ROUTER = {
1684
1899
  name: "loom-harness-router",
@@ -1751,6 +1966,10 @@ const goHttpImage = () => build(GO_HTTP);
1751
1966
  const nodeHttpImage = () => build(NODE_HTTP);
1752
1967
  /** This function gives the image of the MongoDB client. */
1753
1968
  const mongoClientImage = () => build(MONGO);
1969
+ /** This function gives the image of `@procedures/client`: its WebSocket client and its server. */
1970
+ const proceduresImage = () => build(PROCEDURES);
1971
+ /** This function gives the image that a target operates: its stock image, or an image of the harness (`Target.harness`). */
1972
+ const targetImageOf = (target) => target.harness === "procedures" ? proceduresImage() : Promise.resolve(target.image);
1754
1973
  /**
1755
1974
  * This time is the interval of the listing of a router, when its hop does not set one. A listing
1756
1975
  * evicts each expired flow that it walks past, and the DESTROY event of that flow follows.
@@ -1908,6 +2127,11 @@ const clientOf = async (kind, image) => {
1908
2127
  command: ["node", "/opt/harness/mongo/client-mongo.mjs"],
1909
2128
  clientKind: kind
1910
2129
  };
2130
+ if (kind === "procedures-ws") return {
2131
+ clientImage: await proceduresImage(),
2132
+ command: ["node", "/opt/harness/procedures/client-ws.mjs"],
2133
+ clientKind: kind
2134
+ };
1911
2135
  return {
1912
2136
  clientImage: image,
1913
2137
  command: ["/opt/harness/client"],
@@ -1968,7 +2192,8 @@ const openLab = async (target, kind = "tcp", topology) => {
1968
2192
  try {
1969
2193
  for (let at = 0; at <= hops.length; at += 1) networks.push(await new Network().start());
1970
2194
  const last = networks[hops.length];
1971
- let stock = new GenericContainer(target.image).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
2195
+ const targetImage = await targetImageOf(target);
2196
+ let stock = new GenericContainer(targetImage).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
1972
2197
  if (target.files !== void 0) stock = stock.withCopyContentToContainer(Object.entries(target.files).map(([path, content]) => ({
1973
2198
  content,
1974
2199
  target: path
@@ -2502,7 +2727,11 @@ const openLab = async (target, kind = "tcp", topology) => {
2502
2727
  boot_id: await output(client, ["cat", "/proc/sys/kernel/random/boot_id"]),
2503
2728
  docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
2504
2729
  target: {
2505
- image: target.image,
2730
+ image: targetImage,
2731
+ ...target.harness === void 0 ? {} : {
2732
+ harness: target.harness,
2733
+ base: target.image
2734
+ },
2506
2735
  id: (await inspect(server)).Image,
2507
2736
  settings,
2508
2737
  sysctls: targetSysctls,
@@ -3857,6 +4086,20 @@ const probeArgs = (cell, lab) => {
3857
4086
  "--mode",
3858
4087
  probe.mode
3859
4088
  ];
4089
+ if (probe.ws !== void 0) {
4090
+ const { ws } = probe;
4091
+ const waits = (prediction.detects === true ? prediction.reconnects?.waits ?? [] : []).reduce((sum, wait) => sum + valueIn(wait.high, "s"), 0);
4092
+ args.push("--websocket", ws.socket);
4093
+ args.push("--heartbeat", ws.heartbeat === null ? "off" : "on");
4094
+ 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));
4095
+ args.push("--reconnect", ws.reconnect === null ? "off" : "on");
4096
+ 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));
4097
+ args.push("--request-timeout-ms", String(valueIn(ws.requestTimeout, "ms")));
4098
+ args.push("--connection-timeout-ms", String(valueIn(ws.connectionTimeout, "ms")));
4099
+ if (ws.hold !== void 0) args.push("--hold-ms", String(valueIn(ws.hold, "ms")));
4100
+ args.push("--max-s", String(Math.ceil((longest + waits) * 2 + 10)));
4101
+ return args;
4102
+ }
3860
4103
  if (probe.http !== void 0) {
3861
4104
  const { http } = probe;
3862
4105
  const gap = http.gap === void 0 ? 0 : valueIn(http.gap, "s");
@@ -3894,14 +4137,15 @@ const probeArgs = (cell, lab) => {
3894
4137
  /**
3895
4138
  * This function waits for an error on the socket of the observer, or for its end of file
3896
4139
  * (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.
4140
+ * of a topology, waits for that. A target with its own detector can also drop the connection
4141
+ * itself (`dropped`). The promise resolves after `within` ms at the latest.
3898
4142
  */
3899
- const observerError = (cell, partial, within) => new Promise((resolve) => {
4143
+ const observerError = (cell, partial, within, dropped = () => false) => new Promise((resolve) => {
3900
4144
  const started = Date.now();
3901
4145
  const look = () => {
3902
4146
  const run = partial();
3903
4147
  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();
4148
+ 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
4149
  else setTimeout(look, 50);
3906
4150
  };
3907
4151
  look();
@@ -4000,6 +4244,31 @@ const beforeOnce = async (lab, cell, environment, index) => {
4000
4244
  }
4001
4245
  };
4002
4246
  };
4247
+ /** 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. */
4248
+ const pingSent = (probe, after, within) => new Promise((resolve, reject) => {
4249
+ const started = Date.now();
4250
+ const look = () => {
4251
+ const ping = probe.events.find((event) => event.ev === "ping" && event.t >= after);
4252
+ if (ping !== void 0) resolve(ping);
4253
+ else if (Date.now() - started > within) reject(/* @__PURE__ */ new Error(`the client sent no ping into the stall within ${within} ms`));
4254
+ else setTimeout(look, 1);
4255
+ };
4256
+ look();
4257
+ });
4258
+ /**
4259
+ * This function waits until the client wrote `count` lines `dial`, one before each connection
4260
+ * that it tries, or until its deadline. It resolves after `within` ms at the latest.
4261
+ */
4262
+ const dials = (probe, count, within) => new Promise((resolve) => {
4263
+ const started = Date.now();
4264
+ const look = () => {
4265
+ const tried = probe.events.filter((event) => event.ev === "dial").length;
4266
+ const ended = probe.events.some((event) => event.ev === "deadline");
4267
+ if (tried >= count || ended || Date.now() - started > within) resolve();
4268
+ else setTimeout(look, 20);
4269
+ };
4270
+ look();
4271
+ });
4003
4272
  /** This function gives the netem specification of a cell: its latency, then the netem of its fault when it has one. */
4004
4273
  const netemSpec = (cell, fault) => {
4005
4274
  const parts = [...cell.latency === void 0 ? [] : [`delay ${valueIn(cell.latency, "ms")}ms`], ...fault === void 0 ? [] : [fault]];
@@ -4162,13 +4431,14 @@ const once = async (lab, cell, environment, index, dry = false) => {
4162
4431
  await probe.until(HTTP_DONE, (gap + window) * 2e3 + 1e4).catch(() => void 0);
4163
4432
  } else if (cell.fault === "stall") {
4164
4433
  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
4434
  const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
4435
+ const sent = cell.probe.mode === "idle" ? await pingSent(probe, fault?.t1 ?? 0, window * 1e3) : await requestSent(probe, 5e3);
4436
+ heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
4168
4437
  await probe.until([
4169
4438
  "data",
4170
4439
  "error",
4171
- "eof"
4440
+ "eof",
4441
+ "drop"
4172
4442
  ], window * 1e3).catch(() => void 0);
4173
4443
  } else if (prediction.detects === null) {
4174
4444
  const window = valueIn(prediction.window, "s") * 1e3;
@@ -4180,12 +4450,13 @@ const once = async (lab, cell, environment, index, dry = false) => {
4180
4450
  ], window).catch(() => void 0);
4181
4451
  } else if (prediction.detects) {
4182
4452
  const within = valueIn(prediction.high, "s") * 2e3 + 1e4;
4183
- if (cell.observer !== void 0 && cell.observer !== "client") await observerError(cell, partial, within);
4453
+ if (cell.observer !== void 0 && cell.observer !== "client") await observerError(cell, partial, within, () => lab.server.slice(serverFrom).some((line) => line.ev === "drop"));
4184
4454
  else if (http && cell.probe.mode !== "idle") await probe.until(HTTP_DONE, within).catch(() => void 0);
4185
4455
  else await probe.until([
4186
4456
  "error",
4187
4457
  "eof",
4188
4458
  "deadline",
4459
+ "drop",
4189
4460
  ...http ? ["closed"] : []
4190
4461
  ], within).catch(() => void 0);
4191
4462
  } else await sleep(valueIn(prediction.window, "s") * 1e3);
@@ -4193,7 +4464,12 @@ const once = async (lab, cell, environment, index, dry = false) => {
4193
4464
  await lift();
4194
4465
  if (toxicFirst) await lab.toxic(null);
4195
4466
  }
4196
- if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
4467
+ if (cell.fault === "freeze-process" && !server && cell.probe.mode !== "idle") await probe.until(["data"], 5e3).catch(() => void 0);
4468
+ const expected = cell.prediction.detects === true ? cell.prediction.reconnects : void 0;
4469
+ if (expected !== void 0) {
4470
+ const waits = expected.waits.reduce((sum, wait) => sum + valueIn(wait.high, "s"), 0);
4471
+ await dials(probe, 1 + expected.waits.length, waits * 2e3 + 5e3);
4472
+ }
4197
4473
  }
4198
4474
  } finally {
4199
4475
  await probe.stop();
@@ -4370,6 +4646,6 @@ const runCell = async (cell, say = () => {}) => {
4370
4646
  }
4371
4647
  };
4372
4648
  //#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 };
4649
+ export { CONVERGE_MS, 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, deviceOf, dropFinding, dropOf, endpointAfter, errorsOf, fieldOf, fieldsOf, firstUnanswered, goHttpImage, groupOnce, groupVerdictOf, harnessImage, infoOf, initiateScript, isHopPlace, judge, lagsOf, leftAfter, matches, measure, measureDriver, measureGroup, medianInterval, memberCommand, memberConfig, memberLineOf, mongoClientImage, nodeClientImage, nodeHttpImage, 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
4650
 
4375
4651
  //# sourceMappingURL=index.js.map