@mark1russell7/harness 0.2.0 → 0.3.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
@@ -5,6 +5,192 @@ import { createRequire } from "node:module";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ //#region src/verdict.ts
9
+ /**
10
+ * This amount is the noise of the pipeline itself, δ_abs, as the calibration measured it. It is
11
+ * the 99.9th percentile of the whole latency of a reset, at the concurrency cap or less. Its
12
+ * minimum is one jiffy. On 2026-10-12 the worst of 60 resets at 1 and 4 environments took
13
+ * 1.141 ms, thus the minimum sets it to 4 ms. The calibration occurs again after each change to
14
+ * the harness, the kernel or Docker.
15
+ */
16
+ const NOISE = quantity(4, "ms");
17
+ /** This fraction, δ_rel, is how far the median of a cell can be outside the prediction and stay equivalent. */
18
+ const RELATIVE = .05;
19
+ /** These are the run counts of a cell: 11, then 22 and 44 while its median crosses the edge of the band. */
20
+ const STEPS = [
21
+ 11,
22
+ 22,
23
+ 44
24
+ ];
25
+ const s = (value) => quantity(Math.round(value * 1e6) / 1e6, "s");
26
+ const name = (run) => `env ${run.environment} run ${run.index}`;
27
+ /** This function gives P(X ≤ j) for X ~ Binomial(n, 1/2), exactly. */
28
+ const below = (n, j) => {
29
+ let term = 1;
30
+ let sum = 0;
31
+ for (let i = 0; i <= j; i += 1) {
32
+ sum += term;
33
+ term = term * (n - i) / (i + 1);
34
+ }
35
+ return sum / 2 ** n;
36
+ };
37
+ /**
38
+ * This function gives the distribution-free interval of a median: [x_(k), x_(n+1−k)], for the
39
+ * largest k whose two tails each hold 2.5 % or less. For eleven runs, it gives [x_(2), x_(10)],
40
+ * at 98.8 %.
41
+ */
42
+ const medianInterval = (sorted) => {
43
+ const n = sorted.length;
44
+ let k = 0;
45
+ while (k + 1 <= n / 2 && below(n, k) <= .025) k += 1;
46
+ return k === 0 ? [sorted[0], sorted[n - 1]] : [sorted[k - 1], sorted[n - k]];
47
+ };
48
+ const middle = (sorted) => {
49
+ const half = Math.floor(sorted.length / 2);
50
+ return sorted.length % 2 === 1 ? sorted[half] : (sorted[half - 1] + sorted[half]) / 2;
51
+ };
52
+ /**
53
+ * This function gives the verdict of a cell, from two tests:
54
+ * - Containment: each run lands in [T_lo − δ_abs, T_hi + δ_abs]. A run outside is a finding.
55
+ * - Median equivalence: the interval of the median is in the band, made wider by δ_rel. An
56
+ * interval outside the band fails. An interval across its edge extends the cell to 22 runs, then
57
+ * 44, and then the verdict is inconclusive.
58
+ *
59
+ * A silent prediction holds only if no run detected the fault in its window. After a stall, the
60
+ * return of the reply gets the same test as a detection, by its interval, and a detection is a
61
+ * finding. The distribution test (a DKW band) needs a predicted distribution from a simulator,
62
+ * which does not exist at this time.
63
+ */
64
+ const verdictOf = (prediction, runs) => {
65
+ const counted = runs.filter((run) => run.void === null);
66
+ const voided = runs.length - counted.length;
67
+ const outside = counted.filter((run) => run.finding !== null).map((run) => ({
68
+ run: name(run),
69
+ why: run.finding
70
+ }));
71
+ const empty = {
72
+ counted: counted.length,
73
+ voided,
74
+ median: null,
75
+ medianInterval: null
76
+ };
77
+ if (counted.length === 0) return {
78
+ ...empty,
79
+ decision: "inconclusive",
80
+ band: null,
81
+ outside,
82
+ why: "no run counted"
83
+ };
84
+ if (prediction.detects === null) {
85
+ if (prediction.from !== void 0) {
86
+ const times = counted.flatMap((run) => run.elapsed === null ? [] : [valueIn(run.elapsed, "s")]).sort((a, b) => a - b);
87
+ const kinds = /* @__PURE__ */ new Map();
88
+ for (const run of counted) {
89
+ const kind = run.outcome.kind === "error" ? run.outcome.errno : run.outcome.kind;
90
+ kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
91
+ }
92
+ const outcomes = [...kinds].map(([kind, n]) => `${n} ${kind}`).join(", ");
93
+ const timed = times.length === 0 ? "no time" : `median ${s(middle(times)).value} s from ${prediction.from}, range [${s(times[0]).value}, ${s(times.at(-1)).value}] s`;
94
+ return {
95
+ ...empty,
96
+ decision: "measured",
97
+ band: null,
98
+ outside: [],
99
+ median: times.length === 0 ? null : s(middle(times)),
100
+ why: `${counted.length} runs measured: ${outcomes}; ${timed}. ${prediction.why}`
101
+ };
102
+ }
103
+ const samples = counted.flatMap((run) => (run.samples ?? []).map((sample) => valueIn(sample, "s"))).sort((a, b) => a - b);
104
+ const retransmissions = counted.reduce((sum, run) => sum + run.retransmissions, 0);
105
+ const ended = counted.filter((run) => run.outcome.kind === "error" || run.outcome.kind === "eof");
106
+ const why = samples.length === 0 ? `${counted.length} runs measured, with no reply: ${prediction.why}` : `${counted.length} runs, ${samples.length} replies: median ${s(middle(samples)).value} s, slowest ${s(samples.at(-1)).value} s, ${retransmissions} retransmissions, ${ended.length} connections ended. ${prediction.why}`;
107
+ return {
108
+ ...empty,
109
+ decision: "measured",
110
+ band: null,
111
+ outside: [],
112
+ median: samples.length === 0 ? null : s(middle(samples)),
113
+ why
114
+ };
115
+ }
116
+ if (!prediction.detects && prediction.recovers === void 0 && prediction.replies === void 0) {
117
+ const decision = outside.length === 0 ? "holds" : "fails";
118
+ const why = decision === "holds" ? `nothing detected it in ${counted.length} runs of ${valueIn(prediction.window, "s")} s` : `${outside.length} of ${counted.length} runs detected it`;
119
+ return {
120
+ ...empty,
121
+ decision,
122
+ band: null,
123
+ outside,
124
+ why
125
+ };
126
+ }
127
+ const timed = prediction.detects ? prediction : prediction.recovers ?? prediction.replies;
128
+ const noise = valueIn(NOISE, "s");
129
+ const low = valueIn(timed.low, "s");
130
+ const high = valueIn(timed.high, "s");
131
+ const band = {
132
+ low: s(low - noise),
133
+ high: s(high + noise)
134
+ };
135
+ const values = [];
136
+ for (const run of counted) {
137
+ if (run.elapsed === null) continue;
138
+ const value = valueIn(run.elapsed, "s");
139
+ values.push(value);
140
+ if (value < low - noise || value > high + noise) outside.push({
141
+ run: name(run),
142
+ why: `${value} s is outside [${band.low.value}, ${band.high.value}] s`
143
+ });
144
+ }
145
+ values.sort((a, b) => a - b);
146
+ if (values.length === 0) return {
147
+ ...empty,
148
+ decision: "fails",
149
+ band,
150
+ outside,
151
+ why: "no run measured its time"
152
+ };
153
+ const [from, to] = medianInterval(values);
154
+ const median = s(middle(values));
155
+ const interval = {
156
+ low: s(from),
157
+ high: s(to)
158
+ };
159
+ const wide = {
160
+ low: low * .95,
161
+ high: high * 1.05
162
+ };
163
+ const inside = from >= wide.low && to <= wide.high;
164
+ const disjoint = to < wide.low || from > wide.high;
165
+ const off = new Set(outside.map((each) => each.run)).size;
166
+ const counts = `${counted.length - off} of ${counted.length} runs inside [${band.low.value}, ${band.high.value}] s`;
167
+ const centre = `median ${median.value} s, its interval [${interval.low.value}, ${interval.high.value}] s`;
168
+ const result = {
169
+ counted: counted.length,
170
+ voided,
171
+ band,
172
+ outside,
173
+ median,
174
+ medianInterval: interval
175
+ };
176
+ if (outside.length > 0 || disjoint) return {
177
+ ...result,
178
+ decision: "fails",
179
+ why: `${counts}; ${centre}`
180
+ };
181
+ if (inside) return {
182
+ ...result,
183
+ decision: "holds",
184
+ why: `${counts}; ${centre}`
185
+ };
186
+ const decision = counted.length < STEPS[2] ? "extend" : "inconclusive";
187
+ return {
188
+ ...result,
189
+ decision,
190
+ why: `${counts}; ${centre} straddles the band`
191
+ };
192
+ };
193
+ //#endregion
8
194
  //#region src/observe.ts
9
195
  /** These are the errno numbers that the kernel reports to a socket, by the names that predictions use. */
10
196
  const ERRNO = {
@@ -24,14 +210,77 @@ const newState = (record) => record.new;
24
210
  const count = (event, field) => event === void 0 ? 0 : Number(event[field] ?? 0);
25
211
  /** This function gives the view of the client on the same interval: TCP_INFO, with a sample each 2 ms. */
26
212
  const socketVantage = (cell, events, ready, request) => {
27
- if (!cell.prediction.detects || cell.prediction.from === "peer-closed") return null;
213
+ if (!cell.prediction.detects || cell.prediction.from === "peer-closed" || cell.prediction.from === "fault") return null;
28
214
  const ended = events.find((event) => event.ev === "error" || event.ev === "eof");
29
215
  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);
30
216
  return ended === void 0 || reference === void 0 ? null : between(reference.t, ended.t);
31
217
  };
