@mark1russell7/harness 0.5.0 → 0.6.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
@@ -10,6 +10,8 @@ import { fileURLToPath } from "node:url";
10
10
  const isHopPlace = (place) => typeof place === "object" && "hop" in place;
11
11
  //#endregion
12
12
  //#region src/verdict.ts
13
+ /** This function gives the words of the outcome of a second request: its connection, a retry, and its end. */
14
+ const requestWords = (outcome) => `${outcome.connection}${outcome.retried ? ", a retry" : ""}, ${outcome.pending === true ? "pending" : outcome.error ?? "a reply"}`;
13
15
  /**
14
16
  * This amount is the noise of the pipeline itself, δ_abs, as the calibration measured it. It is
15
17
  * the 99.9th percentile of the whole latency of a reset, at the concurrency cap or less. Its
@@ -115,14 +117,32 @@ const middle = (sorted) => {
115
117
  * which does not exist at this time.
116
118
  */
117
119
  const verdictOf = (prediction, runs) => {
118
- const verdict = verdictOfBand(prediction, runs);
120
+ const band = verdictOfBand(prediction, runs);
119
121
  const counted = runs.filter((run) => run.void === null);
122
+ const verdict = counted.some((run) => run.request !== void 0) ? withRequests(band, counted) : band;
120
123
  if (prediction.group === void 0 && !counted.some((run) => run.group !== void 0)) return verdict;
121
124
  return {
122
125
  ...verdict,
123
126
  group: groupVerdictOf(prediction, counted)
124
127
  };
125
128
  };
129
+ /**
130
+ * This function adds the outcomes of the second request of an HTTP client to a verdict. A cell
131
+ * whose outcome is a distribution gets them in its reason too.
132
+ */
133
+ const withRequests = (verdict, counted) => {
134
+ const requests = {};
135
+ for (const run of counted) {
136
+ const words = run.request === null || run.request === void 0 ? "no second request" : requestWords(run.request);
137
+ requests[words] = (requests[words] ?? 0) + 1;
138
+ }
139
+ const listed = Object.entries(requests).map(([words, n]) => `${n} ${words}`).join("; ");
140
+ return {
141
+ ...verdict,
142
+ requests,
143
+ why: verdict.decision === "measured" ? `${verdict.why} Second requests: ${listed}.` : verdict.why
144
+ };
145
+ };
126
146
  /** This function gives the median of some counts, or null for none. */
127
147
  const medianCount = (values) => values.length === 0 ? null : middle([...values].sort((a, b) => a - b));
128
148
  /** This function sums what the group of a group cell did in its counted runs, and tests each stage that has a band. */
