@mark1russell7/harness 0.1.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 = {
@@ -18,17 +204,83 @@ const ERRNO = {
18
204
  };
19
205
  /** This function gives the interval between two instants on the one clock, in seconds to the microsecond. */
20
206
  const between = (from, to) => quantity(Math.round((to - from) / 1e3) / 1e6, "s");
207
+ /** These functions give the states of a state record: before the change, and after it. TCP_ESTABLISHED is 1, TCP_CLOSE_WAIT 8. */
208
+ const oldState = (record) => record.old;
209
+ const newState = (record) => record.new;
21
210
  const count = (event, field) => event === void 0 ? 0 : Number(event[field] ?? 0);
22
211
  /** This function gives the view of the client on the same interval: TCP_INFO, with a sample each 2 ms. */
23
212
  const socketVantage = (cell, events, ready, request) => {
24
- if (!cell.prediction.detects) return null;
213
+ if (!cell.prediction.detects || cell.prediction.from === "peer-closed" || cell.prediction.from === "fault") return null;
25
214
  const ended = events.find((event) => event.ev === "error" || event.ev === "eof");
26
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);
27
216
  return ended === void 0 || reference === void 0 ? null : between(reference.t, ended.t);
28
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
+ };
29
280
  /**
30
281
  * This function reads one run against its cell, from the records of the kernel first.
31
- * - 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.
32
284
  * - The fault must hold before the event that it must meet. That is before the request goes into
33
285
  * it, and before the first keepalive probe. A live peer answers that probe, and the answer
34
286
  * starts the idle time again. A run where the fault was late is void. A run whose records the
@@ -38,11 +290,19 @@ const measure = (cell, run) => {
38
290
  const { events, kernel } = run;
39
291
  const ready = events.find((event) => event.ev === "ready");
40
292
  const local = events.find((event) => event.ev === "connected")?.["local"];
41
- const request = cell.probe.mode === "inflight" ? events.filter((event) => event.ev === "sent")[1] : ready;
293
+ const inflight = cell.probe.mode !== "idle";
294
+ const request = inflight ? events.filter((event) => event.ev === "sent")[1] : ready;
42
295
  const server = cell.observer === "server";
43
- const netns = server ? run.targetNetns : run.netns;
44
- 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;
45
299
  const socket = kernel.filter((record) => record.netns === netns && record.cookie === 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)}>`));
46
306
  const keepalives = socket.filter((record) => record.ev === "probe" && record.what === "keepalive");
47
307
  const retransmitted = socket.filter((record) => record.ev === "retrans");
48
308
  /**
@@ -103,24 +363,59 @@ const measure = (cell, run) => {
103
363
  };
104
364
  let voided = null;
105
365
  if (run.fault !== null) {
366
+ const replyFirst = server && inflight && cell.fault !== "mtu-blackhole";
106
367
  const replied = events.some((event) => event.ev === "data" && event.t > request.t && event.t < run.fault.t1);
107
- if (cell.probe.mode === "inflight" && !server && run.fault.t1 > request.t) voided = "the request went out before the fault held";
108
- else if (cell.probe.mode === "inflight" && server && run.fault.t1 < request.t) voided = "the request went out into the fault, so the server never had it";
109
- else if (cell.probe.mode === "inflight" && server && replied) voided = "the server's reply arrived before the fault held";
368
+ if (inflight && !replyFirst && run.fault.t1 > request.t) voided = "the request went out before the fault held";
369
+ else if (replyFirst && run.fault.t1 < request.t) voided = "the request went out into the fault, so the server never had it";
370
+ else if (replyFirst && replied) voided = "the server's reply arrived before the fault held";
110
371
  else if (keepalives[0] !== void 0 && keepalives[0].mono < run.fault.t1) voided = "a keepalive probe went out before the fault held";
111
372
  }
373
+ const remote = cell.observer !== void 0 && cell.observer !== "client";
112
374
  const reported = socket.find((record) => record.ev === "error" && (record.err ?? 0) !== 0);
113
- 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");
376
+ const closeWait = socket.find((record) => record.ev === "state" && newState(record) === 8);
114
377
  const outcome = reported !== void 0 ? {
115
378
  kind: "error",
116
379
  errno: ERRNO[reported.err] ?? `E${reported.err}`
117
- } : seen?.ev === "eof" ? { kind: "eof" } : seen !== void 0 ? {
380
+ } : closeWait !== void 0 || seen?.ev === "eof" ? { kind: "eof" } : seen !== void 0 ? {
118
381
  kind: "error",
119
382
  errno: String(seen["errno"])
120
383
  } : { kind: "silent" };
121
- const ended = reported?.mono ?? seen?.t;
384
+ const ended = reported?.mono ?? closeWait?.mono ?? seen?.t;
122
385
  const noticed = reported !== void 0 && seen !== void 0 ? between(reported.mono, seen.t) : null;
123
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
+ };
398
+ if (prediction.detects === null) {
399
+ const sends = events.filter((event) => event.ev === "sent").slice(1);
400
+ const samples = sends.flatMap((send, n) => {
401
+ const next = sends[n + 1]?.t ?? Infinity;
402
+ const reply = events.find((event) => event.ev === "data" && event.t > send.t && event.t < next);
403
+ return reply === void 0 ? [] : [between(send.t, reply.t)];
404
+ });
405
+ const from = prediction.from === void 0 ? void 0 : referenceAt(prediction.from);
406
+ const until = ended ?? after.at(-1)?.t;
407
+ return {
408
+ ...base,
409
+ ...nothing,
410
+ ...chain,
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,
414
+ steps: retransmitted.filter((record) => record.mono > request.t).map((record) => between(request.t, record.mono)),
415
+ samples,
416
+ void: voided
417
+ };
418
+ }
124
419
  if (!prediction.detects && prediction.recovers !== void 0) {
125
420
  const { recovers } = prediction;
126
421
  const heal = run.heal ?? null;
@@ -160,16 +455,54 @@ const measure = (cell, run) => {
160
455
  finding: null
161
456
  };
162
457
  }
163
- if (!prediction.detects) {
458
+ if (!prediction.detects && prediction.replies !== void 0) {
164
459
  const window = valueIn(prediction.window, "s") * 1e9;
165
- if (ended === void 0 || ended - request.t > window) return {
460
+ if (ended !== void 0 && ended - request.t <= window) return {
166
461
  ...base,
167
462
  ...nothing,
168
- 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"
169
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
+ }
170
502
  return {
171
503
  ...base,
172
504
  ...nothing,
505
+ ...chain,
173
506
  outcome,
174
507
  elapsed: between(request.t, ended),
175
508
  void: voided,
@@ -179,28 +512,34 @@ const measure = (cell, run) => {
179
512
  if (ended === void 0) return {
180
513
  ...base,
181
514
  ...nothing,
515
+ ...chain,
182
516
  void: voided,
183
517
  finding: `nothing detected it: ${prediction.timer} never fired`
184
518
  };
185
- const before = run.fault?.t1 ?? ready.t;
186
- 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") : void 0;
187
- const reference = prediction.from === "request-sent" ? request.t : kernelReference?.mono;
519
+ const reference = referenceAt(prediction.from);
188
520
  if (reference === void 0) return {
189
521
  ...base,
190
522
  ...nothing,
523
+ ...chain,
191
524
  outcome,
192
525
  noticed,
193
526
  void: voided,
194
527
  finding: `it ended without its reference event (${prediction.from})`
195
528
  };
196
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));
197
- const finding = outcome.kind === "error" && outcome.errno === prediction.errno ? null : `it ended with ${outcome.kind === "error" ? outcome.errno : "end of file"}, not ${prediction.errno}`;
198
- if (prediction.from !== "request-sent") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
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}`;
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`;
199
537
  return {
200
538
  ...base,
539
+ ...chain,
201
540
  outcome,
202
541
  elapsed: between(reference, ended),
203
- socket: server ? null : socketVantage(cell, events, ready, request),
542
+ socket: remote ? null : socketVantage(cell, events, ready, request),
204
543
  noticed,
205
544
  steps,
206
545
  void: voided,
@@ -208,159 +547,6 @@ const measure = (cell, run) => {
208
547
  };
209
548
  };
210
549
  //#endregion
211
- //#region src/verdict.ts
212
- /**
213
- * This amount is the noise of the pipeline itself, δ_abs, as the calibration measured it. It is
214
- * the 99.9th percentile of the whole latency of a reset, at the concurrency cap or less. Its
215
- * minimum is one jiffy. On 2026-10-12 the worst of 60 resets at 1 and 4 environments took
216
- * 1.141 ms, thus the minimum sets it to 4 ms. The calibration occurs again after each change to
217
- * the harness, the kernel or Docker.
218
- */
219
- const NOISE = quantity(4, "ms");
220
- /** This fraction, δ_rel, is how far the median of a cell can be outside the prediction and stay equivalent. */
221
- const RELATIVE = .05;
222
- /** These are the run counts of a cell: 11, then 22 and 44 while its median crosses the edge of the band. */
223
- const STEPS = [
224
- 11,
225
- 22,
226
- 44
227
- ];
228
- const s = (value) => quantity(Math.round(value * 1e6) / 1e6, "s");
229
- const name = (run) => `env ${run.environment} run ${run.index}`;
230
- /** This function gives P(X ≤ j) for X ~ Binomial(n, 1/2), exactly. */
231
- const below = (n, j) => {
232
- let term = 1;
233
- let sum = 0;
234
- for (let i = 0; i <= j; i += 1) {
235
- sum += term;
236
- term = term * (n - i) / (i + 1);
237
- }
238
- return sum / 2 ** n;
239
- };
240
- /**
241
- * This function gives the distribution-free interval of a median: [x_(k), x_(n+1−k)], for the
242
- * largest k whose two tails each hold 2.5 % or less. For eleven runs, it gives [x_(2), x_(10)],
243
- * at 98.8 %.
244
- */
245
- const medianInterval = (sorted) => {
246
- const n = sorted.length;
247
- let k = 0;
248
- while (k + 1 <= n / 2 && below(n, k) <= .025) k += 1;
249
- return k === 0 ? [sorted[0], sorted[n - 1]] : [sorted[k - 1], sorted[n - k]];
250
- };
251
- const middle = (sorted) => {
252
- const half = Math.floor(sorted.length / 2);
253
- return sorted.length % 2 === 1 ? sorted[half] : (sorted[half - 1] + sorted[half]) / 2;
254
- };
255
- /**
256
- * This function gives the verdict of a cell, from two tests:
257
- * - Containment: each run lands in [T_lo − δ_abs, T_hi + δ_abs]. A run outside is a finding.
258
- * - Median equivalence: the interval of the median is in the band, made wider by δ_rel. An
259
- * interval outside the band fails. An interval across its edge extends the cell to 22 runs, then
260
- * 44, and then the verdict is inconclusive.
261
- *
262
- * A silent prediction holds only if no run detected the fault in its window. After a stall, the
263
- * return of the reply gets the same test as a detection, by its interval, and a detection is a
264
- * finding. The distribution test (a DKW band) needs a predicted distribution from a simulator,
265
- * which does not exist at this time.
266
- */
267
- const verdictOf = (prediction, runs) => {
268
- const counted = runs.filter((run) => run.void === null);
269
- const voided = runs.length - counted.length;
270
- const outside = counted.filter((run) => run.finding !== null).map((run) => ({
271
- run: name(run),
272
- why: run.finding
273
- }));
274
- const empty = {
275
- counted: counted.length,
276
- voided,
277
- median: null,
278
- medianInterval: null
279
- };
280
- if (counted.length === 0) return {
281
- ...empty,
282
- decision: "inconclusive",
283
- band: null,
284
- outside,
285
- why: "no run counted"
286
- };
287
- if (!prediction.detects && prediction.recovers === void 0) {
288
- const decision = outside.length === 0 ? "holds" : "fails";
289
- 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`;
290
- return {
291
- ...empty,
292
- decision,
293
- band: null,
294
- outside,
295
- why
296
- };
297
- }
298
- const timed = prediction.detects ? prediction : prediction.recovers;
299
- const noise = valueIn(NOISE, "s");
300
- const low = valueIn(timed.low, "s");
301
- const high = valueIn(timed.high, "s");
302
- const band = {
303
- low: s(low - noise),
304
- high: s(high + noise)
305
- };
306
- const values = [];
307
- for (const run of counted) {
308
- if (run.elapsed === null) continue;
309
- const value = valueIn(run.elapsed, "s");
310
- values.push(value);
311
- if (value < low - noise || value > high + noise) outside.push({
312
- run: name(run),
313
- why: `${value} s is outside [${band.low.value}, ${band.high.value}] s`
314
- });
315
- }
316
- values.sort((a, b) => a - b);
317
- if (values.length === 0) return {
318
- ...empty,
319
- decision: "fails",
320
- band,
321
- outside,
322
- why: "no run measured its time"
323
- };
324
- const [from, to] = medianInterval(values);
325
- const median = s(middle(values));
326
- const interval = {
327
- low: s(from),
328
- high: s(to)
329
- };
330
- const wide = {
331
- low: low * .95,
332
- high: high * 1.05
333
- };
334
- const inside = from >= wide.low && to <= wide.high;
335
- const disjoint = to < wide.low || from > wide.high;
336
- const counts = `${counted.length - outside.length} of ${counted.length} runs inside [${band.low.value}, ${band.high.value}] s`;
337
- const centre = `median ${median.value} s, its interval [${interval.low.value}, ${interval.high.value}] s`;
338
- const result = {
339
- counted: counted.length,
340
- voided,
341
- band,
342
- outside,
343
- median,
344
- medianInterval: interval
345
- };
346
- if (outside.length > 0 || disjoint) return {
347
- ...result,
348
- decision: "fails",
349
- why: `${counts}; ${centre}`
350
- };
351
- if (inside) return {
352
- ...result,
353
- decision: "holds",
354
- why: `${counts}; ${centre}`
355
- };
356
- const decision = counted.length < STEPS[2] ? "extend" : "inconclusive";
357
- return {
358
- ...result,
359
- decision,
360
- why: `${counts}; ${centre} straddles the band`
361
- };
362
- };
363
- //#endregion
364
550
  //#region src/lab.ts
365
551
  /**
366
552
  * These are the roots of the two packages that the images read: this package, and the prober
@@ -399,6 +585,12 @@ const NODE = {
399
585
  dockerfile: "harness/image/node/Dockerfile",
400
586
  from: ["harness/image/node"]
401
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
+ };
402
594
  /** This function gives each file under a path, in a stable sequence, but not the output of a local build. */
403
595
  const sources = (path) => {
404
596
  if (statSync(path).isFile()) return [path];
@@ -457,7 +649,41 @@ const build = (image) => {
457
649
  const harnessImage = () => build(HARNESS);
458
650
  /** This function gives the image of the Node client. */
459
651
  const nodeClientImage = () => build(NODE);
460
- const lines = () => {
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
+ };
672
+ /** This function gives the arguments of the injector for the options of a fault. */
673
+ const optionArgs = (options) => [
674
+ ...options.direction === void 0 ? [] : ["--direction", options.direction],
675
+ ...options.code === void 0 ? [] : ["--code", options.code],
676
+ ...options.above === void 0 ? [] : ["--above", String(options.above)]
677
+ ];
678
+ /** These are the actions of the injector that lift each network fault. */
679
+ const LIFT = {
680
+ blackhole: "clear",
681
+ rst: "clear-rst",
682
+ reject: "clear-reject",
683
+ mtu: "clear-mtu"
684
+ };
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) => {
461
687
  const seen = [];
462
688
  const other = [];
463
689
  const waiting = [];
@@ -469,6 +695,7 @@ const lines = () => {
469
695
  }
470
696
  const line = JSON.parse(text);
471
697
  seen.push(line);
698
+ also?.(line);
472
699
  for (const wait of waiting.splice(0)) if (wait.kinds.includes(line.ev)) wait.resolve(line);
473
700
  else waiting.push(wait);
474
701
  };
@@ -500,6 +727,42 @@ const lines = () => {
500
727
  }
501
728
  };
502
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
+ };
503
766
  const STOP = { timeout: 0 };
504
767
  const output = async (container, argv) => {
505
768
  const result = await container.exec(argv);
@@ -513,62 +776,268 @@ const netnsOf = async (container) => {
513
776
  if (inode === void 0) throw new Error(`not a network namespace: ${link}`);
514
777
  return Number(inode);
515
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
+ };
516
797
  /**
517
798
  * This function opens one environment:
518
- * - 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.
519
800
  * - a client that holds its own network namespace, with a privileged sidecar in it for its
520
- * 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.
521
805
  * - an injector sidecar in the namespace of the target.
522
- * - the prober, a privileged container that loads the kernel program and watches the two
523
- * 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.
524
808
  *
525
809
  * The client of each run is its own container in the namespace of the client. It is the TCP
526
- * client of the harness, or a driver from its own image (ioredis on Node). The two print the same
527
- * 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.
528
816
  */
529
- const openLab = async (target, kind = "tcp") => {
817
+ const openLab = async (target, kind = "tcp", topology) => {
530
818
  const image = await harnessImage();
531
- const clientImage = kind === "ioredis" ? await nodeClientImage() : image;
532
- 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 ?? [];
533
821
  const { GenericContainer, Network, Wait, getContainerRuntimeClient } = await containers();
822
+ const runtime = await getContainerRuntimeClient();
534
823
  const opened = [];
535
- let network;
824
+ const networks = [];
536
825
  const close = async () => {
537
826
  for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
538
- 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;
539
840
  };
540
841
  try {
541
- network = await new Network().start();
542
- const server = await new GenericContainer(target.image).withNetwork(network).withWaitStrategy(Wait.forLogMessage(target.ready)).start();
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));
845
+ const server = await (target.command === void 0 ? stock : stock.withCommand([...target.command])).start();
543
846
  opened.push(server);
544
- 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();
545
921
  opened.push(client);
546
922
  const admin = await new GenericContainer(image).withNetworkMode(`container:${client.getId()}`).withPrivilegedMode().start();
547
923
  opened.push(admin);
548
924
  const injector = await new GenericContainer(image).withNetworkMode(`container:${server.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
549
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
+ }
550
979
  const netns = {
551
980
  client: await netnsOf(admin),
552
- target: await netnsOf(injector)
981
+ target: await netnsOf(injector),
982
+ ...inChain.length === 0 ? {} : { hops: Object.fromEntries(inChain.map((hop) => [hop.hop.name, hop.netns])) }
553
983
  };
554
984
  const records = lines();
555
985
  const prober = await new GenericContainer(image).withPrivilegedMode().withNetworkMode("none").withCommand([
556
986
  "/opt/harness/prober",
557
987
  String(netns.client),
558
- String(netns.target)
988
+ String(netns.target),
989
+ ...inChain.map((hop) => String(hop.netns))
559
990
  ]).withLogConsumer(records.consume).start();
560
991
  opened.push(prober);
561
992
  const begun = await records.until(["begin", "fail"], 3e4, "prober");
562
993
  if (begun.ev === "fail") throw new Error(`the prober failed: ${String(begun["why"])}`);
563
- const targetIp = server.getIpAddress(network.getName());
564
- const clientIp = client.getIpAddress(network.getName());
565
- 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
+ };
566
1006
  const paused = runtime.container.getById(server.getId());
567
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");
568
1034
  return {
569
1035
  targetIp,
1036
+ targetPort,
570
1037
  clientIp,
571
1038
  netns,
1039
+ topology: placed,
1040
+ middlebox,
572
1041
  kernel: records.seen,
573
1042
  async sysctl(values) {
574
1043
  const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
@@ -620,39 +1089,124 @@ const openLab = async (target, kind = "tcp") => {
620
1089
  }
621
1090
  return read;
622
1091
  },
623
- async fault(kind, at = "target") {
624
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
625
- const stamp = JSON.parse(await output(where, [
1092
+ async fault(kind, at = "target", options = {}) {
1093
+ const [where, peer] = placeOf(at);
1094
+ return stamped(where, [
626
1095
  "/opt/harness/inject",
627
1096
  kind,
628
- peer
629
- ]));
630
- return {
631
- t0: stamp.t0,
632
- t1: stamp.t1
633
- };
1097
+ peer,
1098
+ ...optionArgs(options)
1099
+ ]);
634
1100
  },
635
- async lift(kind, at = "target") {
636
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
1101
+ async lift(kind, at = "target", options = {}) {
1102
+ const [where, peer] = placeOf(at);
637
1103
  await output(where, [
638
1104
  "/opt/harness/inject",
639
- kind === "blackhole" ? "clear" : "clear-rst",
640
- peer
1105
+ LIFT[kind],
1106
+ peer,
1107
+ ...optionArgs(options)
641
1108
  ]);
642
1109
  },
1110
+ async netem(spec, at = "target") {
1111
+ const where = at === "client" ? admin : injector;
1112
+ const args = spec === null ? [
1113
+ "/opt/harness/inject",
1114
+ "clear-netem",
1115
+ "eth0"
1116
+ ] : [
1117
+ "/opt/harness/inject",
1118
+ "netem",
1119
+ "eth0",
1120
+ ...spec.split(/\s+/)
1121
+ ];
1122
+ return stamped(where, args);
1123
+ },
1124
+ async offload(on, at = "target") {
1125
+ await output(at === "client" ? admin : injector, [
1126
+ "/opt/harness/inject",
1127
+ "offload",
1128
+ "eth0",
1129
+ on ? "on" : "off"
1130
+ ]);
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
+ },
1168
+ async kill() {
1169
+ if (target.kill === void 0) throw new Error(`${target.image} names no way to kill its server`);
1170
+ await output(server, [...target.kill]);
1171
+ },
1172
+ async warm() {
1173
+ await output(admin, [
1174
+ "ping",
1175
+ "-c",
1176
+ "2",
1177
+ "-W",
1178
+ "2",
1179
+ targetIp
1180
+ ]);
1181
+ await output(injector, [
1182
+ "ping",
1183
+ "-c",
1184
+ "2",
1185
+ "-W",
1186
+ "2",
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]
1196
+ ]);
1197
+ },
1198
+ async setup(commands) {
1199
+ for (const argv of commands) await output(server, [...argv]);
1200
+ },
643
1201
  async heal(at, when) {
644
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
645
- const stamp = JSON.parse(await output(where, [
1202
+ const [where, peer] = placeOf(at);
1203
+ return stamped(where, [
646
1204
  "/opt/harness/inject",
647
1205
  "clear",
648
1206
  peer,
649
1207
  "--at",
650
1208
  String(Math.round(when))
651
- ]));
652
- return {
653
- t0: stamp.t0,
654
- t1: stamp.t1
655
- };
1209
+ ]);
656
1210
  },
657
1211
  async forget() {
658
1212
  await output(admin, [
@@ -667,6 +1221,12 @@ const openLab = async (target, kind = "tcp") => {
667
1221
  "flush",
668
1222
  "all"
669
1223
  ]);
1224
+ for (const hop of proxies) await output(hop.injector, [
1225
+ "ip",
1226
+ "tcp_metrics",
1227
+ "flush",
1228
+ "all"
1229
+ ]);
670
1230
  },
671
1231
  freeze: () => paused.pause().then(() => void 0),
672
1232
  thaw: () => paused.unpause().then(() => void 0),
@@ -679,7 +1239,8 @@ const openLab = async (target, kind = "tcp") => {
679
1239
  go: () => output(container, ["touch", "/tmp/go"]).then(() => void 0),
680
1240
  freeze: () => runtime.container.getById(container.getId()).pause().then(() => void 0),
681
1241
  thaw: () => runtime.container.getById(container.getId()).unpause().then(() => void 0),
682
- stop: () => container.stop(STOP).then(() => void 0)
1242
+ kill: () => runtime.container.getById(container.getId()).kill({ signal: "SIGKILL" }).then(() => void 0),
1243
+ stop: () => container.stop(STOP).then(() => void 0).catch(() => void 0)
683
1244
  };
684
1245
  },
685
1246
  async settle() {
@@ -699,6 +1260,34 @@ const openLab = async (target, kind = "tcp") => {
699
1260
  async manifest(sysctls, settings = {}, targetSysctls = {}) {
700
1261
  const config = await output(admin, ["zcat", "/proc/config.gz"]).catch(() => "");
701
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
+ };
702
1291
  return {
703
1292
  kernel: await output(client, ["uname", "-r"]),
704
1293
  architecture: await output(client, ["uname", "-m"]),
@@ -721,11 +1310,12 @@ const openLab = async (target, kind = "tcp") => {
721
1310
  ])).split("\n").sort()
722
1311
  },
723
1312
  client: {
724
- kind,
1313
+ kind: clientKind,
725
1314
  image: clientImage
726
1315
  },
727
1316
  netns,
728
- sysctls
1317
+ sysctls,
1318
+ ...inChain.length === 0 ? {} : { hops: await hopsOf() }
729
1319
  };
730
1320
  },
731
1321
  close
@@ -743,7 +1333,7 @@ const openLab = async (target, kind = "tcp") => {
743
1333
  * again later. Thus the records get a new analysis after a change to observe.ts or to the verdict.
744
1334
  */
745
1335
  const judge = (cell, runs) => {
746
- const measured = runs.map((run) => measure(cell, run));
1336
+ const measured = runs.filter((run) => run.warmup !== true).map((run) => measure(cell, run));
747
1337
  return {
748
1338
  measured,
749
1339
  verdict: verdictOf(cell.prediction, measured)
@@ -757,14 +1347,16 @@ const droppedSoFar = (records) => {
757
1347
  /** This function gives the argv of the client for a cell, with a deadline far after all that it waits for. */
758
1348
  const probeArgs = (cell, lab) => {
759
1349
  const { prediction, probe } = cell;
760
- 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"));
761
1351
  const args = [
762
1352
  lab.targetIp,
763
- String(cell.target.port),
1353
+ String(lab.targetPort),
764
1354
  "--mode",
765
1355
  probe.mode
766
1356
  ];
767
1357
  if (probe.request !== void 0) args.push("--request", probe.request);
1358
+ if (probe.count !== void 0) args.push("--count", String(probe.count));
1359
+ if (probe.interval !== void 0) args.push("--interval-ms", String(valueIn(probe.interval, "ms")));
768
1360
  if (probe.driver !== void 0) {
769
1361
  args.push("--keepalive-ms", String(valueIn(probe.driver.keepAlive, "ms")));
770
1362
  args.push("--max-s", String(Math.ceil(longest * 2 + 10)));
@@ -776,18 +1368,21 @@ const probeArgs = (cell, lab) => {
776
1368
  return args;
777
1369
  };
778
1370
  /**
779
- * This function waits for an error on the socket of the target for the connection of the
780
- * client. The prober records that error in the namespace of the target. A cell on the server side
781
- * waits for that. The promise resolves after `within` ms 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.
782
1374
  */
783
- const serverError = (lab, local, within) => new Promise((resolve) => {
1375
+ const observerError = (cell, partial, within) => new Promise((resolve) => {
784
1376
  const started = Date.now();
785
1377
  const look = () => {
786
- if (lab.kernel.some((record) => record.ev === "error" && (record.err ?? 0) !== 0 && record.netns === lab.netns.target && record.dst === local) || 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();
787
1381
  else setTimeout(look, 50);
788
1382
  };
789
1383
  look();
790
1384
  });
1385
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
791
1386
  /**
792
1387
  * This function waits for the request that an in-flight client sends on the signal: its second
793
1388
  * send, after the PING that made it ready. Its stamp is when the request first met the fault.
@@ -802,20 +1397,28 @@ const requestSent = (probe, within) => new Promise((resolve, reject) => {
802
1397
  };
803
1398
  look();
804
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
+ };
805
1405
  /**
806
1406
  * This function does one run. The client connects and gets its reply, the fault occurs, and an
807
1407
  * in-flight client sends into it. The harness then waits for the observer to see the fault, or for
808
1408
  * the silent window to end. The observer is the client, or the socket of the target for a cell on
809
- * the server side.
1409
+ * the server side. It can also be a socket of a proxy of a topology.
810
1410
  *
811
1411
  * A stall stops by itself, at its length after the send of the request, and the
812
1412
  * run waits for the reply. Then the harness removes the fault, thus the environment can serve the
813
1413
  * next run. The run keeps the records of the prober from its start to its end. The frozen process
814
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.
815
1417
  */
816
- const once = async (lab, cell, environment, index) => {
1418
+ const once = async (lab, cell, environment, index, dry = false) => {
817
1419
  await lab.forget();
818
1420
  const from = lab.kernel.length;
1421
+ const middleboxFrom = lab.middlebox.length;
819
1422
  const lostBefore = droppedSoFar(lab.kernel);
820
1423
  const probe = await lab.probe(probeArgs(cell, lab));
821
1424
  const server = cell.observer === "server";
@@ -830,48 +1433,124 @@ const once = async (lab, cell, environment, index) => {
830
1433
  const last = probe.events.slice(-3).map((event) => JSON.stringify(event));
831
1434
  throw new Error(`${error.message}; the client's last lines: ${last.join(" ")}`);
832
1435
  });
833
- 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;
834
1450
  let heal = null;
835
1451
  try {
836
1452
  await probe.until(["ready"], 15e3);
837
- const replyInFlight = server && cell.probe.mode === "inflight";
838
- if (replyInFlight) {
839
- await go();
840
- await new Promise((resolve) => setTimeout(resolve, 200));
841
- }
842
- const faultAt = server ? "client" : "target";
843
- const network = cell.fault === "stall" ? "blackhole" : cell.fault;
844
- fault = network === "freeze-process" ? null : await lab.fault(network, faultAt);
845
- if (network === "freeze-process") await peer.freeze();
846
- try {
847
- if (cell.probe.mode === "inflight" && !replyInFlight) await go();
848
- const { prediction } = cell;
849
- if (cell.fault === "stall") {
850
- if (cell.stall === void 0) throw new Error(`${cell.name} says no stall's length`);
851
- const sent = await requestSent(probe, 5e3);
852
- heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
853
- const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
854
- await probe.until([
855
- "data",
856
- "error",
857
- "eof"
858
- ], window * 1e3).catch(() => void 0);
859
- } else if (prediction.detects) {
860
- const within = valueIn(prediction.high, "s") * 2e3 + 1e4;
861
- if (server) {
862
- const local = probe.events.find((event) => event.ev === "connected")?.["local"];
863
- await serverError(lab, local, within);
864
- } else await probe.until([
865
- "error",
866
- "eof",
867
- "deadline"
868
- ], within).catch(() => void 0);
869
- } else await new Promise((resolve) => setTimeout(resolve, valueIn(prediction.window, "s") * 1e3));
870
- } finally {
871
- if (network === "freeze-process") await peer.thaw();
872
- else if (heal === null) await lab.lift(network, 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);
1461
+ }
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);
1551
+ }
1552
+ if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
873
1553
  }
874
- if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
875
1554
  } finally {
876
1555
  await probe.stop();
877
1556
  }
@@ -886,28 +1565,52 @@ const once = async (lab, cell, environment, index) => {
886
1565
  kernel: lab.kernel.slice(from),
887
1566
  netns: lab.netns.client,
888
1567
  targetNetns: lab.netns.target,
889
- lost
1568
+ lost,
1569
+ ...placed,
1570
+ ...lab.topology === null ? {} : { middlebox: lab.middlebox.slice(middleboxFrom) }
890
1571
  };
891
1572
  };
892
1573
  /** This function divides a count of runs across environments, as equally as possible. */
893
1574
  const spread = (runs, environments) => Array.from({ length: environments }, (_, at) => Math.floor(runs / environments) + (at < runs % environments ? 1 : 0)).filter((count) => count > 0);
894
- /** 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
+ */
895
1585
  const inEnvironment = async (cell, environment, count, say) => {
896
- 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);
897
1587
  try {
898
1588
  const settings = await lab.configure(cell.targetSettings ?? {});
899
1589
  const targetSysctls = await lab.targetSysctl(cell.targetSysctls ?? {});
1590
+ if (cell.targetSetup !== void 0) await lab.setup(cell.targetSetup);
1591
+ if (cell.fault === "mtu-blackhole" || cell.fault === "netem") {
1592
+ await lab.offload(false, "target");
1593
+ await lab.offload(false, "client");
1594
+ }
1595
+ const latency = netemSpec(cell, void 0);
1596
+ if (latency !== null) await lab.netem(latency, "target");
1597
+ await lab.warm();
900
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)));
901
1604
  const runs = [];
902
1605
  for (let index = 0; index < count; index += 1) {
903
1606
  const run = await once(lab, cell, environment, index);
904
1607
  runs.push(run);
905
- const read = measure(cell, run);
906
- 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)));
907
1609
  }
908
1610
  return {
909
1611
  manifest,
910
- runs
1612
+ runs,
1613
+ warmup
911
1614
  };
912
1615
  } finally {
913
1616
  await lab.close();
@@ -919,11 +1622,14 @@ const inEnvironment = async (cell, environment, count, say) => {
919
1622
  * the cell from 11 runs to 22, and then to 44, each extension in new environments. With a
920
1623
  * concurrency of more than one, that many environments operate at the same time, each with its
921
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.
922
1627
  */
923
1628
  const runCell = async (cell, say = () => {}) => {
924
1629
  const started = (/* @__PURE__ */ new Date()).toISOString();
925
1630
  const manifests = [];
926
1631
  const runs = [];
1632
+ const warmups = [];
927
1633
  const concurrency = Math.max(1, cell.concurrency ?? 1);
928
1634
  let target = cell.runs;
929
1635
  let next = 0;
@@ -938,6 +1644,7 @@ const runCell = async (cell, say = () => {}) => {
938
1644
  for (const each of done) {
939
1645
  manifests.push(each.manifest);
940
1646
  runs.push(...each.runs);
1647
+ warmups.push(each.warmup);
941
1648
  }
942
1649
  }
943
1650
  const { measured, verdict } = judge(cell, runs);
@@ -946,6 +1653,7 @@ const runCell = async (cell, say = () => {}) => {
946
1653
  cell,
947
1654
  manifests,
948
1655
  runs,
1656
+ warmups,
949
1657
  measured,
950
1658
  verdict,
951
1659
  at: {
@@ -957,6 +1665,6 @@ const runCell = async (cell, say = () => {}) => {
957
1665
  }
958
1666
  };
959
1667
  //#endregion
960
- 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 };
961
1669
 
962
1670
  //# sourceMappingURL=index.js.map