218
+ const ipOf = (address) => address.split(":")[0];
219
+ /**
220
+ * This function finds the socket that became ESTABLISHED in a run, in a namespace, whose peer
221
+ * matches. In a topology, an address of a hop serves each run, thus only the socket that the
222
+ * run opened is the socket of the run.
223
+ */
224
+ const opened = (run, netns, peer) => {
225
+ const record = run.kernel.find((each) => each.netns === netns && each.ev === "state" && newState(each) === 1 && each.dst !== void 0 && peer(each.dst));
226
+ return record?.cookie === void 0 ? void 0 : {
227
+ netns,
228
+ cookie: record.cookie
229
+ };
230
+ };
231
+ /** This function finds the socket of the target for the connection of a run. In a topology, its peer is the last hop. */
232
+ const targetSocket = (run, local) => {
233
+ if (run.topology === void 0) {
234
+ const cookie = run.kernel.find((record) => record.netns === run.targetNetns && record.dst === local)?.cookie;
235
+ return cookie === void 0 ? void 0 : {
236
+ netns: run.targetNetns,
237
+ cookie
238
+ };
239
+ }
240
+ const peer = run.topology.targetPeer;
241
+ return opened(run, run.targetNetns, (dst) => ipOf(dst) === peer);
242
+ };
243
+ /**
244
+ * This function finds the socket of the observer of a cell in a run:
245
+ * - the client socket, by the address that it connected from, in its namespace.
246
+ * - the socket of the target for the same connection, in its namespace.
247
+ * - a socket of a proxy of the topology, toward the client or toward the target.
248
+ */
249
+ const socketOf = (cell, run) => {
250
+ const local = run.events.find((event) => event.ev === "connected")?.["local"];
251
+ const observer = cell.observer ?? "client";
252
+ if (observer === "client") {
253
+ const cookie = run.kernel.find((record) => record.netns === run.netns && record.src === local)?.cookie;
254
+ return cookie === void 0 ? void 0 : {
255
+ netns: run.netns,
256
+ cookie
257
+ };
258
+ }
259
+ if (observer === "server") return targetSocket(run, local);
260
+ const hop = run.topology?.hops[observer.hop];
261
+ if (hop === void 0) return void 0;
262
+ return observer.toward === "client" ? opened(run, hop.netns, (dst) => ipOf(dst) === hop.client) : opened(run, hop.netns, (dst) => dst === hop.target);
263
+ };
264
+ /**
265
+ * This function finds the socket of the peer of the observer: its close is the reference of a FIN.
266
+ * In a direct pair, it is the other end of the connection. In a topology, it is the socket of the
267
+ * target, for the client.
268
+ */
269
+ const peerOf = (cell, run) => {
270
+ const local = run.events.find((event) => event.ev === "connected")?.["local"];
271
+ const server = cell.observer === "server";
272
+ if (run.topology !== void 0) return server ? void 0 : targetSocket(run, local);
273
+ const netns = server ? run.netns : run.targetNetns;
274
+ const cookie = run.kernel.find((record) => record.netns === netns && (server ? record.src : record.dst) === local)?.cookie;
275
+ return cookie === void 0 ? void 0 : {
276
+ netns,
277
+ cookie
278
+ };
279
+ };
32
280
  /**
33
281
  * This function reads one run against its cell, from the records of the kernel first.
34
- * - The socket of the run is the socket that the client connected from, in its namespace.
282
+ * - The socket of the run is the socket of its observer. It is the socket that the client connected
283
+ * from, in its namespace, or the socket of the target or of a proxy for that connection.
35
284
  * - The fault must hold before the event that it must meet. That is before the request goes into
36
285
  * it, and before the first keepalive probe. A live peer answers that probe, and the answer
37
286
  * starts the idle time again. A run where the fault was late is void. A run whose records the
@@ -44,11 +293,16 @@ const measure = (cell, run) => {
44
293
  const inflight = cell.probe.mode !== "idle";
45
294
  const request = inflight ? events.filter((event) => event.ev === "sent")[1] : ready;
46
295
  const server = cell.observer === "server";
47
- const netns = server ? run.targetNetns : run.netns;
48
- const cookie = kernel.find((record) => record.netns === netns && (server ? record.dst : record.src) === local)?.cookie;
296
+ const place = socketOf(cell, run);
297
+ const netns = place?.netns;
298
+ const cookie = place?.cookie;
49
299
  const socket = kernel.filter((record) => record.netns === netns && record.cookie === cookie);
50
- const peerNetns = server ? run.netns : run.targetNetns;
51
- const peerCookie = kernel.find((record) => record.netns === peerNetns && (server ? record.src : record.dst) === local)?.cookie;
300
+ const peerPlace = peerOf(cell, run);
301
+ const peerNetns = peerPlace?.netns;
302
+ const peerCookie = peerPlace?.cookie;
303
+ const after = request === void 0 ? [] : events.filter((event) => event.ev === "data" && event.t > request.t);
304
+ const bytes = after.reduce((sum, event) => sum + Number(event["n"] ?? 0), 0);
305
+ const forgotten = (run.middlebox ?? []).find((line) => line.ev === "ct" && line["type"] === "DESTROY" && typeof line["orig"] === "string" && line["orig"].startsWith(`${String(local)}>`));
52
306
  const keepalives = socket.filter((record) => record.ev === "probe" && record.what === "keepalive");
53
307
  const retransmitted = socket.filter((record) => record.ev === "retrans");
54
308
  /**
@@ -116,8 +370,9 @@ const measure = (cell, run) => {
116
370
  else if (replyFirst && replied) voided = "the server's reply arrived before the fault held";
117
371
  else if (keepalives[0] !== void 0 && keepalives[0].mono < run.fault.t1) voided = "a keepalive probe went out before the fault held";
118
372
  }
373
+ const remote = cell.observer !== void 0 && cell.observer !== "client";
119
374
  const reported = socket.find((record) => record.ev === "error" && (record.err ?? 0) !== 0);
120
- const seen = server ? void 0 : events.find((event) => event.ev === "error" || event.ev === "eof");
375
+ const seen = remote ? void 0 : events.find((event) => event.ev === "error" || event.ev === "eof");
121
376
  const closeWait = socket.find((record) => record.ev === "state" && newState(record) === 8);
122
377
  const outcome = reported !== void 0 ? {
123
378
  kind: "error",
@@ -129,6 +384,17 @@ const measure = (cell, run) => {
129
384
  const ended = reported?.mono ?? closeWait?.mono ?? seen?.t;
130
385
  const noticed = reported !== void 0 && seen !== void 0 ? between(reported.mono, seen.t) : null;
131
386
  const { prediction } = cell;
387
+ const before = run.fault?.t1 ?? ready.t;
388
+ const referenceAt = (from) => {
389
+ if (from === "request-sent") return request.t;
390
+ if (from === "fault") return run.fault?.t0;
391
+ 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;
392
+ };
393
+ const lastRx = kernel.filter((each) => each.netns === run.netns && each.src === local && each.ev === "rx" && each.mono <= before).at(-1)?.mono ?? ready.t;
394
+ const chain = run.topology === void 0 ? {} : {
395
+ bytes,
396
+ forgot: forgotten === void 0 ? null : between(lastRx, forgotten.t)
397
+ };
132
398
  if (prediction.detects === null) {
133
399
  const sends = events.filter((event) => event.ev === "sent").slice(1);
134
400
  const samples = sends.flatMap((send, n) => {
@@ -136,10 +402,15 @@ const measure = (cell, run) => {
136
402
  const reply = events.find((event) => event.ev === "data" && event.t > send.t && event.t < next);
137
403
  return reply === void 0 ? [] : [between(send.t, reply.t)];
138
404
  });
405
+ const from = prediction.from === void 0 ? void 0 : referenceAt(prediction.from);
406
+ const until = ended ?? after.at(-1)?.t;
139
407
  return {
140
408
  ...base,
141
409
  ...nothing,
410
+ ...chain,
142
411
  outcome: ended !== void 0 ? outcome : { kind: "answered" },
412
+ ...from === void 0 || until === void 0 ? {} : { elapsed: between(from, until) },
413
+ noticed: prediction.from === void 0 ? null : noticed,
143
414
  steps: retransmitted.filter((record) => record.mono > request.t).map((record) => between(request.t, record.mono)),
144
415
  samples,
145
416
  void: voided
@@ -184,16 +455,54 @@ const measure = (cell, run) => {
184
455
  finding: null
185
456
  };
186
457
  }
187
- if (!prediction.detects) {
458
+ if (!prediction.detects && prediction.replies !== void 0) {
188
459
  const window = valueIn(prediction.window, "s") * 1e9;
189
- if (ended === void 0 || ended - request.t > window) return {
460
+ if (ended !== void 0 && ended - request.t <= window) return {
190
461
  ...base,
191
462
  ...nothing,
192
- void: voided
463
+ ...chain,
464
+ outcome,
465
+ elapsed: between(request.t, ended),
466
+ void: voided,
467
+ finding: `detected after ${between(request.t, ended).value} s, where the reply should come`
468
+ };
469
+ const answer = socket.find((record) => record.ev === "rx" && record.mono > request.t && (record.seg?.len.value ?? 0) > 0);
470
+ const reply = after[0];
471
+ if (answer === void 0) return {
472
+ ...base,
473
+ ...nothing,
474
+ ...chain,
475
+ void: voided,
476
+ finding: "the reply never came"
193
477
  };
478
+ return {
479
+ ...base,
480
+ ...chain,
481
+ outcome: { kind: "answered" },
482
+ elapsed: between(request.t, answer.mono),
483
+ socket: reply === void 0 ? null : between(request.t, reply.t),
484
+ noticed: reply === void 0 ? null : between(answer.mono, reply.t),
485
+ steps: [],
486
+ void: voided,
487
+ finding: null
488
+ };
489
+ }
490
+ if (!prediction.detects) {
491
+ const window = valueIn(prediction.window, "s") * 1e9;
492
+ if (ended === void 0 || ended - request.t > window) {
493
+ const forgot = forgotten !== void 0 && forgotten.t - request.t <= window ? `a router forgot the flow ${between(lastRx, forgotten.t).value} s after the last segment, where it should keep it` : null;
494
+ return {
495
+ ...base,
496
+ ...nothing,
497
+ ...chain,
498
+ void: voided,
499
+ finding: forgot
500
+ };
501
+ }
194
502
  return {
195
503
  ...base,
196
504
  ...nothing,
505
+ ...chain,
197
506
  outcome,
198
507
  elapsed: between(request.t, ended),
199
508
  void: voided,
@@ -203,15 +512,15 @@ const measure = (cell, run) => {
203
512
  if (ended === void 0) return {
204
513
  ...base,
205
514
  ...nothing,
515
+ ...chain,
206
516
  void: voided,
207
517
  finding: `nothing detected it: ${prediction.timer} never fired`
208
518
  };
209
- const before = run.fault?.t1 ?? ready.t;
210
- const kernelReference = prediction.from === "last-received" ? socket.filter((record) => record.ev === "rx" && record.mono <= before).at(-1) : prediction.from === "first-retransmission" ? retransmitted.find((record) => record.why === "rto") : prediction.from === "peer-closed" ? kernel.find((record) => record.netns === peerNetns && record.cookie === peerCookie && record.ev === "state" && oldState(record) === 1) : void 0;
211
- const reference = prediction.from === "request-sent" ? request.t : kernelReference?.mono;
519
+ const reference = referenceAt(prediction.from);
212
520
  if (reference === void 0) return {
213
521
  ...base,
214
522
  ...nothing,
523
+ ...chain,
215
524
  outcome,
216
525
  noticed,
217
526
  void: voided,
@@ -219,12 +528,18 @@ const measure = (cell, run) => {
219
528
  };
220
529
  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));
221
530
  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}`;
222
- if (prediction.from !== "request-sent" && prediction.from !== "peer-closed") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
531
+ if (prediction.from === "last-received" || prediction.from === "first-retransmission") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
532
+ const lag = (valueIn(prediction.high, "s") - valueIn(prediction.low, "s")) * 1e9;
533
+ 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;
534
+ 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`;
535
+ const commit = run.fault === null ? 0 : (run.fault.t1 - run.fault.t0) / 1e6;
536
+ 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`;
223
537
  return {
224
538
  ...base,
539
+ ...chain,
225
540
  outcome,
226
541
  elapsed: between(reference, ended),
227
- socket: server ? null : socketVantage(cell, events, ready, request),
542
+ socket: remote ? null : socketVantage(cell, events, ready, request),
228
543
  noticed,
229
544
  steps,
230
545
  void: voided,
@@ -232,173 +547,6 @@ const measure = (cell, run) => {
232
547
  };
233
548
  };
234
549
  //#endregion
235
- //#region src/verdict.ts
236
- /**
237
- * This amount is the noise of the pipeline itself, δ_abs, as the calibration measured it. It is
238
- * the 99.9th percentile of the whole latency of a reset, at the concurrency cap or less. Its
239
- * minimum is one jiffy. On 2026-10-12 the worst of 60 resets at 1 and 4 environments took
240
- * 1.141 ms, thus the minimum sets it to 4 ms. The calibration occurs again after each change to
241
- * the harness, the kernel or Docker.
242
- */
243
- const NOISE = quantity(4, "ms");
244
- /** This fraction, δ_rel, is how far the median of a cell can be outside the prediction and stay equivalent. */
245
- const RELATIVE = .05;
246
- /** These are the run counts of a cell: 11, then 22 and 44 while its median crosses the edge of the band. */
247
- const STEPS = [
248
- 11,
249
- 22,
250
- 44
251
- ];
252
- const s = (value) => quantity(Math.round(value * 1e6) / 1e6, "s");
253
- const name = (run) => `env ${run.environment} run ${run.index}`;
254
- /** This function gives P(X ≤ j) for X ~ Binomial(n, 1/2), exactly. */
255
- const below = (n, j) => {
256
- let term = 1;
257
- let sum = 0;
258
- for (let i = 0; i <= j; i += 1) {
259
- sum += term;
260
- term = term * (n - i) / (i + 1);
261
- }
262
- return sum / 2 ** n;
263
- };
264
- /**
265
- * This function gives the distribution-free interval of a median: [x_(k), x_(n+1−k)], for the
266
- * largest k whose two tails each hold 2.5 % or less. For eleven runs, it gives [x_(2), x_(10)],
267
- * at 98.8 %.
268
- */
269
- const medianInterval = (sorted) => {
270
- const n = sorted.length;
271
- let k = 0;
272
- while (k + 1 <= n / 2 && below(n, k) <= .025) k += 1;
273
- return k === 0 ? [sorted[0], sorted[n - 1]] : [sorted[k - 1], sorted[n - k]];
274
- };
275
- const middle = (sorted) => {
276
- const half = Math.floor(sorted.length / 2);
277
- return sorted.length % 2 === 1 ? sorted[half] : (sorted[half - 1] + sorted[half]) / 2;
278
- };
279
- /**
280
- * This function gives the verdict of a cell, from two tests:
281
- * - Containment: each run lands in [T_lo − δ_abs, T_hi + δ_abs]. A run outside is a finding.
282
- * - Median equivalence: the interval of the median is in the band, made wider by δ_rel. An
283
- * interval outside the band fails. An interval across its edge extends the cell to 22 runs, then
284
- * 44, and then the verdict is inconclusive.
285
- *
286
- * A silent prediction holds only if no run detected the fault in its window. After a stall, the
287
- * return of the reply gets the same test as a detection, by its interval, and a detection is a
288
- * finding. The distribution test (a DKW band) needs a predicted distribution from a simulator,
289
- * which does not exist at this time.
290
- */
291
- const verdictOf = (prediction, runs) => {
292
- const counted = runs.filter((run) => run.void === null);
293
- const voided = runs.length - counted.length;
294
- const outside = counted.filter((run) => run.finding !== null).map((run) => ({
295
- run: name(run),
296
- why: run.finding
297
- }));
298
- const empty = {
299
- counted: counted.length,
300
- voided,
301
- median: null,
302
- medianInterval: null
303
- };
304
- if (counted.length === 0) return {
305
- ...empty,
306
- decision: "inconclusive",
307
- band: null,
308
- outside,
309
- why: "no run counted"
310
- };
311
- if (prediction.detects === null) {
312
- const samples = counted.flatMap((run) => (run.samples ?? []).map((sample) => valueIn(sample, "s"))).sort((a, b) => a - b);
313
- const retransmissions = counted.reduce((sum, run) => sum + run.retransmissions, 0);
314
- const ended = counted.filter((run) => run.outcome.kind === "error" || run.outcome.kind === "eof");
315
- const why = samples.length === 0 ? `${counted.length} runs measured, with no reply: ${prediction.why}` : `${counted.length} runs, ${samples.length} replies: median ${s(middle(samples)).value} s, slowest ${s(samples.at(-1)).value} s, ${retransmissions} retransmissions, ${ended.length} connections ended. ${prediction.why}`;
316
- return {
317
- ...empty,
318
- decision: "measured",
319
- band: null,
320
- outside: [],
321
- median: samples.length === 0 ? null : s(middle(samples)),
322
- why
323
- };
324
- }
325
- if (!prediction.detects && prediction.recovers === void 0) {
326
- const decision = outside.length === 0 ? "holds" : "fails";
327
- const why = decision === "holds" ? `nothing detected it in ${counted.length} runs of ${valueIn(prediction.window, "s")} s` : `${outside.length} of ${counted.length} runs detected it`;
328
- return {
329
- ...empty,
330
- decision,
331
- band: null,
332
- outside,
333
- why
334
- };
335
- }
336
- const timed = prediction.detects ? prediction : prediction.recovers;
337
- const noise = valueIn(NOISE, "s");
338
- const low = valueIn(timed.low, "s");
339
- const high = valueIn(timed.high, "s");
340
- const band = {
341
- low: s(low - noise),
342
- high: s(high + noise)
343
- };
344
- const values = [];
345
- for (const run of counted) {
346
- if (run.elapsed === null) continue;
347
- const value = valueIn(run.elapsed, "s");
348
- values.push(value);
349
- if (value < low - noise || value > high + noise) outside.push({
350
- run: name(run),
351
- why: `${value} s is outside [${band.low.value}, ${band.high.value}] s`
352
- });
353
- }
354
- values.sort((a, b) => a - b);
355
- if (values.length === 0) return {
356
- ...empty,
357
- decision: "fails",
358
- band,
359
- outside,
360
- why: "no run measured its time"
361
- };
362
- const [from, to] = medianInterval(values);
363
- const median = s(middle(values));
364
- const interval = {
365
- low: s(from),
366
- high: s(to)
367
- };
368
- const wide = {
369
- low: low * .95,
370
- high: high * 1.05
371
- };
372
- const inside = from >= wide.low && to <= wide.high;
373
- const disjoint = to < wide.low || from > wide.high;
374
- const counts = `${counted.length - outside.length} of ${counted.length} runs inside [${band.low.value}, ${band.high.value}] s`;
375
- const centre = `median ${median.value} s, its interval [${interval.low.value}, ${interval.high.value}] s`;
376
- const result = {
377
- counted: counted.length,
378
- voided,
379
- band,
380
- outside,
381
- median,
382
- medianInterval: interval
383
- };
384
- if (outside.length > 0 || disjoint) return {
385
- ...result,
386
- decision: "fails",
387
- why: `${counts}; ${centre}`
388
- };
389
- if (inside) return {
390
- ...result,
391
- decision: "holds",
392
- why: `${counts}; ${centre}`
393
- };
394
- const decision = counted.length < STEPS[2] ? "extend" : "inconclusive";
395
- return {
396
- ...result,
397
- decision,
398
- why: `${counts}; ${centre} straddles the band`
399
- };
400
- };
401
- //#endregion
402
550
  //#region src/lab.ts