@@ -761,8 +781,60 @@ const crossingsOf = (run, hop, toClient, toTarget) => {
761
781
  * it, and before the first keepalive probe. A live peer answers that probe, and the answer
762
782
  * starts the idle time again. A run where the fault was late is void. A run whose records the
763
783
  * prober lost is also void.
784
+ * - For an HTTP client, it also reads what the second request met (`requestOf`). An outcome other
785
+ * than the one that the prediction gives is a finding.
764
786
  */
765
787
  const measure = (cell, run) => {
788
+ const read = measureSocket(cell, run);
789
+ const expected = cell.prediction.request;
790
+ if (cell.probe.http === void 0 && expected === void 0) return read;
791
+ const request = requestOf(run.events);
792
+ if (expected === void 0 || read.void !== null) return {
793
+ ...read,
794
+ request
795
+ };
796
+ const differs = requestFinding(expected, request);
797
+ const finding = read.finding === null ? differs : differs === null ? read.finding : `${read.finding}. ${differs}`;
798
+ return {
799
+ ...read,
800
+ request,
801
+ finding
802
+ };
803
+ };
804
+ /** This function tells how a request reading differs from its prediction, or gives null. */
805
+ const requestFinding = (expected, read) => {
806
+ if (read === null) return `the second request never went out, where it should meet: ${requestWords(expected)}`;
807
+ return read.connection === expected.connection && read.retried === expected.retried && read.error === expected.error && read.pending === (expected.pending === true) ? null : `the second request met: ${requestWords(read)}, not ${requestWords(expected)}`;
808
+ };
809
+ /**
810
+ * This function reads the second request of an HTTP client from its lines. Its first try is its
811
+ * first `request` line, and each `retry` line is a new try. Its end is its `error` line, or its
812
+ * `replied` line. A request with neither is pending. It gives null when the client wrote no try.
813
+ *
814
+ * A new try that gets no connection writes no `retry` line. Its dial writes a `dial-error` line.
815
+ * Thus a `dial-error` line after the first try and before the end is also a new try.
816
+ */
817
+ const requestOf = (events) => {
818
+ const of = (event) => Number(event["i"]) === 2;
819
+ const tries = events.filter((event) => (event.ev === "request" || event.ev === "retry") && of(event));
820
+ const first = tries[0];
821
+ if (first === void 0) return null;
822
+ const failed = events.find((event) => event.ev === "error" && of(event));
823
+ const replied = events.some((event) => event.ev === "replied" && of(event));
824
+ const ready = events.find((event) => event.ev === "ready");
825
+ const sent = events.find((event) => event.ev === "sent" && of(event));
826
+ const end = failed ?? events.find((event) => event.ev === "done" && of(event));
827
+ const dialed = events.some((event) => event.ev === "dial-error" && event.t > first.t && (end === void 0 || event.t <= end.t));
828
+ return {
829
+ connection: first["reused"] === true ? "reused" : "new",
830
+ retried: dialed || tries.some((each) => each.ev === "retry"),
831
+ error: failed === void 0 ? null : String(failed["errno"]),
832
+ pending: failed === void 0 && !replied,
833
+ gap: ready === void 0 || sent === void 0 ? null : between(ready.t, sent.t)
834
+ };
835
+ };
836
+ /** This function reads the socket of one run against its cell, as `measure` does without the second request of an HTTP client. */
837
+ const measureSocket = (cell, run) => {
766
838
  if (cell.group !== void 0) {
767
839
  const read = measureGroup(cell, run);
768
840
  return {
@@ -784,7 +856,7 @@ const measure = (cell, run) => {
784
856
  const { events, kernel } = run;
785
857
  const ready = events.find((event) => event.ev === "ready");
786
858
  const local = events.find((event) => event.ev === "connected")?.["local"];
787
- const inflight = cell.probe.mode !== "idle";
859
+ const inflight = cell.probe.mode !== "idle" && cell.probe.mode !== "gap";
788
860
  const request = inflight ? events.filter((event) => event.ev === "sent")[1] : ready;
789
861
  const server = cell.observer === "server";
790
862
  const place = socketOf(cell, run);
@@ -873,14 +945,15 @@ const measure = (cell, run) => {
873
945
  const reported = socket.find((record) => record.ev === "error" && (record.err ?? 0) !== 0);
874
946
  const seen = remote ? void 0 : events.find((event) => event.ev === "error" || event.ev === "eof");
875
947
  const closeWait = socket.find((record) => record.ev === "state" && newState(record) === 8);
948
+ const own = cell.prediction.detects === true && cell.prediction.errno === "CLOSED" ? socket.find((record) => record.ev === "state" && oldState(record) === 1 && newState(record) === 4) : void 0;
876
949
  const outcome = reported !== void 0 ? {
877
950
  kind: "error",
878
951
  errno: ERRNO[reported.err] ?? `E${reported.err}`
879
- } : closeWait !== void 0 || seen?.ev === "eof" ? { kind: "eof" } : seen !== void 0 ? {
952
+ } : closeWait !== void 0 || seen?.ev === "eof" ? { kind: "eof" } : own !== void 0 ? { kind: "closed" } : seen !== void 0 ? {
880
953
  kind: "error",
881
954
  errno: String(seen["errno"])
882
955
  } : { kind: "silent" };
883
- const ended = reported?.mono ?? closeWait?.mono ?? seen?.t;
956
+ const ended = reported?.mono ?? closeWait?.mono ?? own?.mono ?? seen?.t;
884
957
  const noticed = reported !== void 0 && seen !== void 0 ? between(reported.mono, seen.t) : null;
885
958
  const { prediction } = cell;
886
959
  const before = run.fault?.t1 ?? ready.t;
@@ -1056,8 +1129,9 @@ const measure = (cell, run) => {
1056
1129
  finding: `it ended without its reference event (${prediction.from})`
1057
1130
  };
1058
1131
  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));
1059
- const finding = (prediction.errno === "EOF" ? outcome.kind === "eof" : outcome.kind === "error" && outcome.errno === prediction.errno) ? null : `it ended with ${outcome.kind === "error" ? outcome.errno : "end of file"}, not ${prediction.errno}`;
1060
- if (prediction.from === "last-received" || prediction.from === "first-retransmission") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
1132
+ 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}`;
1133
+ const programTimer = cell.probe.http !== void 0 && prediction.from === "last-received" && prediction.timer !== "keepalive";
1134
+ if (prediction.from === "last-received" && !programTimer || prediction.from === "first-retransmission") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
1061
1135
  const lag = (valueIn(prediction.high, "s") - valueIn(prediction.low, "s")) * 1e9;
1062
1136
  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;
1063
1137
  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`;
@@ -1114,6 +1188,18 @@ const NODE = {
1114
1188
  dockerfile: "harness/image/node/Dockerfile",
1115
1189
  from: ["harness/image/node"]
1116
1190
  };
1191
+ /** This image holds the Go HTTP client: the http.Transport of Go 1.27.2. */
1192
+ const GO_HTTP = {
1193
+ name: "loom-harness-go-http",
1194
+ dockerfile: "harness/image/go-http/Dockerfile",
1195
+ from: ["harness/image/go-http"]
1196
+ };
1197
+ /** This image holds the Node HTTP clients: the http.Agent of Node 24.21.0, and undici 8.12.2. */
1198
+ const NODE_HTTP = {
1199
+ name: "loom-harness-node-http",
1200
+ dockerfile: "harness/image/node-http/Dockerfile",
1201
+ from: ["harness/image/node-http"]
1202
+ };
1117
1203
  /** This image is the router of a middlebox chain: NAT and conntrack, with a form for a forgotten flow. */
1118
1204
  const ROUTER = {
1119
1205
  name: "loom-harness-router",
@@ -1180,6 +1266,10 @@ const harnessImage = () => build(HARNESS);
1180
1266
  const nodeClientImage = () => build(NODE);
1181
1267
  /** This function gives the image of the router. */
1182
1268
  const routerImage = () => build(ROUTER);
1269
+ /** This function gives the image of the Go HTTP client. */
1270
+ const goHttpImage = () => build(GO_HTTP);
1271
+ /** This function gives the image of the Node HTTP clients. */
1272
+ const nodeHttpImage = () => build(NODE_HTTP);
1183
1273
  /**
1184
1274
  * This time is the interval of the listing of a router, when its hop does not set one. A listing
1185
1275
  * evicts each expired flow that it walks past, and the DESTROY event of that flow follows.
@@ -1322,6 +1412,16 @@ const clientOf = async (kind, image) => {
1322
1412
  command: ["node", "/opt/harness/node/client-ioredis.mjs"],
1323
1413
  clientKind: kind
1324
1414
  };
1415
+ if (kind === "go-http") return {
1416
+ clientImage: await goHttpImage(),
1417
+ command: ["/opt/harness/client-go"],
1418
+ clientKind: kind
1419
+ };
1420
+ if (kind === "http-agent" || kind === "undici") return {
1421
+ clientImage: await nodeHttpImage(),
1422
+ command: ["node", `/opt/harness/node-http/client-${kind === "undici" ? "undici" : "agent"}.mjs`],
1423
+ clientKind: kind
1424
+ };
1325
1425
  return {
1326
1426
  clientImage: image,
1327
1427
  command: ["/opt/harness/client"],
@@ -1362,6 +1462,7 @@ const openLab = async (target, kind = "tcp", topology) => {
1362
1462
  const runtime = await getContainerRuntimeClient();
1363
1463
  const opened = [];
1364
1464
  const networks = [];
1465
+ const serverLines = lines();
1365
1466
  const close = async () => {
1366
1467
  for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
1367
1468
  for (const network of networks.reverse()) await network.stop().catch(() => void 0);
@@ -1381,7 +1482,12 @@ const openLab = async (target, kind = "tcp", topology) => {
1381
1482
  try {
1382
1483
  for (let at = 0; at <= hops.length; at += 1) networks.push(await new Network().start());
1383
1484
  const last = networks[hops.length];
1384
- const stock = new GenericContainer(target.image).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
1485
+ let stock = new GenericContainer(target.image).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
1486
+ if (target.files !== void 0) stock = stock.withCopyContentToContainer(Object.entries(target.files).map(([path, content]) => ({
1487
+ content,
1488
+ target: path
1489
+ })));
1490
+ if (target.lines === true) stock = stock.withLogConsumer(serverLines.consume);
1385
1491
  const server = await (target.command === void 0 ? stock : stock.withCommand([...target.command])).start();
1386
1492
  opened.push(server);
1387
1493
  const serverIp = await ipOn(server, last);
@@ -1633,6 +1739,7 @@ const openLab = async (target, kind = "tcp", topology) => {
1633
1739
  netns,
1634
1740
  topology: placed,
1635
1741
  middlebox,
1742
+ server: serverLines.seen,
1636
1743
  kernel: records.seen,
1637
1744
  async sysctl(values) {
1638
1745
  const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
@@ -1898,7 +2005,9 @@ const openLab = async (target, kind = "tcp", topology) => {
1898
2005
  image: target.image,
1899
2006
  id: (await inspect(server)).Image,
1900
2007
  settings,
1901
- sysctls: targetSysctls
2008
+ sysctls: targetSysctls,
2009
+ ...target.files === void 0 ? {} : { files: Object.fromEntries(Object.entries(target.files).map(([path, content]) => [path, createHash("sha256").update(content).digest("hex")])) },
2010
+ ...serverLines.seen[0] === void 0 ? {} : { first: serverLines.seen[0] }
1902
2011
  },
1903
2012
  harness: {
1904
2013
  image,
@@ -2621,6 +2730,18 @@ const probeArgs = (cell, lab) => {
2621
2730
  "--mode",
2622
2731
  probe.mode
2623
2732
  ];
2733
+ if (probe.http !== void 0) {
2734
+ const { http } = probe;
2735
+ const gap = http.gap === void 0 ? 0 : valueIn(http.gap, "s");
2736
+ args.push("--method", http.method);
2737
+ if (http.gap !== void 0) args.push("--gap-ms", String(valueIn(http.gap, "ms")));
2738
+ if (http.hold !== void 0) args.push("--hold-ms", String(valueIn(http.hold, "ms")));
2739
+ if (http.bytes !== void 0) args.push("--bytes", String(http.bytes));
2740
+ for (const [name, value] of Object.entries(http.options ?? {})) args.push(`--${name}`, String(value));
2741
+ args.push("--linger-ms", String(LINGER_MS));
2742
+ args.push("--max-s", String(Math.ceil(longest * 2 + gap + 10)));
2743
+ return args;
2744
+ }
2624
2745
  if (probe.request !== void 0) args.push("--request", probe.request);
2625
2746
  if (probe.count !== void 0) args.push("--count", String(probe.count));
2626
2747
  if (probe.interval !== void 0) args.push("--interval-ms", String(valueIn(probe.interval, "ms")));
@@ -2651,6 +2772,22 @@ const observerError = (cell, partial, within) => new Promise((resolve) => {
2651
2772
  });
2652
2773
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2653
2774
  /**
2775
+ * This time is the pause between the client's first reply and the fault. It is more than Linux's
2776
+ * TCP_DELACK_MAX of 200 ms. Thus the delayed ACK of the reply is out before the fault.
2777
+ */
2778
+ const QUIET_MS = 250;
2779
+ /**
2780
+ * This time is how long an HTTP client waits after its second request, before it exits. The exit
2781
+ * closes its sockets, thus that close comes after the window of the cell.
2782
+ */
2783
+ const LINGER_MS = 1e3;
2784
+ /** These are the lines that end the workload of an HTTP client: the end of its second request, its deadline, or its failure. */
2785
+ const HTTP_DONE = [
2786
+ "done",
2787
+ "deadline",
2788
+ "fail"
2789
+ ];
2790
+ /**
2654
2791
  * This function waits for the request that an in-flight client sends on the signal: its second
2655
2792
  * send, after the PING that made it ready. Its stamp is when the request first met the fault.
2656
2793
  */
@@ -2686,6 +2823,7 @@ const once = async (lab, cell, environment, index, dry = false) => {
2686
2823
  await lab.forget();
2687
2824
  const from = lab.kernel.length;
2688
2825
  const middleboxFrom = lab.middlebox.length;
2826
+ const serverFrom = lab.server.length;
2689
2827
  const lostBefore = droppedSoFar(lab.kernel);
2690
2828
  const probe = await lab.probe(probeArgs(cell, lab));
2691
2829
  const server = cell.observer === "server";
@@ -2748,11 +2886,12 @@ const once = async (lab, cell, environment, index, dry = false) => {
2748
2886
  return null;
2749
2887
  case "unreachable": return lab.fault("reject", faultAt, { code: cell.icmp ?? "host" });
2750
2888
  case "simplex": return lab.fault("blackhole", faultAt, options);
2751
- case "mtu-blackhole": return lab.fault("mtu", "client", { above: cell.mtu ?? 1200 });
2889
+ case "mtu-blackhole": return lab.fault("mtu", cell.faultAt ?? "client", { above: cell.mtu ?? 1200 });
2752
2890
  case "netem": return netemOn(cell.netem);
2753
2891
  case "rst": return lab.fault("rst", faultAt);
2754
2892
  case "middlebox-forget":
2755
2893
  case "proxy-idle":
2894
+ case "app-stall":
2756
2895
  case "none": return null;
2757
2896
  case "toxic":
2758
2897
  if (cell.toxic === void 0) throw new Error(`${cell.name} names no toxic`);
@@ -2770,10 +2909,11 @@ const once = async (lab, cell, environment, index, dry = false) => {
2770
2909
  case "middlebox-forget":
2771
2910
  case "proxy-idle":
2772
2911
  case "server-close":
2912
+ case "app-stall":
2773
2913
  case "none": return;
2774
2914
  case "unreachable": return lab.lift("reject", faultAt, { code: cell.icmp ?? "host" });
2775
2915
  case "simplex": return lab.lift("blackhole", faultAt, options);
2776
- case "mtu-blackhole": return lab.lift("mtu", "client", { above: cell.mtu ?? 1200 });
2916
+ case "mtu-blackhole": return lab.lift("mtu", cell.faultAt ?? "client", { above: cell.mtu ?? 1200 });
2777
2917
  case "netem":
2778
2918
  await netemOn(void 0);
2779
2919
  return;
@@ -2787,9 +2927,14 @@ const once = async (lab, cell, environment, index, dry = false) => {
2787
2927
  fault = await apply();
2788
2928
  try {
2789
2929
  if (cell.idleBefore !== void 0 && cell.probe.mode !== "idle") await sleep(valueIn(cell.idleBefore, "ms"));
2790
- if (cell.probe.mode !== "idle" && !replyInFlight) await go();
2930
+ if ((cell.probe.mode === "inflight" || cell.probe.mode === "stream") && !replyInFlight) await go();
2791
2931
  const { prediction } = cell;
2792
- if (cell.fault === "stall") {
2932
+ const http = cell.probe.http !== void 0;
2933
+ if (cell.probe.mode === "gap") {
2934
+ const gap = cell.probe.http?.gap === void 0 ? 0 : valueIn(cell.probe.http.gap, "s");
2935
+ const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
2936
+ await probe.until(HTTP_DONE, (gap + window) * 2e3 + 1e4).catch(() => void 0);
2937
+ } else if (cell.fault === "stall") {
2793
2938
  if (cell.stall === void 0) throw new Error(`${cell.name} says no stall's length`);
2794
2939
  const sent = await requestSent(probe, 5e3);
2795
2940
  heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
@@ -2810,10 +2955,12 @@ const once = async (lab, cell, environment, index, dry = false) => {
2810
2955
  } else if (prediction.detects) {
2811
2956
  const within = valueIn(prediction.high, "s") * 2e3 + 1e4;
2812
2957
  if (cell.observer !== void 0 && cell.observer !== "client") await observerError(cell, partial, within);
2958
+ else if (http && cell.probe.mode !== "idle") await probe.until(HTTP_DONE, within).catch(() => void 0);
2813
2959
  else await probe.until([
2814
2960
  "error",
2815
2961
  "eof",
2816
- "deadline"
2962
+ "deadline",
2963
+ ...http ? ["closed"] : []
2817
2964
  ], within).catch(() => void 0);
2818
2965
  } else await sleep(valueIn(prediction.window, "s") * 1e3);
2819
2966
  } finally {
@@ -2838,7 +2985,8 @@ const once = async (lab, cell, environment, index, dry = false) => {
2838
2985
  targetNetns: lab.netns.target,
2839
2986
  lost,
2840
2987
  ...placed,
2841
- ...lab.topology === null ? {} : { middlebox: lab.middlebox.slice(middleboxFrom) }
2988
+ ...lab.topology === null ? {} : { middlebox: lab.middlebox.slice(middleboxFrom) },
2989
+ ...cell.target.lines === true ? { server: lab.server.slice(serverFrom) } : {}
2842
2990
  };
2843
2991
  };
2844
2992
  /** This function divides a count of runs across environments, as equally as possible. */
@@ -2967,6 +3115,6 @@ const runCell = async (cell, say = () => {}) => {
2967
3115
  }
2968
3116
  };
2969
3117
  //#endregion
2970
- export { CONVERGE_MS, LISTING, NOISE, PUMP, RELATIVE, STAGES, STEPS, crossingsOf, deadlineOf, deviceOf, endpointAfter, errorsOf, fieldsOf, groupOnce, groupVerdictOf, harnessImage, infoOf, isHopPlace, judge, lagsOf, measure, measureGroup, medianInterval, memberConfig, nodeClientImage, openGroupLab, openLab, peersOf, quantileOf, reorderingOf, routerImage, routesOf, runCell, sentinelConfig, socketOf, spread, spreadOf, stagesOf, subscriptionsOf, templated, toxicBody, verdictOf, viewOf, writesOf };
3118
+ export { CONVERGE_MS, LINGER_MS, LISTING, NOISE, PUMP, QUIET_MS, RELATIVE, STAGES, STEPS, crossingsOf, deadlineOf, deviceOf, endpointAfter, errorsOf, fieldsOf, goHttpImage, groupOnce, groupVerdictOf, harnessImage, infoOf, isHopPlace, judge, lagsOf, measure, measureGroup, medianInterval, memberConfig, nodeClientImage, nodeHttpImage, openGroupLab, openLab, peersOf, quantileOf, reorderingOf, requestOf, requestWords, routerImage, routesOf, runCell, sentinelConfig, socketOf, spread, spreadOf, stagesOf, subscriptionsOf, templated, toxicBody, verdictOf, viewOf, writesOf };
2971
3119
 
2972
3120
  //# sourceMappingURL=index.js.map