403
551
  /**
404
552
  * These are the roots of the two packages that the images read: this package, and the prober
@@ -437,6 +585,12 @@ const NODE = {
437
585
  dockerfile: "harness/image/node/Dockerfile",
438
586
  from: ["harness/image/node"]
439
587
  };
588
+ /** This image is the router of a middlebox chain: NAT and conntrack, with a form for a forgotten flow. */
589
+ const ROUTER = {
590
+ name: "loom-harness-router",
591
+ dockerfile: "harness/image/router/Dockerfile",
592
+ from: ["harness/image/router"]
593
+ };
440
594
  /** This function gives each file under a path, in a stable sequence, but not the output of a local build. */
441
595
  const sources = (path) => {
442
596
  if (statSync(path).isFile()) return [path];
@@ -495,6 +649,26 @@ const build = (image) => {
495
649
  const harnessImage = () => build(HARNESS);
496
650
  /** This function gives the image of the Node client. */
497
651
  const nodeClientImage = () => build(NODE);
652
+ /** This function gives the image of the router. */
653
+ const routerImage = () => build(ROUTER);
654
+ /**
655
+ * This time is the interval of the listing of a router, when its hop does not set one. A listing
656
+ * evicts each expired flow that it walks past, and the DESTROY event of that flow follows.
657
+ */
658
+ const PUMP = {
659
+ value: .1,
660
+ unit: "s"
661
+ };
662
+ /**
663
+ * This time is the longest listing of the conntrack table of a router that a prediction assumes.
664
+ * A DESTROY follows an expiry within the interval of the pump and two listings. On 2026-10-15,
665
+ * the slowest of 500 listings in an idle router took 19.4 ms, and the median took 5.5 ms. A
666
+ * longer listing writes a `pump-slow` line, and a run with one near its detection is void.
667
+ */
668
+ const LISTING = {
669
+ value: 20,
670
+ unit: "ms"
671
+ };
498
672
  /** This function gives the arguments of the injector for the options of a fault. */
499
673
  const optionArgs = (options) => [
500
674
  ...options.direction === void 0 ? [] : ["--direction", options.direction],
@@ -508,7 +682,8 @@ const LIFT = {
508
682
  reject: "clear-reject",
509
683
  mtu: "clear-mtu"
510
684
  };
511
- const lines = () => {
685
+ /** This function collects the JSON lines of a program. It gives each line to `also` too, when the caller gives it. */
686
+ const lines = (also) => {
512
687
  const seen = [];
513
688
  const other = [];
514
689
  const waiting = [];
@@ -520,6 +695,7 @@ const lines = () => {
520
695
  }
521
696
  const line = JSON.parse(text);
522
697
  seen.push(line);
698
+ also?.(line);
523
699
  for (const wait of waiting.splice(0)) if (wait.kinds.includes(line.ev)) wait.resolve(line);
524
700
  else waiting.push(wait);
525
701
  };
@@ -551,6 +727,42 @@ const lines = () => {
551
727
  }
552
728
  };
553
729
  };
730
+ /** This container class can share the process namespace of another container. */
731
+ const shared = (Base) => class extends Base {
732
+ withPidMode(mode) {
733
+ this.hostConfig.PidMode = mode;
734
+ return this;
735
+ }
736
+ };
737
+ /** This function gives the content of a file of a proxy, with the address of the next hop for each {{next}}. */
738
+ const templated = (content, next) => content.replaceAll("{{next}}", next);
739
+ /** This function gives the JSON body of a toxic for the API of Toxiproxy. Each toxic of a cell has the name `fault`. */
740
+ const toxicBody = (toxic) => JSON.stringify({
741
+ name: "fault",
742
+ type: toxic.type,
743
+ stream: toxic.stream,
744
+ toxicity: 1,
745
+ attributes: toxic.attributes
746
+ });
747
+ /**
748
+ * This function gives the routes of a chain: for each router, the networks after it, which the
749
+ * element before it reaches through it. Element 0 is the client, and element k is hop k - 1. Network
750
+ * j is the network between element j and element j + 1.
751
+ */
752
+ const routesOf = (hops) => hops.flatMap((hop, at) => hop.kind === "router" ? [{
753
+ from: at,
754
+ via: at + 1,
755
+ networks: Array.from({ length: hops.length - at }, (_, k) => at + 1 + k)
756
+ }] : []);
757
+ /**
758
+ * This function gives the endpoint that an element connects to: the first element after it that
759
+ * is not a router. Element 0 is the client, element k is hop k - 1, and the last element is the target.
760
+ */
761
+ const endpointAfter = (hops, element) => {
762
+ let at = element + 1;
763
+ while (at <= hops.length && hops[at - 1].kind === "router") at += 1;
764
+ return at;
765
+ };
554
766
  const STOP = { timeout: 0 };
555
767
  const output = async (container, argv) => {
556
768
  const result = await container.exec(argv);
@@ -564,63 +776,268 @@ const netnsOf = async (container) => {
564
776
  if (inode === void 0) throw new Error(`not a network namespace: ${link}`);
565
777
  return Number(inode);
566
778
  };
779
+ /** This function gives the image and the command of the client of each run, and its kind for the manifest. */
780
+ const clientOf = async (kind, image) => {
781
+ if (typeof kind === "object") return {
782
+ clientImage: kind.image,
783
+ command: kind.command,
784
+ clientKind: "image"
785
+ };
786
+ if (kind === "ioredis") return {
787
+ clientImage: await nodeClientImage(),
788
+ command: ["node", "/opt/harness/node/client-ioredis.mjs"],
789
+ clientKind: kind
790
+ };
791
+ return {
792
+ clientImage: image,
793
+ command: ["/opt/harness/client"],
794
+ clientKind: kind
795
+ };
796
+ };
567
797
  /**
568
798
  * This function opens one environment:
569
- * - a new network and the stock target.
799
+ * - a chain of new networks, and the stock target on the last one. A direct pair has one network.
570
800
  * - a client that holds its own network namespace, with a privileged sidecar in it for its
571
- * sysctls. Docker Desktop keeps /proc/sys read-only in a container that operates.
801
+ * sysctls and its routes. Docker Desktop keeps /proc/sys read-only in a container that operates
802
+ * without privileges.
803
+ * - each hop of the topology, on the network of each of its sides. A hop is a router, or a stock
804
+ * proxy with an injector sidecar in its namespace.
572
805
  * - an injector sidecar in the namespace of the target.
573
- * - the prober, a privileged container that loads the kernel program and watches the two
574
- * namespaces.
806
+ * - the prober, a privileged container that loads the kernel program and watches the namespace of
807
+ * the client, of the target and of each hop.
575
808
  *
576
809
  * The client of each run is its own container in the namespace of the client. It is the TCP
577
- * client of the harness, or a driver from its own image (ioredis on Node). The two print the same
578
- * lines.
810
+ * client of the harness, a driver from its own image (ioredis on Node), or an image that the
811
+ * caller gives. Each prints the same lines.
812
+ *
813
+ * The client reaches the networks after a router through that router, by a route that its
814
+ * privileged sidecar adds. A router translates the address of the side of the client, thus the
815
+ * target and each later hop see the router as the peer.
579
816
  */
580
- const openLab = async (target, kind = "tcp") => {
817
+ const openLab = async (target, kind = "tcp", topology) => {
581
818
  const image = await harnessImage();
582
- const clientImage = kind === "ioredis" ? await nodeClientImage() : image;
583
- const command = kind === "ioredis" ? ["node", "/opt/harness/node/client-ioredis.mjs"] : ["/opt/harness/client"];
819
+ const { clientImage, command, clientKind } = await clientOf(kind, image);
820
+ const hops = topology?.hops ?? [];
584
821
  const { GenericContainer, Network, Wait, getContainerRuntimeClient } = await containers();
822
+ const runtime = await getContainerRuntimeClient();
585
823
  const opened = [];
586
- let network;
824
+ const networks = [];
587
825
  const close = async () => {
588
826
  for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
589
- await network?.stop().catch(() => void 0);
827
+ for (const network of networks.reverse()) await network.stop().catch(() => void 0);
828
+ };
829
+ /** This function gives the address of a container on a network, as Docker has it at this time. A container can join a network after its start. */
830
+ const ipOn = async (container, network) => {
831
+ const ip = (await runtime.container.inspect(runtime.container.getById(container.getId()))).NetworkSettings.Networks[network.getName()]?.IPAddress;
832
+ if (ip === void 0 || ip === "") throw new Error(`${container.getName()} has no address on ${network.getName()}`);
833
+ return ip;
834
+ };
835
+ const join = (container, network) => runtime.container.connectToNetwork(runtime.container.getById(container.getId()), runtime.network.getById(network.getId()), []);
836
+ const subnetOf = async (network) => {
837
+ const subnet = (await runtime.network.getById(network.getId()).inspect()).IPAM?.Config?.[0]?.Subnet;
838
+ if (subnet === void 0) throw new Error(`${network.getName()} has no subnet`);
839
+ return subnet;
590
840
  };
591
841
  try {
592
- network = await new Network().start();
593
- const stock = new GenericContainer(target.image).withNetwork(network).withWaitStrategy(Wait.forLogMessage(target.ready));
842
+ for (let at = 0; at <= hops.length; at += 1) networks.push(await new Network().start());
843
+ const last = networks[hops.length];
844
+ const stock = new GenericContainer(target.image).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
594
845
  const server = await (target.command === void 0 ? stock : stock.withCommand([...target.command])).start();
595
846
  opened.push(server);
596
- const client = await new GenericContainer(image).withNetwork(network).start();
847
+ const serverIp = await ipOn(server, last);
848
+ const middlebox = [];
849
+ const inChain = [];
850
+ const endpointOf = (element) => {
851
+ const at = endpointAfter(hops, element);
852
+ if (at === hops.length + 1) return `${serverIp}:${target.port}`;
853
+ const proxy = inChain[at - 1];
854
+ return `${proxy.ip.client}:${proxy.hop.port}`;
855
+ };
856
+ for (let at = hops.length - 1; at >= 0; at -= 1) {
857
+ const hop = hops[at];
858
+ const [clientSide, targetSide] = [networks[at], networks[at + 1]];
859
+ if (hop.kind === "router") {
860
+ const seen = lines((line) => middlebox.push({
861
+ ...line,
862
+ hop: hop.name
863
+ }));
864
+ const box = await new GenericContainer(await routerImage()).withNetwork(clientSide).withPrivilegedMode().withEnvironment({
865
+ FORM: hop.form,
866
+ IDLE: String(valueIn(hop.idle, "s")),
867
+ PUMP: String(valueIn(hop.pump ?? PUMP, "s")),
868
+ SLOW: String(valueIn(LISTING, "s"))
869
+ }).withLogConsumer(seen.consume).start();
870
+ opened.push(box);
871
+ await join(box, targetSide);
872
+ const ready = await seen.until(["mbx-ready"], 3e4, `router ${hop.name}`);
873
+ await seen.until(hop.form === "reset-both" ? ["resetter"] : ["ct-watch"], 3e4, `router ${hop.name}`);
874
+ inChain[at] = {
875
+ hop,
876
+ box,
877
+ injector: box,
878
+ ip: {
879
+ client: await ipOn(box, clientSide),
880
+ target: await ipOn(box, targetSide)
881
+ },
882
+ peer: {
883
+ client: "",
884
+ target: endpointOf(at + 1)
885
+ },
886
+ netns: await netnsOf(box),
887
+ sysctls: { "net.netfilter.nf_conntrack_tcp_timeout_established": String(ready["idle"]) }
888
+ };
889
+ continue;
890
+ }
891
+ const next = endpointOf(at + 1);
892
+ let proxy = new GenericContainer(hop.image).withNetwork(targetSide);
893
+ if (hop.command !== void 0) proxy = proxy.withCommand([...hop.command]);
894
+ if (hop.files !== void 0) proxy = proxy.withCopyContentToContainer(Object.entries(hop.files).map(([path, content]) => ({
895
+ content: templated(content, next),
896
+ target: path
897
+ })));
898
+ if (hop.ready !== void 0) proxy = proxy.withWaitStrategy(Wait.forLogMessage(hop.ready));
899
+ const box = await proxy.start();
900
+ opened.push(box);
901
+ await join(box, clientSide);
902
+ const injector = await new GenericContainer(image).withNetworkMode(`container:${box.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
903
+ opened.push(injector);
904
+ inChain[at] = {
905
+ hop,
906
+ box,
907
+ injector,
908
+ ip: {
909
+ client: await ipOn(box, clientSide),
910
+ target: await ipOn(box, targetSide)
911
+ },
912
+ peer: {
913
+ client: "",
914
+ target: next
915
+ },
916
+ netns: await netnsOf(injector),
917
+ sysctls: {}
918
+ };
919
+ }
920
+ const client = await new GenericContainer(image).withNetwork(networks[0]).start();
597
921
  opened.push(client);
598
922
  const admin = await new GenericContainer(image).withNetworkMode(`container:${client.getId()}`).withPrivilegedMode().start();
599
923
  opened.push(admin);
600
924
  const injector = await new GenericContainer(image).withNetworkMode(`container:${server.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
601
925
  opened.push(injector);
926
+ const clientIp = await ipOn(client, networks[0]);
927
+ for (let at = 0; at < inChain.length; at += 1) {
928
+ const before = at === 0 ? clientIp : inChain[at - 1].ip.target;
929
+ inChain[at] = {
930
+ ...inChain[at],
931
+ peer: {
932
+ ...inChain[at].peer,
933
+ client: before
934
+ }
935
+ };
936
+ }
937
+ const admins = /* @__PURE__ */ new Map();
938
+ const adminOf = async (element) => {
939
+ if (element === 0) return admin;
940
+ const hop = inChain[element - 1];
941
+ if (hop.hop.kind === "router") return hop.box;
942
+ let found = admins.get(element);
943
+ if (found === void 0) {
944
+ found = await new GenericContainer(image).withNetworkMode(`container:${hop.box.getId()}`).withPrivilegedMode().start();
945
+ opened.push(found);
946
+ admins.set(element, found);
947
+ }
948
+ return found;
949
+ };
950
+ for (const route of routesOf(hops)) {
951
+ const where = await adminOf(route.from);
952
+ const via = inChain[route.via - 1].ip.client;
953
+ for (const network of route.networks) await output(where, [
954
+ "ip",
955
+ "route",
956
+ "replace",
957
+ await subnetOf(networks[network]),
958
+ "via",
959
+ via
960
+ ]);
961
+ }
962
+ for (let at = 0; at < inChain.length; at += 1) {
963
+ const hop = inChain[at];
964
+ if (hop.hop.kind !== "proxy" || hop.hop.sysctls === void 0) continue;
965
+ const where = await adminOf(at + 1);
966
+ const settings = Object.entries(hop.hop.sysctls).map(([key, value]) => `${key}=${value}`);
967
+ await output(where, [
968
+ "sysctl",
969
+ "-q",
970
+ "-w",
971
+ ...settings
972
+ ]);
973
+ for (const key of Object.keys(hop.hop.sysctls)) hop.sysctls[key] = await output(where, [
974
+ "sysctl",
975
+ "-n",
976
+ key
977
+ ]);
978
+ }
602
979
  const netns = {
603
980
  client: await netnsOf(admin),
604
- target: await netnsOf(injector)
981
+ target: await netnsOf(injector),
982
+ ...inChain.length === 0 ? {} : { hops: Object.fromEntries(inChain.map((hop) => [hop.hop.name, hop.netns])) }
605
983
  };
606
984
  const records = lines();
607
985
  const prober = await new GenericContainer(image).withPrivilegedMode().withNetworkMode("none").withCommand([
608
986
  "/opt/harness/prober",
609
987
  String(netns.client),
610
- String(netns.target)
988
+ String(netns.target),
989
+ ...inChain.map((hop) => String(hop.netns))
611
990
  ]).withLogConsumer(records.consume).start();
612
991
  opened.push(prober);
613
992
  const begun = await records.until(["begin", "fail"], 3e4, "prober");
614
993
  if (begun.ev === "fail") throw new Error(`the prober failed: ${String(begun["why"])}`);
615
- const targetIp = server.getIpAddress(network.getName());
616
- const clientIp = client.getIpAddress(network.getName());
617
- const runtime = await getContainerRuntimeClient();
994
+ const first = endpointAfter(hops, 0);
995
+ const targetIp = first === hops.length + 1 ? serverIp : inChain[first - 1].ip.client;
996
+ const targetPort = first === hops.length + 1 ? target.port : inChain[first - 1].hop.port;
997
+ const targetPeer = inChain.length === 0 ? clientIp : inChain.at(-1).ip.target;
998
+ const placed = inChain.length === 0 ? null : {
999
+ targetPeer,
1000
+ hops: Object.fromEntries(inChain.map((hop) => [hop.hop.name, {
1001
+ netns: hop.netns,
1002
+ client: hop.peer.client,
1003
+ target: hop.peer.target
1004
+ }]))
1005
+ };
618
1006
  const paused = runtime.container.getById(server.getId());
619
1007
  let targetAdmin;
1008
+ const hopNamed = (name) => {
1009
+ const found = inChain.find((hop) => hop.hop.name === name);
1010
+ if (found === void 0) throw new Error(`the topology has no hop ${name}`);
1011
+ return found;
1012
+ };
1013
+ /** This function gives the namespace and the peer of the rules of a fault at a place. */
1014
+ const placeOf = (at) => {
1015
+ if (at === "client") return [admin, targetIp];
1016
+ if (at === "target") return [injector, targetPeer];
1017
+ const hop = hopNamed(at.hop);
1018
+ if (hop.hop.kind !== "proxy") throw new Error(`a fault at ${at.hop} needs a proxy: a router forwards past INPUT and OUTPUT`);
1019
+ return [hop.injector, at.toward === "client" ? hop.peer.client : hop.peer.target.split(":")[0]];
1020
+ };
1021
+ const toxiproxy = () => {
1022
+ const found = inChain.find((hop) => hop.hop.kind === "proxy" && hop.hop.api !== void 0);
1023
+ if (found === void 0) throw new Error("the topology has no hop with the API of Toxiproxy");
1024
+ return found;
1025
+ };
1026
+ const stamped = async (where, argv) => {
1027
+ const stamp = JSON.parse((await output(where, argv)).split("\n").at(-1));
1028
+ return {
1029
+ t0: stamp.t0,
1030
+ t1: stamp.t1
1031
+ };
1032
+ };
1033
+ const proxies = inChain.filter((hop) => hop.hop.kind === "proxy");
620
1034
  return {
621
1035
  targetIp,
1036
+ targetPort,
622
1037
  clientIp,
623
1038
  netns,
1039
+ topology: placed,
1040
+ middlebox,
624
1041
  kernel: records.seen,
625
1042
  async sysctl(values) {
626
1043
  const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
@@ -673,21 +1090,16 @@ const openLab = async (target, kind = "tcp") => {
673
1090
  return read;
674
1091
  },
675
1092
  async fault(kind, at = "target", options = {}) {
676
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
677
- const args = [
1093
+ const [where, peer] = placeOf(at);
1094
+ return stamped(where, [
678
1095
  "/opt/harness/inject",
679
1096
  kind,
680
1097
  peer,
681
1098
  ...optionArgs(options)
682
- ];
683
- const stamp = JSON.parse(await output(where, args));
684
- return {
685
- t0: stamp.t0,
686
- t1: stamp.t1
687
- };
1099
+ ]);
688
1100
  },
689
1101
  async lift(kind, at = "target", options = {}) {
690
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
1102
+ const [where, peer] = placeOf(at);
691
1103
  await output(where, [
692
1104
  "/opt/harness/inject",
693
1105
  LIFT[kind],
@@ -707,11 +1119,7 @@ const openLab = async (target, kind = "tcp") => {
707
1119
  "eth0",
708
1120
  ...spec.split(/\s+/)
709
1121
  ];
710
- const stamp = JSON.parse(await output(where, args));
711
- return {
712
- t0: stamp.t0,
713
- t1: stamp.t1
714
- };
1122
+ return stamped(where, args);
715
1123
  },
716
1124
  async offload(on, at = "target") {
717
1125
  await output(at === "client" ? admin : injector, [
@@ -721,6 +1129,42 @@ const openLab = async (target, kind = "tcp") => {
721
1129
  on ? "on" : "off"
722
1130
  ]);
723
1131
  },
1132
+ async toxic(toxic) {
1133
+ const hop = toxiproxy();
1134
+ const api = `127.0.0.1:${hop.hop.api.port}`;
1135
+ const path = `/proxies/${hop.hop.api.proxy}/toxics`;
1136
+ return stamped(hop.injector, toxic === null ? [
1137
+ "/opt/harness/inject",
1138
+ "http",
1139
+ "DELETE",
1140
+ api,
1141
+ `${path}/fault`
1142
+ ] : [
1143
+ "/opt/harness/inject",
1144
+ "http",
1145
+ "POST",
1146
+ api,
1147
+ path,
1148
+ toxicBody(toxic)
1149
+ ]);
1150
+ },
1151
+ async serverClose() {
1152
+ if (target.close === void 0) throw new Error(`${target.image} names no way to close its clients`);
1153
+ await output(server, [...target.close]);
1154
+ },
1155
+ async agent(agentImage, argv, options = {}) {
1156
+ const Shared = shared(GenericContainer);
1157
+ const seen = lines();
1158
+ let started = new Shared(agentImage).withNetworkMode(`container:${server.getId()}`).withCommand([...argv]).withLogConsumer(seen.consume);
1159
+ if (options.pid === true) started = started.withPidMode(`container:${server.getId()}`);
1160
+ const container = await started.start();
1161
+ opened.push(container);
1162
+ return {
1163
+ lines: seen,
1164
+ exec: (args) => output(container, [...args]),
1165
+ stop: () => container.stop(STOP).then(() => void 0).catch(() => void 0)
1166
+ };
1167
+ },
724
1168
  async kill() {
725
1169
  if (target.kill === void 0) throw new Error(`${target.image} names no way to kill its server`);
726
1170
  await output(server, [...target.kill]);
@@ -740,25 +1184,29 @@ const openLab = async (target, kind = "tcp") => {
740
1184
  "2",
741
1185
  "-W",
742
1186
  "2",
743
- clientIp
1187
+ targetPeer
1188
+ ]);
1189
+ for (const hop of proxies) await output(hop.injector, [
1190
+ "ping",
1191
+ "-c",
1192
+ "2",
1193
+ "-W",
1194
+ "2",
1195
+ hop.peer.target.split(":")[0]
744
1196
  ]);
745
1197
  },
746
1198
  async setup(commands) {
747
1199
  for (const argv of commands) await output(server, [...argv]);
748
1200
  },
749
1201
  async heal(at, when) {
750
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
751
- const stamp = JSON.parse(await output(where, [
1202
+ const [where, peer] = placeOf(at);
1203
+ return stamped(where, [
752
1204
  "/opt/harness/inject",
753
1205
  "clear",
754
1206
  peer,
755
1207
  "--at",
756
1208
  String(Math.round(when))
757
- ]));
758
- return {
759
- t0: stamp.t0,
760
- t1: stamp.t1
761
- };
1209
+ ]);
762
1210
  },
763
1211
  async forget() {
764
1212
  await output(admin, [
@@ -773,6 +1221,12 @@ const openLab = async (target, kind = "tcp") => {
773
1221
  "flush",
774
1222
  "all"
775
1223
  ]);
1224
+ for (const hop of proxies) await output(hop.injector, [
1225
+ "ip",
1226
+ "tcp_metrics",
1227
+ "flush",
1228
+ "all"
1229
+ ]);
776
1230
  },
777
1231
  freeze: () => paused.pause().then(() => void 0),
778
1232
  thaw: () => paused.unpause().then(() => void 0),
@@ -806,6 +1260,34 @@ const openLab = async (target, kind = "tcp") => {
806
1260
  async manifest(sysctls, settings = {}, targetSysctls = {}) {
807
1261
  const config = await output(admin, ["zcat", "/proc/config.gz"]).catch(() => "");
808
1262
  const inspect = async (container) => runtime.container.inspect(runtime.container.getById(container.getId()));
1263
+ const hopsOf = async () => {
1264
+ const out = [];
1265
+ for (const hop of inChain) {
1266
+ const box = hop.hop;
1267
+ out.push({
1268
+ name: box.name,
1269
+ kind: box.kind,
1270
+ image: box.kind === "router" ? await routerImage() : box.image,
1271
+ id: (await inspect(hop.box)).Image,
1272
+ settings: box.kind === "router" ? {
1273
+ form: box.form,
1274
+ idle: String(valueIn(box.idle, "s")),
1275
+ pump: String(valueIn(box.pump ?? PUMP, "s")),
1276
+ slow: String(valueIn(LISTING, "s")),
1277
+ packages: (await output(hop.box, [
1278
+ "apk",
1279
+ "info",
1280
+ "-v"
1281
+ ])).split("\n").sort().join(" ")
1282
+ } : {
1283
+ ...box.settings,
1284
+ ...box.files
1285
+ },
1286
+ sysctls: hop.sysctls
1287
+ });
1288
+ }
1289
+ return out;
1290
+ };
809
1291
  return {
810
1292
  kernel: await output(client, ["uname", "-r"]),
811
1293
  architecture: await output(client, ["uname", "-m"]),
@@ -828,11 +1310,12 @@ const openLab = async (target, kind = "tcp") => {
828
1310
  ])).split("\n").sort()
829
1311
  },
830
1312
  client: {
831
- kind,
1313
+ kind: clientKind,
832
1314
  image: clientImage
833
1315
  },
834
1316
  netns,
835
- sysctls
1317
+ sysctls,
1318
+ ...inChain.length === 0 ? {} : { hops: await hopsOf() }
836
1319
  };
837
1320
  },
838
1321
  close
@@ -850,7 +1333,7 @@ const openLab = async (target, kind = "tcp") => {
850
1333
  * again later. Thus the records get a new analysis after a change to observe.ts or to the verdict.
851
1334
  */
852
1335
  const judge = (cell, runs) => {
853
- const measured = runs.map((run) => measure(cell, run));
1336
+ const measured = runs.filter((run) => run.warmup !== true).map((run) => measure(cell, run));
854
1337
  return {
855
1338
  measured,
856
1339
  verdict: verdictOf(cell.prediction, measured)
@@ -864,10 +1347,10 @@ const droppedSoFar = (records) => {
864
1347
  /** This function gives the argv of the client for a cell, with a deadline far after all that it waits for. */
865
1348
  const probeArgs = (cell, lab) => {
866
1349
  const { prediction, probe } = cell;
867
- const longest = prediction.detects ? valueIn(prediction.high, "s") : valueIn(prediction.window, "s");
1350
+ const longest = (prediction.detects ? valueIn(prediction.high, "s") : valueIn(prediction.window, "s")) + (cell.idleBefore === void 0 ? 0 : valueIn(cell.idleBefore, "s"));
868
1351
  const args = [
869
1352
  lab.targetIp,
870
- String(cell.target.port),
1353
+ String(lab.targetPort),
871
1354
  "--mode",
872
1355
  probe.mode
873
1356
  ];
@@ -885,19 +1368,21 @@ const probeArgs = (cell, lab) => {
885
1368
  return args;
886
1369
  };
887
1370
  /**
888
- * This function waits for an error on the socket of the target for the connection of the
889
- * client, or for its end of file (CLOSE_WAIT, state 8). The prober records both in the namespace
890
- * of the target. A cell on the server side waits for that. The promise resolves after `within` ms
891
- * at the latest.
1371
+ * This function waits for an error on the socket of the observer, or for its end of file
1372
+ * (CLOSE_WAIT, state 8). The prober records both. A cell whose observer is the server, or a proxy
1373
+ * of a topology, waits for that. The promise resolves after `within` ms at the latest.
892
1374
  */
893
- const serverError = (lab, local, within) => new Promise((resolve) => {
1375
+ const observerError = (cell, partial, within) => new Promise((resolve) => {
894
1376
  const started = Date.now();
895
1377
  const look = () => {
896
- if (lab.kernel.some((record) => record.netns === lab.netns.target && record.dst === local && (record.ev === "error" && (record.err ?? 0) !== 0 || record.ev === "state" && record.new === 8)) || Date.now() - started > within) resolve();
1378
+ const run = partial();
1379
+ const at = socketOf(cell, run);
1380
+ 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();
897
1381
  else setTimeout(look, 50);
898
1382
  };
899
1383
  look();
900
1384
  });
1385
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
901
1386
  /**
902
1387
  * This function waits for the request that an in-flight client sends on the signal: its second
903
1388
  * send, after the PING that made it ready. Its stamp is when the request first met the fault.
@@ -912,25 +1397,28 @@ const requestSent = (probe, within) => new Promise((resolve, reject) => {
912
1397
  };
913
1398
  look();
914
1399
  });
1400
+ /** This function gives the netem specification of a cell: its latency, then the netem of its fault when it has one. */
1401
+ const netemSpec = (cell, fault) => {
1402
+ const parts = [...cell.latency === void 0 ? [] : [`delay ${valueIn(cell.latency, "ms")}ms`], ...fault === void 0 ? [] : [fault]];
1403
+ return parts.length === 0 ? null : parts.join(" ");
1404
+ };
915
1405
  /**
916
1406
  * This function does one run. The client connects and gets its reply, the fault occurs, and an
917
1407
  * in-flight client sends into it. The harness then waits for the observer to see the fault, or for
918
1408
  * the silent window to end. The observer is the client, or the socket of the target for a cell on
919
- * the server side.
1409
+ * the server side. It can also be a socket of a proxy of a topology.
920
1410
  *
921
1411
  * A stall stops by itself, at its length after the send of the request, and the
922
1412
  * run waits for the reply. Then the harness removes the fault, thus the environment can serve the
923
1413
  * next run. The run keeps the records of the prober from its start to its end. The frozen process
924
1414
  * is the peer of the observer: the target, or the client for the server.
1415
+ *
1416
+ * A dry run does all of this but the fault: the warm-up of a cell whose fault ends its environment.
925
1417
  */
926
- /** This function gives the netem specification of a cell: its latency, then the netem of its fault when it has one. */
927
- const netemSpec = (cell, fault) => {
928
- const parts = [...cell.latency === void 0 ? [] : [`delay ${valueIn(cell.latency, "ms")}ms`], ...fault === void 0 ? [] : [fault]];
929
- return parts.length === 0 ? null : parts.join(" ");
930
- };
931
- const once = async (lab, cell, environment, index) => {
1418
+ const once = async (lab, cell, environment, index, dry = false) => {
932
1419
  await lab.forget();
933
1420
  const from = lab.kernel.length;
1421
+ const middleboxFrom = lab.middlebox.length;
934
1422
  const lostBefore = droppedSoFar(lab.kernel);
935
1423
  const probe = await lab.probe(probeArgs(cell, lab));
936
1424
  const server = cell.observer === "server";
@@ -945,89 +1433,124 @@ const once = async (lab, cell, environment, index) => {
945
1433
  const last = probe.events.slice(-3).map((event) => JSON.stringify(event));
946
1434
  throw new Error(`${error.message}; the client's last lines: ${last.join(" ")}`);
947
1435
  });
948
- let fault;
1436
+ const placed = lab.topology === null ? {} : { topology: lab.topology };
1437
+ /** This function gives the run as the records show it so far: the waits read it. */
1438
+ const partial = () => ({
1439
+ environment,
1440
+ index,
1441
+ events: probe.events,
1442
+ fault: null,
1443
+ kernel: lab.kernel.slice(from),
1444
+ netns: lab.netns.client,
1445
+ targetNetns: lab.netns.target,
1446
+ lost: 0,
1447
+ ...placed
1448
+ });
1449
+ let fault = null;
949
1450
  let heal = null;
950
1451
  try {
951
1452
  await probe.until(["ready"], 15e3);
952
- const replyInFlight = cell.probe.mode === "inflight" && (server && cell.fault !== "mtu-blackhole" || cell.fault === "fin");
953
- if (replyInFlight) {
954
- await go();
955
- await new Promise((resolve) => setTimeout(resolve, 200));
956
- }
957
- const faultAt = server ? "client" : "target";
958
- const direction = cell.drops === void 0 ? void 0 : cell.drops === "to-server" === (faultAt === "target") ? "in" : "out";
959
- const options = {
960
- ...direction === void 0 ? {} : { direction },
961
- ...cell.icmp === void 0 ? {} : { code: cell.icmp },
962
- ...cell.mtu === void 0 ? {} : { above: cell.mtu }
963
- };
964
- const apply = async () => {
965
- switch (cell.fault) {
966
- case "freeze-process":
967
- await peer.freeze();
968
- return null;
969
- case "fin":
970
- await (server ? probe.kill() : lab.kill());
971
- return null;
972
- case "unreachable": return lab.fault("reject", faultAt, { code: cell.icmp ?? "host" });
973
- case "simplex": return lab.fault("blackhole", faultAt, options);
974
- case "mtu-blackhole": return lab.fault("mtu", "client", { above: cell.mtu ?? 1200 });
975
- case "netem": return lab.netem(netemSpec(cell, cell.netem), "target");
976
- case "rst": return lab.fault("rst", faultAt);
977
- default: return lab.fault("blackhole", faultAt);
1453
+ if (dry) {
1454
+ if (cell.probe.mode !== "idle") await go();
1455
+ await sleep(500);
1456
+ } else {
1457
+ const replyInFlight = cell.probe.mode === "inflight" && (server && cell.fault !== "mtu-blackhole" || cell.fault === "fin");
1458
+ if (replyInFlight) {
1459
+ await go();
1460
+ await sleep(200);
978
1461
  }
979
- };
980
- const lift = async () => {
981
- switch (cell.fault) {
982
- case "freeze-process": return peer.thaw();
983
- case "fin": return;
984
- case "unreachable": return lab.lift("reject", faultAt, { code: cell.icmp ?? "host" });
985
- case "simplex": return lab.lift("blackhole", faultAt, options);
986
- case "mtu-blackhole": return lab.lift("mtu", "client", { above: cell.mtu ?? 1200 });
987
- case "netem":
988
- await lab.netem(netemSpec(cell, void 0), "target");
989
- return;
990
- case "rst": return lab.lift("rst", faultAt);
991
- default: return heal === null ? lab.lift("blackhole", faultAt) : void 0;
1462
+ const faultAt = cell.faultAt ?? (server ? "client" : "target");
1463
+ const towardServerComesIn = faultAt === "target" || typeof faultAt === "object" && faultAt.toward === "client";
1464
+ const direction = cell.drops === void 0 ? void 0 : cell.drops === "to-server" === towardServerComesIn ? "in" : "out";
1465
+ const options = {
1466
+ ...direction === void 0 ? {} : { direction },
1467
+ ...cell.icmp === void 0 ? {} : { code: cell.icmp },
1468
+ ...cell.mtu === void 0 ? {} : { above: cell.mtu }
1469
+ };
1470
+ const toxicFirst = cell.toxic !== void 0 && cell.fault !== "toxic";
1471
+ if (toxicFirst) await lab.toxic(cell.toxic);
1472
+ const apply = async () => {
1473
+ switch (cell.fault) {
1474
+ case "freeze-process":
1475
+ await peer.freeze();
1476
+ return null;
1477
+ case "fin":
1478
+ await (server ? probe.kill() : lab.kill());
1479
+ return null;
1480
+ case "unreachable": return lab.fault("reject", faultAt, { code: cell.icmp ?? "host" });
1481
+ case "simplex": return lab.fault("blackhole", faultAt, options);
1482
+ case "mtu-blackhole": return lab.fault("mtu", "client", { above: cell.mtu ?? 1200 });
1483
+ case "netem": return lab.netem(netemSpec(cell, cell.netem), "target");
1484
+ case "rst": return lab.fault("rst", faultAt);
1485
+ case "middlebox-forget":
1486
+ case "proxy-idle": return null;
1487
+ case "toxic":
1488
+ if (cell.toxic === void 0) throw new Error(`${cell.name} names no toxic`);
1489
+ return lab.toxic(cell.toxic);
1490
+ case "server-close":
1491
+ await lab.serverClose();
1492
+ return null;
1493
+ default: return lab.fault("blackhole", faultAt);
1494
+ }
1495
+ };
1496
+ const lift = async () => {
1497
+ switch (cell.fault) {
1498
+ case "freeze-process": return peer.thaw();
1499
+ case "fin":
1500
+ case "middlebox-forget":
1501
+ case "proxy-idle":
1502
+ case "server-close": return;
1503
+ case "unreachable": return lab.lift("reject", faultAt, { code: cell.icmp ?? "host" });
1504
+ case "simplex": return lab.lift("blackhole", faultAt, options);
1505
+ case "mtu-blackhole": return lab.lift("mtu", "client", { above: cell.mtu ?? 1200 });
1506
+ case "netem":
1507
+ await lab.netem(netemSpec(cell, void 0), "target");
1508
+ return;
1509
+ case "rst": return lab.lift("rst", faultAt);
1510
+ case "toxic":
1511
+ await lab.toxic(null);
1512
+ return;
1513
+ default: return heal === null ? lab.lift("blackhole", faultAt) : void 0;
1514
+ }
1515
+ };
1516
+ fault = await apply();
1517
+ try {
1518
+ if (cell.idleBefore !== void 0 && cell.probe.mode !== "idle") await sleep(valueIn(cell.idleBefore, "ms"));
1519
+ if (cell.probe.mode !== "idle" && !replyInFlight) await go();
1520
+ const { prediction } = cell;
1521
+ if (cell.fault === "stall") {
1522
+ if (cell.stall === void 0) throw new Error(`${cell.name} says no stall's length`);
1523
+ const sent = await requestSent(probe, 5e3);
1524
+ heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
1525
+ const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
1526
+ await probe.until([
1527
+ "data",
1528
+ "error",
1529
+ "eof"
1530
+ ], window * 1e3).catch(() => void 0);
1531
+ } else if (prediction.detects === null) {
1532
+ const window = valueIn(prediction.window, "s") * 1e3;
1533
+ await probe.until([
1534
+ "done",
1535
+ "error",
1536
+ "eof",
1537
+ "deadline"
1538
+ ], window).catch(() => void 0);
1539
+ } else if (prediction.detects) {
1540
+ const within = valueIn(prediction.high, "s") * 2e3 + 1e4;
1541
+ if (cell.observer !== void 0 && cell.observer !== "client") await observerError(cell, partial, within);
1542
+ else await probe.until([
1543
+ "error",
1544
+ "eof",
1545
+ "deadline"
1546
+ ], within).catch(() => void 0);
1547
+ } else await sleep(valueIn(prediction.window, "s") * 1e3);
1548
+ } finally {
1549
+ await lift();
1550
+ if (toxicFirst) await lab.toxic(null);
992
1551
  }
993
- };
994
- fault = await apply();
995
- try {
996
- if (cell.probe.mode !== "idle" && !replyInFlight) await go();
997
- const { prediction } = cell;
998
- if (cell.fault === "stall") {
999
- if (cell.stall === void 0) throw new Error(`${cell.name} says no stall's length`);
1000
- const sent = await requestSent(probe, 5e3);
1001
- heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
1002
- const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
1003
- await probe.until([
1004
- "data",
1005
- "error",
1006
- "eof"
1007
- ], window * 1e3).catch(() => void 0);
1008
- } else if (prediction.detects === null) {
1009
- const window = valueIn(prediction.window, "s") * 1e3;
1010
- await probe.until([
1011
- "done",
1012
- "error",
1013
- "eof",
1014
- "deadline"
1015
- ], window).catch(() => void 0);
1016
- } else if (prediction.detects) {
1017
- const within = valueIn(prediction.high, "s") * 2e3 + 1e4;
1018
- if (server) {
1019
- const local = probe.events.find((event) => event.ev === "connected")?.["local"];
1020
- await serverError(lab, local, within);
1021
- } else await probe.until([
1022
- "error",
1023
- "eof",
1024
- "deadline"
1025
- ], within).catch(() => void 0);
1026
- } else await new Promise((resolve) => setTimeout(resolve, valueIn(prediction.window, "s") * 1e3));
1027
- } finally {
1028
- await lift();
1552
+ if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
1029
1553
  }
1030
- if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
1031
1554
  } finally {
1032
1555
  await probe.stop();
1033
1556
  }
@@ -1042,14 +1565,25 @@ const once = async (lab, cell, environment, index) => {
1042
1565
  kernel: lab.kernel.slice(from),
1043
1566
  netns: lab.netns.client,
1044
1567
  targetNetns: lab.netns.target,
1045
- lost
1568
+ lost,
1569
+ ...placed,
1570
+ ...lab.topology === null ? {} : { middlebox: lab.middlebox.slice(middleboxFrom) }
1046
1571
  };
1047
1572
  };
1048
1573
  /** This function divides a count of runs across environments, as equally as possible. */
1049
1574
  const spread = (runs, environments) => Array.from({ length: environments }, (_, at) => Math.floor(runs / environments) + (at < runs % environments ? 1 : 0)).filter((count) => count > 0);
1050
- /** This function does the part of the runs of a cell in one environment, and records its manifest. */
1575
+ /** This function tells if the fault of a cell ends its environment: a server that stopped cannot serve the next run. */
1576
+ const endsEnvironment = (cell) => cell.fault === "fin" && (cell.observer === void 0 || cell.observer === "client");
1577
+ /** This function says one line for a run. */
1578
+ const line = (cell, run, read) => `${cell.name}: env ${run.environment} ${run.warmup === true ? "warm-up" : `run ${run.index}`} ${read.outcome.kind}${read.elapsed === null ? "" : ` ${read.elapsed.value} s`}${read.void === null ? "" : ` (void: ${read.void})`}`;
1579
+ /**
1580
+ * This function does the part of the runs of a cell in one environment, and records its manifest.
1581
+ * A warm-up goes first, and the verdict does not count it. On a loaded machine, a new environment is
1582
+ * sometimes slow at first: its first exec, its first client, its first fault. The warm-up
1583
+ * of a cell whose fault ends the environment is a dry run, without the fault.
1584
+ */
1051
1585
  const inEnvironment = async (cell, environment, count, say) => {
1052
- const lab = await openLab(cell.target, cell.probe.driver?.name ?? "tcp");
1586
+ const lab = await openLab(cell.target, cell.client ?? cell.probe.driver?.name ?? "tcp", cell.topology);
1053
1587
  try {
1054
1588
  const settings = await lab.configure(cell.targetSettings ?? {});
1055
1589
  const targetSysctls = await lab.targetSysctl(cell.targetSysctls ?? {});
@@ -1062,16 +1596,21 @@ const inEnvironment = async (cell, environment, count, say) => {
1062
1596
  if (latency !== null) await lab.netem(latency, "target");
1063
1597
  await lab.warm();
1064
1598
  const manifest = await lab.manifest(await lab.sysctl(cell.sysctls), settings, targetSysctls);
1599
+ const warmup = {
1600
+ ...await once(lab, cell, environment, -1, endsEnvironment(cell)),
1601
+ warmup: true
1602
+ };
1603
+ say(line(cell, warmup, measure(cell, warmup)));
1065
1604
  const runs = [];
1066
1605
  for (let index = 0; index < count; index += 1) {
1067
1606
  const run = await once(lab, cell, environment, index);
1068
1607
  runs.push(run);
1069
- const read = measure(cell, run);
1070
- say(`${cell.name}: env ${environment} run ${index} ${read.outcome.kind}${read.elapsed === null ? "" : ` ${read.elapsed.value} s`}${read.void === null ? "" : ` (void: ${read.void})`}`);
1608
+ say(line(cell, run, measure(cell, run)));
1071
1609
  }
1072
1610
  return {
1073
1611
  manifest,
1074
- runs
1612
+ runs,
1613
+ warmup
1075
1614
  };
1076
1615
  } finally {
1077
1616
  await lab.close();
@@ -1083,11 +1622,14 @@ const inEnvironment = async (cell, environment, count, say) => {
1083
1622
  * the cell from 11 runs to 22, and then to 44, each extension in new environments. With a
1084
1623
  * concurrency of more than one, that many environments operate at the same time, each with its
1085
1624
  * own prober. The runs then go across at least as many environments.
1625
+ *
1626
+ * Each environment does a warm-up first, and the result keeps it apart from the counted runs.
1086
1627
  */
1087
1628
  const runCell = async (cell, say = () => {}) => {
1088
1629
  const started = (/* @__PURE__ */ new Date()).toISOString();
1089
1630
  const manifests = [];
1090
1631
  const runs = [];
1632
+ const warmups = [];
1091
1633
  const concurrency = Math.max(1, cell.concurrency ?? 1);
1092
1634
  let target = cell.runs;
1093
1635
  let next = 0;
@@ -1102,6 +1644,7 @@ const runCell = async (cell, say = () => {}) => {
1102
1644
  for (const each of done) {
1103
1645
  manifests.push(each.manifest);
1104
1646
  runs.push(...each.runs);
1647
+ warmups.push(each.warmup);
1105
1648
  }
1106
1649
  }
1107
1650
  const { measured, verdict } = judge(cell, runs);
@@ -1110,6 +1653,7 @@ const runCell = async (cell, say = () => {}) => {
1110
1653
  cell,
1111
1654
  manifests,
1112
1655
  runs,
1656
+ warmups,
1113
1657
  measured,
1114
1658
  verdict,
1115
1659
  at: {
@@ -1121,6 +1665,6 @@ const runCell = async (cell, say = () => {}) => {
1121
1665
  }
1122
1666
  };
1123
1667
  //#endregion
1124
- export { NOISE, RELATIVE, STEPS, harnessImage, judge, measure, medianInterval, nodeClientImage, openLab, runCell, spread, verdictOf };
1668
+ export { LISTING, NOISE, PUMP, RELATIVE, STEPS, endpointAfter, harnessImage, judge, measure, medianInterval, nodeClientImage, openLab, routerImage, routesOf, runCell, socketOf, spread, templated, toxicBody, verdictOf };
1125
1669
 
1126
1670
  //# sourceMappingURL=index.js.map