@mark1russell7/harness 0.2.0 → 0.4.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,271 @@ 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 the value at quantile p of sorted values, by nearest rank: x_(⌈p·n⌉). */
28
+ const quantileOf = (sorted, p) => sorted[Math.min(sorted.length, Math.max(1, Math.ceil(p * sorted.length))) - 1];
29
+ /** This function gives the spread of a set of times, or null for none. */
30
+ const spreadOf = (times) => {
31
+ if (times.length === 0) return null;
32
+ const sorted = times.map((time) => valueIn(time, "s")).sort((a, b) => a - b);
33
+ return {
34
+ count: sorted.length,
35
+ median: s(middle(sorted)),
36
+ p99: s(quantileOf(sorted, .99)),
37
+ p999: s(quantileOf(sorted, .999)),
38
+ max: s(sorted.at(-1))
39
+ };
40
+ };
41
+ /** This function adds the reordering records of runs: the counts add, and each largest value is the largest of all. */
42
+ const reorderingTotal = (runs) => {
43
+ const each = runs.flatMap((run) => run.reordering === void 0 ? [] : [run.reordering]);
44
+ if (each.length === 0) return void 0;
45
+ const sum = (key) => each.reduce((total, one) => total + one[key], 0);
46
+ const add = (key) => each.some((one) => one[key] !== null) ? each.reduce((total, one) => total + (one[key] ?? 0), 0) : null;
47
+ const top = (key) => each.some((one) => one[key] !== null) ? Math.max(...each.map((one) => one[key] ?? 0)) : null;
48
+ return {
49
+ outOfOrder: sum("outOfOrder"),
50
+ duplicates: sum("duplicates"),
51
+ ooo: add("ooo"),
52
+ sacked: top("sacked"),
53
+ lost: top("lost"),
54
+ dsacks: add("dsacks"),
55
+ fast: sum("fast"),
56
+ degree: top("degree"),
57
+ seen: add("seen")
58
+ };
59
+ };
60
+ /** This function says a reordering record in words. */
61
+ const reorderingWords = (total) => [
62
+ `${total.outOfOrder} arrivals out of order and ${total.duplicates} duplicate arrivals at the client`,
63
+ ...total.ooo === null ? [] : [`its kernel counted ${total.ooo} segments out of order`],
64
+ ...total.sacked === null ? [] : [`the server held up to ${total.sacked} segments SACKed`],
65
+ ...total.dsacks === null ? [] : [`got ${total.dsacks} DSACKs`],
66
+ `sent ${total.fast} fast retransmissions`,
67
+ ...total.seen === null ? [] : [`saw ${total.seen} reorderings`],
68
+ ...total.degree === null ? [] : [`and its reordering degree went to ${total.degree}`]
69
+ ].join(", ");
70
+ /** This function says a spread in words, in milliseconds. */
71
+ const spreadWords = (spread) => {
72
+ if (spread === null) return "no time";
73
+ const ms = (time) => Math.round(valueIn(time, "s") * 1e6) / 1e3;
74
+ return `median ${ms(spread.median)} ms, p99 ${ms(spread.p99)} ms, p99.9 ${ms(spread.p999)} ms, max ${ms(spread.max)} ms of ${spread.count}`;
75
+ };
76
+ /** This function gives P(X ≤ j) for X ~ Binomial(n, 1/2), exactly. */
77
+ const below = (n, j) => {
78
+ let term = 1;
79
+ let sum = 0;
80
+ for (let i = 0; i <= j; i += 1) {
81
+ sum += term;
82
+ term = term * (n - i) / (i + 1);
83
+ }
84
+ return sum / 2 ** n;
85
+ };
86
+ /**
87
+ * This function gives the distribution-free interval of a median: [x_(k), x_(n+1−k)], for the
88
+ * largest k whose two tails each hold 2.5 % or less. For eleven runs, it gives [x_(2), x_(10)],
89
+ * at 98.8 %.
90
+ */
91
+ const medianInterval = (sorted) => {
92
+ const n = sorted.length;
93
+ let k = 0;
94
+ while (k + 1 <= n / 2 && below(n, k) <= .025) k += 1;
95
+ return k === 0 ? [sorted[0], sorted[n - 1]] : [sorted[k - 1], sorted[n - k]];
96
+ };
97
+ const middle = (sorted) => {
98
+ const half = Math.floor(sorted.length / 2);
99
+ return sorted.length % 2 === 1 ? sorted[half] : (sorted[half - 1] + sorted[half]) / 2;
100
+ };
101
+ /**
102
+ * This function gives the verdict of a cell, from two tests:
103
+ * - Containment: each run lands in [T_lo − δ_abs, T_hi + δ_abs]. A run outside is a finding.
104
+ * - Median equivalence: the interval of the median is in the band, made wider by δ_rel. An
105
+ * interval outside the band fails. An interval across its edge extends the cell to 22 runs, then
106
+ * 44, and then the verdict is inconclusive.
107
+ *
108
+ * A silent prediction holds only if no run detected the fault in its window. After a stall, the
109
+ * return of the reply gets the same test as a detection, by its interval, and a detection is a
110
+ * finding. The distribution test (a DKW band) needs a predicted distribution from a simulator,
111
+ * which does not exist at this time.
112
+ */
113
+ const verdictOf = (prediction, runs) => {
114
+ const counted = runs.filter((run) => run.void === null);
115
+ const voided = runs.length - counted.length;
116
+ const outside = counted.filter((run) => run.finding !== null).map((run) => ({
117
+ run: name(run),
118
+ why: run.finding
119
+ }));
120
+ const empty = {
121
+ counted: counted.length,
122
+ voided,
123
+ median: null,
124
+ medianInterval: null
125
+ };
126
+ if (counted.length === 0) return {
127
+ ...empty,
128
+ decision: "inconclusive",
129
+ band: null,
130
+ outside,
131
+ why: "no run counted"
132
+ };
133
+ if (prediction.detects === null) {
134
+ const crossed = counted.flatMap((run) => run.crossings === void 0 ? [] : [run.crossings]);
135
+ if (crossed.length > 0) {
136
+ const hop = crossed[0].hop;
137
+ const up = spreadOf(crossed.flatMap((each) => each.up));
138
+ const down = spreadOf(crossed.flatMap((each) => each.down));
139
+ return {
140
+ ...empty,
141
+ decision: "measured",
142
+ band: null,
143
+ outside: [],
144
+ median: up?.median ?? null,
145
+ proxy: {
146
+ hop,
147
+ up,
148
+ down
149
+ },
150
+ why: `${counted.length} runs measured ${hop}: toward the target ${spreadWords(up)}; toward the client ${spreadWords(down)}. ${prediction.why}`
151
+ };
152
+ }
153
+ if (prediction.from !== void 0) {
154
+ const times = counted.flatMap((run) => run.elapsed === null ? [] : [valueIn(run.elapsed, "s")]).sort((a, b) => a - b);
155
+ const kinds = /* @__PURE__ */ new Map();
156
+ for (const run of counted) {
157
+ const kind = run.outcome.kind === "error" ? run.outcome.errno : run.outcome.kind;
158
+ kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
159
+ }
160
+ const outcomes = [...kinds].map(([kind, n]) => `${n} ${kind}`).join(", ");
161
+ 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`;
162
+ return {
163
+ ...empty,
164
+ decision: "measured",
165
+ band: null,
166
+ outside: [],
167
+ median: times.length === 0 ? null : s(middle(times)),
168
+ why: `${counted.length} runs measured: ${outcomes}; ${timed}. ${prediction.why}`
169
+ };
170
+ }
171
+ const samples = counted.flatMap((run) => (run.samples ?? []).map((sample) => valueIn(sample, "s"))).sort((a, b) => a - b);
172
+ const retransmissions = counted.reduce((sum, run) => sum + run.retransmissions, 0);
173
+ const ended = counted.filter((run) => run.outcome.kind === "error" || run.outcome.kind === "eof");
174
+ const reordering = reorderingTotal(counted);
175
+ const reordered = reordering !== void 0 && [
176
+ reordering.outOfOrder,
177
+ reordering.duplicates,
178
+ reordering.ooo,
179
+ reordering.sacked,
180
+ reordering.dsacks,
181
+ reordering.fast,
182
+ reordering.seen
183
+ ].some((count) => (count ?? 0) > 0) ? ` Reordering: ${reorderingWords(reordering)}.` : "";
184
+ 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, p99 ${s(quantileOf(samples, .99)).value} s, slowest ${s(samples.at(-1)).value} s, ${retransmissions} retransmissions, ${ended.length} connections ended.${reordered} ${prediction.why}`;
185
+ return {
186
+ ...empty,
187
+ decision: "measured",
188
+ band: null,
189
+ outside: [],
190
+ median: samples.length === 0 ? null : s(middle(samples)),
191
+ ...reordering === void 0 ? {} : { reordering },
192
+ why
193
+ };
194
+ }
195
+ if (!prediction.detects && prediction.recovers === void 0 && prediction.replies === void 0) {
196
+ const decision = outside.length === 0 ? "holds" : "fails";
197
+ 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`;
198
+ return {
199
+ ...empty,
200
+ decision,
201
+ band: null,
202
+ outside,
203
+ why
204
+ };
205
+ }
206
+ const timed = prediction.detects ? prediction : prediction.recovers ?? prediction.replies;
207
+ const noise = valueIn(NOISE, "s");
208
+ const low = valueIn(timed.low, "s");
209
+ const high = valueIn(timed.high, "s");
210
+ const band = {
211
+ low: s(low - noise),
212
+ high: s(high + noise)
213
+ };
214
+ const values = [];
215
+ for (const run of counted) {
216
+ if (run.elapsed === null) continue;
217
+ const value = valueIn(run.elapsed, "s");
218
+ values.push(value);
219
+ if (value < low - noise || value > high + noise) outside.push({
220
+ run: name(run),
221
+ why: `${value} s is outside [${band.low.value}, ${band.high.value}] s`
222
+ });
223
+ }
224
+ values.sort((a, b) => a - b);
225
+ if (values.length === 0) return {
226
+ ...empty,
227
+ decision: "fails",
228
+ band,
229
+ outside,
230
+ why: "no run measured its time"
231
+ };
232
+ const [from, to] = medianInterval(values);
233
+ const median = s(middle(values));
234
+ const interval = {
235
+ low: s(from),
236
+ high: s(to)
237
+ };
238
+ const wide = {
239
+ low: low * .95,
240
+ high: high * 1.05
241
+ };
242
+ const inside = from >= wide.low && to <= wide.high;
243
+ const disjoint = to < wide.low || from > wide.high;
244
+ const off = new Set(outside.map((each) => each.run)).size;
245
+ const counts = `${counted.length - off} of ${counted.length} runs inside [${band.low.value}, ${band.high.value}] s`;
246
+ const centre = `median ${median.value} s, its interval [${interval.low.value}, ${interval.high.value}] s`;
247
+ const result = {
248
+ counted: counted.length,
249
+ voided,
250
+ band,
251
+ outside,
252
+ median,
253
+ medianInterval: interval
254
+ };
255
+ if (outside.length > 0 || disjoint) return {
256
+ ...result,
257
+ decision: "fails",
258
+ why: `${counts}; ${centre}`
259
+ };
260
+ if (inside) return {
261
+ ...result,
262
+ decision: "holds",
263
+ why: `${counts}; ${centre}`
264
+ };
265
+ const decision = counted.length < STEPS[2] ? "extend" : "inconclusive";
266
+ return {
267
+ ...result,
268
+ decision,
269
+ why: `${counts}; ${centre} straddles the band`
270
+ };
271
+ };
272
+ //#endregion
8
273
  //#region src/observe.ts
9
274
  /** These are the errno numbers that the kernel reports to a socket, by the names that predictions use. */
10
275
  const ERRNO = {
@@ -24,14 +289,136 @@ const newState = (record) => record.new;
24
289
  const count = (event, field) => event === void 0 ? 0 : Number(event[field] ?? 0);
25
290
  /** This function gives the view of the client on the same interval: TCP_INFO, with a sample each 2 ms. */
26
291
  const socketVantage = (cell, events, ready, request) => {
27
- if (!cell.prediction.detects || cell.prediction.from === "peer-closed") return null;
292
+ if (!cell.prediction.detects || cell.prediction.from === "peer-closed" || cell.prediction.from === "fault") return null;
28
293
  const ended = events.find((event) => event.ev === "error" || event.ev === "eof");
29
294
  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
295
  return ended === void 0 || reference === void 0 ? null : between(reference.t, ended.t);
31
296
  };
297
+ const ipOf = (address) => address.split(":")[0];
298
+ /**
299
+ * This function finds the socket that became ESTABLISHED in a run, in a namespace, whose peer
300
+ * matches. In a topology, an address of a hop serves each run, thus only the socket that the
301
+ * run opened is the socket of the run.
302
+ */
303
+ const opened = (run, netns, peer) => {
304
+ const record = run.kernel.find((each) => each.netns === netns && each.ev === "state" && newState(each) === 1 && each.dst !== void 0 && peer(each.dst));
305
+ return record?.cookie === void 0 ? void 0 : {
306
+ netns,
307
+ cookie: record.cookie
308
+ };
309
+ };
310
+ /** This function finds the socket of the target for the connection of a run. In a topology, its peer is the last hop. */
311
+ const targetSocket = (run, local) => {
312
+ if (run.topology === void 0) {
313
+ const cookie = run.kernel.find((record) => record.netns === run.targetNetns && record.dst === local)?.cookie;
314
+ return cookie === void 0 ? void 0 : {
315
+ netns: run.targetNetns,
316
+ cookie
317
+ };
318
+ }
319
+ const peer = run.topology.targetPeer;
320
+ return opened(run, run.targetNetns, (dst) => ipOf(dst) === peer);
321
+ };
322
+ /**
323
+ * This function finds the socket of the observer of a cell in a run:
324
+ * - the client socket, by the address that it connected from, in its namespace.
325
+ * - the socket of the target for the same connection, in its namespace.
326
+ * - a socket of a proxy of the topology, toward the client or toward the target.
327
+ */
328
+ const socketOf = (cell, run) => {
329
+ const local = run.events.find((event) => event.ev === "connected")?.["local"];
330
+ const observer = cell.observer ?? "client";
331
+ if (observer === "client") {
332
+ const cookie = run.kernel.find((record) => record.netns === run.netns && record.src === local)?.cookie;
333
+ return cookie === void 0 ? void 0 : {
334
+ netns: run.netns,
335
+ cookie
336
+ };
337
+ }
338
+ if (observer === "server") return targetSocket(run, local);
339
+ const hop = run.topology?.hops[observer.hop];
340
+ if (hop === void 0) return void 0;
341
+ return observer.toward === "client" ? opened(run, hop.netns, (dst) => ipOf(dst) === hop.client) : opened(run, hop.netns, (dst) => dst === hop.target);
342
+ };
343
+ /**
344
+ * This function finds the socket of the peer of the observer: its close is the reference of a FIN.
345
+ * In a direct pair, it is the other end of the connection. In a topology, it is the socket of the
346
+ * target, for the client.
347
+ */
348
+ const peerOf = (cell, run) => {
349
+ const local = run.events.find((event) => event.ev === "connected")?.["local"];
350
+ const server = cell.observer === "server";
351
+ if (run.topology !== void 0) return server ? void 0 : targetSocket(run, local);
352
+ const netns = server ? run.netns : run.targetNetns;
353
+ const cookie = run.kernel.find((record) => record.netns === netns && (server ? record.src : record.dst) === local)?.cookie;
354
+ return cookie === void 0 ? void 0 : {
355
+ netns,
356
+ cookie
357
+ };
358
+ };
359
+ /** This function tells if sequence number a is after b, in the 32-bit space of TCP (`after` in include/net/tcp.h). */
360
+ const after32 = (a, b) => (a - b | 0) > 0;
361
+ /** This function gives the largest value of a count of the kernel in records, or null when no record has it. */
362
+ const most = (records, count) => records.reduce((top, record) => {
363
+ const value = count(record);
364
+ return value === void 0 ? top : Math.max(top ?? 0, value);
365
+ }, null);
366
+ /** This function gives what reordering did in a run, from the records of the receiver and of the sender. */
367
+ const reorderingOf = (receiver, sender) => {
368
+ const arrivals = receiver.filter((record) => record.ev === "rx" && (record.seg?.len.value ?? 0) > 0 && record.tcp?.rcv_nxt !== void 0);
369
+ return {
370
+ outOfOrder: arrivals.filter((record) => after32(record.seg.seq, record.tcp.rcv_nxt)).length,
371
+ duplicates: arrivals.filter((record) => !after32(record.seg.seq + record.seg.len.value, record.tcp.rcv_nxt)).length,
372
+ ooo: most(receiver, (record) => record.tcp?.rcv_ooopack),
373
+ sacked: most(sender, (record) => record.tcp?.sacked),
374
+ lost: most(sender, (record) => record.tcp?.lost),
375
+ dsacks: most(sender, (record) => record.tcp?.dsack_dups),
376
+ fast: sender.filter((record) => record.ev === "retrans" && record.why === "fast").length,
377
+ degree: most(sender, (record) => record.tcp?.reordering),
378
+ seen: most(sender, (record) => record.tcp?.reord_seen)
379
+ };
380
+ };
381
+ /**
382
+ * This function gives the times of a proxy in a run, for each request after the first. The time
383
+ * of a request starts at its arrival on the socket of the proxy toward the client. It stops at the
384
+ * next write on the socket toward the target. The time of its reply starts at its arrival on the
385
+ * socket toward the target, and it stops at the next write toward the client. A request without
386
+ * each record gives no time.
387
+ */
388
+ const crossingsOf = (run, hop, toClient, toTarget) => {
389
+ const up = [];
390
+ const down = [];
391
+ if (toClient === void 0 || toTarget === void 0) return {
392
+ hop,
393
+ up,
394
+ down
395
+ };
396
+ const of = (place) => run.kernel.filter((record) => record.netns === place.netns && record.cookie === place.cookie);
397
+ const near = of(toClient);
398
+ const far = of(toTarget);
399
+ const arrival = (records, from, to) => records.find((record) => record.ev === "rx" && (record.seg?.len.value ?? 0) > 0 && record.mono > from && record.mono < to);
400
+ const write = (records, from, to) => records.find((record) => record.ev === "tx" && record.mono >= from && record.mono < to);
401
+ const sends = run.events.filter((event) => event.ev === "sent").slice(1);
402
+ sends.forEach((send, n) => {
403
+ const next = sends[n + 1]?.t ?? Infinity;
404
+ const request = arrival(near, send.t, next);
405
+ const relayed = request === void 0 ? void 0 : write(far, request.mono, next);
406
+ if (request === void 0 || relayed === void 0) return;
407
+ up.push(between(request.mono, relayed.mono));
408
+ const reply = arrival(far, relayed.mono, next);
409
+ const answered = reply === void 0 ? void 0 : write(near, reply.mono, next);
410
+ if (reply !== void 0 && answered !== void 0) down.push(between(reply.mono, answered.mono));
411
+ });
412
+ return {
413
+ hop,
414
+ up,
415
+ down
416
+ };
417
+ };
32
418
  /**
33
419
  * 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.
420
+ * - The socket of the run is the socket of its observer. It is the socket that the client connected
421
+ * from, in its namespace, or the socket of the target or of a proxy for that connection.
35
422
  * - The fault must hold before the event that it must meet. That is before the request goes into
36
423
  * it, and before the first keepalive probe. A live peer answers that probe, and the answer
37
424
  * starts the idle time again. A run where the fault was late is void. A run whose records the
@@ -44,11 +431,16 @@ const measure = (cell, run) => {
44
431
  const inflight = cell.probe.mode !== "idle";
45
432
  const request = inflight ? events.filter((event) => event.ev === "sent")[1] : ready;
46
433
  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;
434
+ const place = socketOf(cell, run);
435
+ const netns = place?.netns;
436
+ const cookie = place?.cookie;
49
437
  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;
438
+ const peerPlace = peerOf(cell, run);
439
+ const peerNetns = peerPlace?.netns;
440
+ const peerCookie = peerPlace?.cookie;
441
+ const after = request === void 0 ? [] : events.filter((event) => event.ev === "data" && event.t > request.t);
442
+ const bytes = after.reduce((sum, event) => sum + Number(event["n"] ?? 0), 0);
443
+ const forgotten = (run.middlebox ?? []).find((line) => line.ev === "ct" && line["type"] === "DESTROY" && typeof line["orig"] === "string" && line["orig"].startsWith(`${String(local)}>`));
52
444
  const keepalives = socket.filter((record) => record.ev === "probe" && record.what === "keepalive");
53
445
  const retransmitted = socket.filter((record) => record.ev === "retrans");
54
446
  /**
@@ -67,12 +459,15 @@ const measure = (cell, run) => {
67
459
  return firings.find((record) => timer === "keepalive" && record.owned === true) === void 0 ? null : "its keepalive timer found the socket held by its owner and put itself off by HZ/20: the run measured the owner, not the timer";
68
460
  };
69
461
  const acks = request === void 0 ? 0 : socket.filter((record) => record.ev === "rx" && record.mono > request.t).length;
462
+ const srtt = socket.find((record) => record.tcp?.srtt !== void 0)?.tcp?.srtt;
463
+ const rtt = srtt === void 0 ? null : quantity(valueIn(srtt, "us") / 1e3, "ms");
70
464
  const base = {
71
465
  environment: run.environment,
72
466
  index: run.index,
73
467
  probes: keepalives.length,
74
468
  retransmissions: retransmitted.length,
75
- acks
469
+ acks,
470
+ rtt
76
471
  };
77
472
  const nothing = {
78
473
  outcome: { kind: "silent" },
@@ -116,8 +511,11 @@ const measure = (cell, run) => {
116
511
  else if (replyFirst && replied) voided = "the server's reply arrived before the fault held";
117
512
  else if (keepalives[0] !== void 0 && keepalives[0].mono < run.fault.t1) voided = "a keepalive probe went out before the fault held";
118
513
  }
514
+ const assumed = cell.prediction.detects === null ? void 0 : cell.prediction.rtt;
515
+ if (voided === null && assumed !== void 0 && rtt !== null && valueIn(rtt, "ms") > valueIn(assumed.high, "ms")) voided = `rtt-over-assumption: its first round trip was ${rtt.value} ms, above the ${valueIn(assumed.high, "ms")} ms that the prediction assumes`;
516
+ const remote = cell.observer !== void 0 && cell.observer !== "client";
119
517
  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");
518
+ const seen = remote ? void 0 : events.find((event) => event.ev === "error" || event.ev === "eof");
121
519
  const closeWait = socket.find((record) => record.ev === "state" && newState(record) === 8);
122
520
  const outcome = reported !== void 0 ? {
123
521
  kind: "error",
@@ -129,19 +527,53 @@ const measure = (cell, run) => {
129
527
  const ended = reported?.mono ?? closeWait?.mono ?? seen?.t;
130
528
  const noticed = reported !== void 0 && seen !== void 0 ? between(reported.mono, seen.t) : null;
131
529
  const { prediction } = cell;
530
+ const before = run.fault?.t1 ?? ready.t;
531
+ const referenceAt = (from) => {
532
+ if (from === "request-sent") return request.t;
533
+ if (from === "fault") return run.fault?.t0;
534
+ 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;
535
+ };
536
+ const lastRx = kernel.filter((each) => each.netns === run.netns && each.src === local && each.ev === "rx" && each.mono <= before).at(-1)?.mono ?? ready.t;
537
+ const chain = run.topology === void 0 ? {} : {
538
+ bytes,
539
+ forgot: forgotten === void 0 ? null : between(lastRx, forgotten.t)
540
+ };
132
541
  if (prediction.detects === null) {
133
542
  const sends = events.filter((event) => event.ev === "sent").slice(1);
543
+ const answers = events.some((event) => event.ev === "replied") ? "replied" : "data";
134
544
  const samples = sends.flatMap((send, n) => {
135
545
  const next = sends[n + 1]?.t ?? Infinity;
136
- const reply = events.find((event) => event.ev === "data" && event.t > send.t && event.t < next);
546
+ const reply = events.find((event) => event.ev === answers && event.t > send.t && event.t < next);
137
547
  return reply === void 0 ? [] : [between(send.t, reply.t)];
138
548
  });
549
+ const from = prediction.from === void 0 ? void 0 : referenceAt(prediction.from);
550
+ const until = ended ?? after.at(-1)?.t;
551
+ const peerRecords = peerCookie === void 0 ? [] : kernel.filter((record) => record.netns === peerNetns && record.cookie === peerCookie);
552
+ const proxy = cell.calibrates === void 0 ? void 0 : run.topology?.hops[cell.calibrates];
553
+ const crossings = cell.calibrates === void 0 ? {} : { crossings: crossingsOf(run, cell.calibrates, proxy === void 0 ? void 0 : socketOf({
554
+ ...cell,
555
+ observer: {
556
+ hop: cell.calibrates,
557
+ toward: "client"
558
+ }
559
+ }, run), proxy === void 0 ? void 0 : socketOf({
560
+ ...cell,
561
+ observer: {
562
+ hop: cell.calibrates,
563
+ toward: "target"
564
+ }
565
+ }, run)) };
139
566
  return {
140
567
  ...base,
141
568
  ...nothing,
569
+ ...chain,
142
570
  outcome: ended !== void 0 ? outcome : { kind: "answered" },
571
+ ...from === void 0 || until === void 0 ? {} : { elapsed: between(from, until) },
572
+ noticed: prediction.from === void 0 ? null : noticed,
143
573
  steps: retransmitted.filter((record) => record.mono > request.t).map((record) => between(request.t, record.mono)),
144
574
  samples,
575
+ reordering: reorderingOf(socket.filter((record) => record.mono > request.t), peerRecords.filter((record) => record.mono > request.t)),
576
+ ...crossings,
145
577
  void: voided
146
578
  };
147
579
  }
@@ -184,16 +616,54 @@ const measure = (cell, run) => {
184
616
  finding: null
185
617
  };
186
618
  }
187
- if (!prediction.detects) {
619
+ if (!prediction.detects && prediction.replies !== void 0) {
188
620
  const window = valueIn(prediction.window, "s") * 1e9;
189
- if (ended === void 0 || ended - request.t > window) return {
621
+ if (ended !== void 0 && ended - request.t <= window) return {
190
622
  ...base,
191
623
  ...nothing,
192
- void: voided
624
+ ...chain,
625
+ outcome,
626
+ elapsed: between(request.t, ended),
627
+ void: voided,
628
+ finding: `detected after ${between(request.t, ended).value} s, where the reply should come`
629
+ };
630
+ const answer = socket.find((record) => record.ev === "rx" && record.mono > request.t && (record.seg?.len.value ?? 0) > 0);
631
+ const reply = after[0];
632
+ if (answer === void 0) return {
633
+ ...base,
634
+ ...nothing,
635
+ ...chain,
636
+ void: voided,
637
+ finding: "the reply never came"
638
+ };
639
+ return {
640
+ ...base,
641
+ ...chain,
642
+ outcome: { kind: "answered" },
643
+ elapsed: between(request.t, answer.mono),
644
+ socket: reply === void 0 ? null : between(request.t, reply.t),
645
+ noticed: reply === void 0 ? null : between(answer.mono, reply.t),
646
+ steps: [],
647
+ void: voided,
648
+ finding: null
193
649
  };
650
+ }
651
+ if (!prediction.detects) {
652
+ const window = valueIn(prediction.window, "s") * 1e9;
653
+ if (ended === void 0 || ended - request.t > window) {
654
+ 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;
655
+ return {
656
+ ...base,
657
+ ...nothing,
658
+ ...chain,
659
+ void: voided,
660
+ finding: forgot
661
+ };
662
+ }
194
663
  return {
195
664
  ...base,
196
665
  ...nothing,
666
+ ...chain,
197
667
  outcome,
198
668
  elapsed: between(request.t, ended),
199
669
  void: voided,
@@ -203,15 +673,15 @@ const measure = (cell, run) => {
203
673
  if (ended === void 0) return {
204
674
  ...base,
205
675
  ...nothing,
676
+ ...chain,
206
677
  void: voided,
207
678
  finding: `nothing detected it: ${prediction.timer} never fired`
208
679
  };
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;
680
+ const reference = referenceAt(prediction.from);
212
681
  if (reference === void 0) return {
213
682
  ...base,
214
683
  ...nothing,
684
+ ...chain,
215
685
  outcome,
216
686
  noticed,
217
687
  void: voided,
@@ -219,12 +689,18 @@ const measure = (cell, run) => {
219
689
  };
220
690
  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
691
  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");
692
+ if (prediction.from === "last-received" || prediction.from === "first-retransmission") voided ??= delayed(reference, ended, prediction.from === "last-received" ? "keepalive" : "retransmit");
693
+ const lag = (valueIn(prediction.high, "s") - valueIn(prediction.low, "s")) * 1e9;
694
+ 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;
695
+ 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`;
696
+ const commit = run.fault === null ? 0 : (run.fault.t1 - run.fault.t0) / 1e6;
697
+ 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
698
  return {
224
699
  ...base,
700
+ ...chain,
225
701
  outcome,
226
702
  elapsed: between(reference, ended),
227
- socket: server ? null : socketVantage(cell, events, ready, request),
703
+ socket: remote ? null : socketVantage(cell, events, ready, request),
228
704
  noticed,
229
705
  steps,
230
706
  void: voided,
@@ -232,173 +708,6 @@ const measure = (cell, run) => {
232
708
  };
233
709
  };
234
710
  //#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
711
  //#region src/lab.ts
403
712
  /**
404
713
  * These are the roots of the two packages that the images read: this package, and the prober
@@ -437,6 +746,12 @@ const NODE = {
437
746
  dockerfile: "harness/image/node/Dockerfile",
438
747
  from: ["harness/image/node"]
439
748
  };
749
+ /** This image is the router of a middlebox chain: NAT and conntrack, with a form for a forgotten flow. */
750
+ const ROUTER = {
751
+ name: "loom-harness-router",
752
+ dockerfile: "harness/image/router/Dockerfile",
753
+ from: ["harness/image/router"]
754
+ };
440
755
  /** This function gives each file under a path, in a stable sequence, but not the output of a local build. */
441
756
  const sources = (path) => {
442
757
  if (statSync(path).isFile()) return [path];
@@ -495,6 +810,26 @@ const build = (image) => {
495
810
  const harnessImage = () => build(HARNESS);
496
811
  /** This function gives the image of the Node client. */
497
812
  const nodeClientImage = () => build(NODE);
813
+ /** This function gives the image of the router. */
814
+ const routerImage = () => build(ROUTER);
815
+ /**
816
+ * This time is the interval of the listing of a router, when its hop does not set one. A listing
817
+ * evicts each expired flow that it walks past, and the DESTROY event of that flow follows.
818
+ */
819
+ const PUMP = {
820
+ value: .1,
821
+ unit: "s"
822
+ };
823
+ /**
824
+ * This time is the longest listing of the conntrack table of a router that a prediction assumes.
825
+ * A DESTROY follows an expiry within the interval of the pump and two listings. On 2026-10-15,
826
+ * the slowest of 500 listings in an idle router took 19.4 ms, and the median took 5.5 ms. A
827
+ * longer listing writes a `pump-slow` line, and a run with one near its detection is void.
828
+ */
829
+ const LISTING = {
830
+ value: 20,
831
+ unit: "ms"
832
+ };
498
833
  /** This function gives the arguments of the injector for the options of a fault. */
499
834
  const optionArgs = (options) => [
500
835
  ...options.direction === void 0 ? [] : ["--direction", options.direction],
@@ -508,7 +843,8 @@ const LIFT = {
508
843
  reject: "clear-reject",
509
844
  mtu: "clear-mtu"
510
845
  };
511
- const lines = () => {
846
+ /** This function collects the JSON lines of a program. It gives each line to `also` too, when the caller gives it. */
847
+ const lines = (also) => {
512
848
  const seen = [];
513
849
  const other = [];
514
850
  const waiting = [];
@@ -520,6 +856,7 @@ const lines = () => {
520
856
  }
521
857
  const line = JSON.parse(text);
522
858
  seen.push(line);
859
+ also?.(line);
523
860
  for (const wait of waiting.splice(0)) if (wait.kinds.includes(line.ev)) wait.resolve(line);
524
861
  else waiting.push(wait);
525
862
  };
@@ -551,6 +888,42 @@ const lines = () => {
551
888
  }
552
889
  };
553
890
  };
891
+ /** This container class can share the process namespace of another container. */
892
+ const shared = (Base) => class extends Base {
893
+ withPidMode(mode) {
894
+ this.hostConfig.PidMode = mode;
895
+ return this;
896
+ }
897
+ };
898
+ /** This function gives the content of a file of a proxy, with the address of the next hop for each {{next}}. */
899
+ const templated = (content, next) => content.replaceAll("{{next}}", next);
900
+ /** This function gives the JSON body of a toxic for the API of Toxiproxy. Each toxic of a cell has the name `fault`. */
901
+ const toxicBody = (toxic) => JSON.stringify({
902
+ name: "fault",
903
+ type: toxic.type,
904
+ stream: toxic.stream,
905
+ toxicity: 1,
906
+ attributes: toxic.attributes
907
+ });
908
+ /**
909
+ * This function gives the routes of a chain: for each router, the networks after it, which the
910
+ * element before it reaches through it. Element 0 is the client, and element k is hop k - 1. Network
911
+ * j is the network between element j and element j + 1.
912
+ */
913
+ const routesOf = (hops) => hops.flatMap((hop, at) => hop.kind === "router" ? [{
914
+ from: at,
915
+ via: at + 1,
916
+ networks: Array.from({ length: hops.length - at }, (_, k) => at + 1 + k)
917
+ }] : []);
918
+ /**
919
+ * This function gives the endpoint that an element connects to: the first element after it that
920
+ * is not a router. Element 0 is the client, element k is hop k - 1, and the last element is the target.
921
+ */
922
+ const endpointAfter = (hops, element) => {
923
+ let at = element + 1;
924
+ while (at <= hops.length && hops[at - 1].kind === "router") at += 1;
925
+ return at;
926
+ };
554
927
  const STOP = { timeout: 0 };
555
928
  const output = async (container, argv) => {
556
929
  const result = await container.exec(argv);
@@ -564,63 +937,323 @@ const netnsOf = async (container) => {
564
937
  if (inode === void 0) throw new Error(`not a network namespace: ${link}`);
565
938
  return Number(inode);
566
939
  };
940
+ /** This function gives the image and the command of the client of each run, and its kind for the manifest. */
941
+ const clientOf = async (kind, image) => {
942
+ if (typeof kind === "object") return {
943
+ clientImage: kind.image,
944
+ command: kind.command,
945
+ clientKind: "image"
946
+ };
947
+ if (kind === "ioredis") return {
948
+ clientImage: await nodeClientImage(),
949
+ command: ["node", "/opt/harness/node/client-ioredis.mjs"],
950
+ clientKind: kind
951
+ };
952
+ return {
953
+ clientImage: image,
954
+ command: ["/opt/harness/client"],
955
+ clientKind: kind
956
+ };
957
+ };
958
+ /** This function gives the device of a route from its line in `ip -o route get`: "10.0.0.2 dev eth0 src …". */
959
+ const deviceOf = (route) => {
960
+ const device = /\bdev (\S+)/.exec(route)?.[1];
961
+ if (device === void 0) throw new Error(`no device in the route: ${route}`);
962
+ return device;
963
+ };
567
964
  /**
568
965
  * This function opens one environment:
569
- * - a new network and the stock target.
966
+ * - a chain of new networks, and the stock target on the last one. A direct pair has one network.
570
967
  * - 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.
968
+ * sysctls and its routes. Docker Desktop keeps /proc/sys read-only in a container that operates
969
+ * without privileges.
970
+ * - each hop of the topology, on the network of each of its sides. A hop is a router, or a stock
971
+ * proxy with an injector sidecar in its namespace.
572
972
  * - 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.
973
+ * - the prober, a privileged container that loads the kernel program and watches the namespace of
974
+ * the client, of the target and of each hop.
575
975
  *
576
976
  * 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.
977
+ * client of the harness, a driver from its own image (ioredis on Node), or an image that the
978
+ * caller gives. Each prints the same lines.
979
+ *
980
+ * The client reaches the networks after a router through that router, by a route that its
981
+ * privileged sidecar adds. A router translates the address of the side of the client, thus the
982
+ * target and each later hop see the router as the peer.
579
983
  */
580
- const openLab = async (target, kind = "tcp") => {
984
+ const openLab = async (target, kind = "tcp", topology) => {
581
985
  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"];
986
+ const { clientImage, command, clientKind } = await clientOf(kind, image);
987
+ const hops = topology?.hops ?? [];
584
988
  const { GenericContainer, Network, Wait, getContainerRuntimeClient } = await containers();
989
+ const runtime = await getContainerRuntimeClient();
585
990
  const opened = [];
586
- let network;
991
+ const networks = [];
587
992
  const close = async () => {
588
993
  for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
589
- await network?.stop().catch(() => void 0);
994
+ for (const network of networks.reverse()) await network.stop().catch(() => void 0);
995
+ };
996
+ /** 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. */
997
+ const ipOn = async (container, network) => {
998
+ const ip = (await runtime.container.inspect(runtime.container.getById(container.getId()))).NetworkSettings.Networks[network.getName()]?.IPAddress;
999
+ if (ip === void 0 || ip === "") throw new Error(`${container.getName()} has no address on ${network.getName()}`);
1000
+ return ip;
1001
+ };
1002
+ const join = (container, network) => runtime.container.connectToNetwork(runtime.container.getById(container.getId()), runtime.network.getById(network.getId()), []);
1003
+ const subnetOf = async (network) => {
1004
+ const subnet = (await runtime.network.getById(network.getId()).inspect()).IPAM?.Config?.[0]?.Subnet;
1005
+ if (subnet === void 0) throw new Error(`${network.getName()} has no subnet`);
1006
+ return subnet;
590
1007
  };
591
1008
  try {
592
- network = await new Network().start();
593
- const stock = new GenericContainer(target.image).withNetwork(network).withWaitStrategy(Wait.forLogMessage(target.ready));
1009
+ for (let at = 0; at <= hops.length; at += 1) networks.push(await new Network().start());
1010
+ const last = networks[hops.length];
1011
+ const stock = new GenericContainer(target.image).withNetwork(last).withWaitStrategy(Wait.forLogMessage(target.ready));
594
1012
  const server = await (target.command === void 0 ? stock : stock.withCommand([...target.command])).start();
595
1013
  opened.push(server);
596
- const client = await new GenericContainer(image).withNetwork(network).start();
1014
+ const serverIp = await ipOn(server, last);
1015
+ const middlebox = [];
1016
+ const inChain = [];
1017
+ const endpointOf = (element) => {
1018
+ const at = endpointAfter(hops, element);
1019
+ if (at === hops.length + 1) return `${serverIp}:${target.port}`;
1020
+ const proxy = inChain[at - 1];
1021
+ return `${proxy.ip.client}:${proxy.hop.port}`;
1022
+ };
1023
+ for (let at = hops.length - 1; at >= 0; at -= 1) {
1024
+ const hop = hops[at];
1025
+ const [clientSide, targetSide] = [networks[at], networks[at + 1]];
1026
+ if (hop.kind === "router") {
1027
+ const seen = lines((line) => middlebox.push({
1028
+ ...line,
1029
+ hop: hop.name
1030
+ }));
1031
+ const box = await new GenericContainer(await routerImage()).withNetwork(clientSide).withPrivilegedMode().withEnvironment({
1032
+ FORM: hop.form,
1033
+ IDLE: String(valueIn(hop.idle, "s")),
1034
+ PUMP: String(valueIn(hop.pump ?? PUMP, "s")),
1035
+ SLOW: String(valueIn(LISTING, "s"))
1036
+ }).withLogConsumer(seen.consume).start();
1037
+ opened.push(box);
1038
+ await join(box, targetSide);
1039
+ const ready = await seen.until(["mbx-ready"], 3e4, `router ${hop.name}`);
1040
+ await seen.until(hop.form === "reset-both" ? ["resetter"] : ["ct-watch"], 3e4, `router ${hop.name}`);
1041
+ const injector = await new GenericContainer(image).withNetworkMode(`container:${box.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
1042
+ opened.push(injector);
1043
+ inChain[at] = {
1044
+ hop,
1045
+ box,
1046
+ injector,
1047
+ ip: {
1048
+ client: await ipOn(box, clientSide),
1049
+ target: await ipOn(box, targetSide)
1050
+ },
1051
+ peer: {
1052
+ client: "",
1053
+ target: endpointOf(at + 1)
1054
+ },
1055
+ devices: {
1056
+ client: "",
1057
+ target: ""
1058
+ },
1059
+ netns: await netnsOf(box),
1060
+ sysctls: { "net.netfilter.nf_conntrack_tcp_timeout_established": String(ready["idle"]) }
1061
+ };
1062
+ continue;
1063
+ }
1064
+ const next = endpointOf(at + 1);
1065
+ let proxy = new GenericContainer(hop.image).withNetwork(targetSide);
1066
+ if (hop.command !== void 0) proxy = proxy.withCommand([...hop.command]);
1067
+ if (hop.files !== void 0) proxy = proxy.withCopyContentToContainer(Object.entries(hop.files).map(([path, content]) => ({
1068
+ content: templated(content, next),
1069
+ target: path
1070
+ })));
1071
+ if (hop.ready !== void 0) proxy = proxy.withWaitStrategy(Wait.forLogMessage(hop.ready));
1072
+ const box = await proxy.start();
1073
+ opened.push(box);
1074
+ await join(box, clientSide);
1075
+ const injector = await new GenericContainer(image).withNetworkMode(`container:${box.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
1076
+ opened.push(injector);
1077
+ inChain[at] = {
1078
+ hop,
1079
+ box,
1080
+ injector,
1081
+ ip: {
1082
+ client: await ipOn(box, clientSide),
1083
+ target: await ipOn(box, targetSide)
1084
+ },
1085
+ peer: {
1086
+ client: "",
1087
+ target: next
1088
+ },
1089
+ devices: {
1090
+ client: "",
1091
+ target: ""
1092
+ },
1093
+ netns: await netnsOf(injector),
1094
+ sysctls: {}
1095
+ };
1096
+ }
1097
+ const client = await new GenericContainer(image).withNetwork(networks[0]).start();
597
1098
  opened.push(client);
598
1099
  const admin = await new GenericContainer(image).withNetworkMode(`container:${client.getId()}`).withPrivilegedMode().start();
599
1100
  opened.push(admin);
600
1101
  const injector = await new GenericContainer(image).withNetworkMode(`container:${server.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
601
1102
  opened.push(injector);
1103
+ const clientIp = await ipOn(client, networks[0]);
1104
+ for (let at = 0; at < inChain.length; at += 1) {
1105
+ const before = at === 0 ? clientIp : inChain[at - 1].ip.target;
1106
+ inChain[at] = {
1107
+ ...inChain[at],
1108
+ peer: {
1109
+ ...inChain[at].peer,
1110
+ client: before
1111
+ }
1112
+ };
1113
+ }
1114
+ const admins = /* @__PURE__ */ new Map();
1115
+ const adminOf = async (element) => {
1116
+ if (element === 0) return admin;
1117
+ const hop = inChain[element - 1];
1118
+ if (hop.hop.kind === "router") return hop.box;
1119
+ let found = admins.get(element);
1120
+ if (found === void 0) {
1121
+ found = await new GenericContainer(image).withNetworkMode(`container:${hop.box.getId()}`).withPrivilegedMode().start();
1122
+ opened.push(found);
1123
+ admins.set(element, found);
1124
+ }
1125
+ return found;
1126
+ };
1127
+ for (const route of routesOf(hops)) {
1128
+ const where = await adminOf(route.from);
1129
+ const via = inChain[route.via - 1].ip.client;
1130
+ for (const network of route.networks) await output(where, [
1131
+ "ip",
1132
+ "route",
1133
+ "replace",
1134
+ await subnetOf(networks[network]),
1135
+ "via",
1136
+ via
1137
+ ]);
1138
+ }
1139
+ for (let at = 0; at < inChain.length; at += 1) {
1140
+ const hop = inChain[at];
1141
+ const toward = async (address) => deviceOf(await output(hop.injector, [
1142
+ "ip",
1143
+ "-o",
1144
+ "route",
1145
+ "get",
1146
+ address.split(":")[0]
1147
+ ]));
1148
+ inChain[at] = {
1149
+ ...hop,
1150
+ devices: {
1151
+ client: await toward(hop.peer.client),
1152
+ target: await toward(hop.peer.target)
1153
+ }
1154
+ };
1155
+ if (hop.hop.sysctls === void 0) continue;
1156
+ const where = await adminOf(at + 1);
1157
+ const settings = Object.entries(hop.hop.sysctls).map(([key, value]) => `${key}=${value}`);
1158
+ await output(where, [
1159
+ "sysctl",
1160
+ "-q",
1161
+ "-w",
1162
+ ...settings
1163
+ ]);
1164
+ for (const key of Object.keys(hop.hop.sysctls)) hop.sysctls[key] = await output(where, [
1165
+ "sysctl",
1166
+ "-n",
1167
+ key
1168
+ ]);
1169
+ }
602
1170
  const netns = {
603
1171
  client: await netnsOf(admin),
604
- target: await netnsOf(injector)
1172
+ target: await netnsOf(injector),
1173
+ ...inChain.length === 0 ? {} : { hops: Object.fromEntries(inChain.map((hop) => [hop.hop.name, hop.netns])) }
605
1174
  };
606
1175
  const records = lines();
607
1176
  const prober = await new GenericContainer(image).withPrivilegedMode().withNetworkMode("none").withCommand([
608
1177
  "/opt/harness/prober",
609
1178
  String(netns.client),
610
- String(netns.target)
1179
+ String(netns.target),
1180
+ ...inChain.map((hop) => String(hop.netns))
611
1181
  ]).withLogConsumer(records.consume).start();
612
1182
  opened.push(prober);
613
1183
  const begun = await records.until(["begin", "fail"], 3e4, "prober");
614
1184
  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();
1185
+ const first = endpointAfter(hops, 0);
1186
+ const targetIp = first === hops.length + 1 ? serverIp : inChain[first - 1].ip.client;
1187
+ const targetPort = first === hops.length + 1 ? target.port : inChain[first - 1].hop.port;
1188
+ const targetPeer = inChain.length === 0 ? clientIp : inChain.at(-1).ip.target;
1189
+ const placed = inChain.length === 0 ? null : {
1190
+ targetPeer,
1191
+ hops: Object.fromEntries(inChain.map((hop) => [hop.hop.name, {
1192
+ netns: hop.netns,
1193
+ client: hop.peer.client,
1194
+ target: hop.peer.target
1195
+ }]))
1196
+ };
618
1197
  const paused = runtime.container.getById(server.getId());
619
1198
  let targetAdmin;
1199
+ const hopNamed = (name) => {
1200
+ const found = inChain.find((hop) => hop.hop.name === name);
1201
+ if (found === void 0) throw new Error(`the topology has no hop ${name}`);
1202
+ return found;
1203
+ };
1204
+ /**
1205
+ * This function gives the namespace and the peer of the rules of a fault at a place, and the
1206
+ * other arguments of the injector. At a proxy, the rules are INPUT and OUTPUT rules, which act
1207
+ * on its sockets. A router forwards past INPUT and OUTPUT, thus its rules are FORWARD rules on
1208
+ * its device toward the side (`--via`).
1209
+ */
1210
+ const placeOf = (at) => {
1211
+ if (at === "client") return [
1212
+ admin,
1213
+ targetIp,
1214
+ []
1215
+ ];
1216
+ if (at === "target") return [
1217
+ injector,
1218
+ targetPeer,
1219
+ []
1220
+ ];
1221
+ const hop = hopNamed(at.hop);
1222
+ const peer = at.toward === "client" ? hop.peer.client : hop.peer.target.split(":")[0];
1223
+ const via = hop.hop.kind === "router" ? ["--via", hop.devices[at.toward]] : [];
1224
+ return [
1225
+ hop.injector,
1226
+ peer,
1227
+ via
1228
+ ];
1229
+ };
1230
+ /** This function gives the namespace and the device of netem at a place: an end, or the device of a hop toward a side. */
1231
+ const deviceAt = (at) => {
1232
+ if (at === "client") return [admin, "eth0"];
1233
+ if (at === "target") return [injector, "eth0"];
1234
+ const hop = hopNamed(at.hop);
1235
+ return [hop.injector, hop.devices[at.toward]];
1236
+ };
1237
+ const toxiproxy = () => {
1238
+ const found = inChain.find((hop) => hop.hop.kind === "proxy" && hop.hop.api !== void 0);
1239
+ if (found === void 0) throw new Error("the topology has no hop with the API of Toxiproxy");
1240
+ return found;
1241
+ };
1242
+ const stamped = async (where, argv) => {
1243
+ const stamp = JSON.parse((await output(where, argv)).split("\n").at(-1));
1244
+ return {
1245
+ t0: stamp.t0,
1246
+ t1: stamp.t1
1247
+ };
1248
+ };
1249
+ const proxies = inChain.filter((hop) => hop.hop.kind === "proxy");
620
1250
  return {
621
1251
  targetIp,
1252
+ targetPort,
622
1253
  clientIp,
623
1254
  netns,
1255
+ topology: placed,
1256
+ middlebox,
624
1257
  kernel: records.seen,
625
1258
  async sysctl(values) {
626
1259
  const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
@@ -673,54 +1306,85 @@ const openLab = async (target, kind = "tcp") => {
673
1306
  return read;
674
1307
  },
675
1308
  async fault(kind, at = "target", options = {}) {
676
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
677
- const args = [
1309
+ const [where, peer, via] = placeOf(at);
1310
+ return stamped(where, [
678
1311
  "/opt/harness/inject",
679
1312
  kind,
680
1313
  peer,
681
- ...optionArgs(options)
682
- ];
683
- const stamp = JSON.parse(await output(where, args));
684
- return {
685
- t0: stamp.t0,
686
- t1: stamp.t1
687
- };
1314
+ ...optionArgs(options),
1315
+ ...via
1316
+ ]);
688
1317
  },
689
1318
  async lift(kind, at = "target", options = {}) {
690
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
1319
+ const [where, peer, via] = placeOf(at);
691
1320
  await output(where, [
692
1321
  "/opt/harness/inject",
693
1322
  LIFT[kind],
694
1323
  peer,
695
- ...optionArgs(options)
1324
+ ...optionArgs(options),
1325
+ ...via
696
1326
  ]);
697
1327
  },
698
1328
  async netem(spec, at = "target") {
699
- const where = at === "client" ? admin : injector;
1329
+ const [where, device] = deviceAt(at);
700
1330
  const args = spec === null ? [
701
1331
  "/opt/harness/inject",
702
1332
  "clear-netem",
703
- "eth0"
1333
+ device
704
1334
  ] : [
705
1335
  "/opt/harness/inject",
706
1336
  "netem",
707
- "eth0",
1337
+ device,
708
1338
  ...spec.split(/\s+/)
709
1339
  ];
710
- const stamp = JSON.parse(await output(where, args));
711
- return {
712
- t0: stamp.t0,
713
- t1: stamp.t1
714
- };
1340
+ return stamped(where, args);
715
1341
  },
716
1342
  async offload(on, at = "target") {
717
- await output(at === "client" ? admin : injector, [
1343
+ const devices = typeof at === "object" ? Object.values(hopNamed(at.hop).devices) : [deviceAt(at)[1]];
1344
+ const [where] = deviceAt(at);
1345
+ for (const device of devices) await output(where, [
718
1346
  "/opt/harness/inject",
719
1347
  "offload",
720
- "eth0",
1348
+ device,
721
1349
  on ? "on" : "off"
722
1350
  ]);
723
1351
  },
1352
+ async toxic(toxic) {
1353
+ const hop = toxiproxy();
1354
+ const api = `127.0.0.1:${hop.hop.api.port}`;
1355
+ const path = `/proxies/${hop.hop.api.proxy}/toxics`;
1356
+ return stamped(hop.injector, toxic === null ? [
1357
+ "/opt/harness/inject",
1358
+ "http",
1359
+ "DELETE",
1360
+ api,
1361
+ `${path}/fault`
1362
+ ] : [
1363
+ "/opt/harness/inject",
1364
+ "http",
1365
+ "POST",
1366
+ api,
1367
+ path,
1368
+ toxicBody(toxic)
1369
+ ]);
1370
+ },
1371
+ async serverClose() {
1372
+ if (target.close === void 0) throw new Error(`${target.image} names no way to close its clients`);
1373
+ await output(server, [...target.close]);
1374
+ },
1375
+ async agent(agentImage, argv, options = {}) {
1376
+ const Shared = shared(GenericContainer);
1377
+ const seen = lines();
1378
+ let started = new Shared(agentImage).withNetworkMode(`container:${server.getId()}`).withCommand([...argv]).withLogConsumer(seen.consume);
1379
+ if (options.pid === true) started = started.withPidMode(`container:${server.getId()}`);
1380
+ const container = await started.start();
1381
+ opened.push(container);
1382
+ return {
1383
+ lines: seen,
1384
+ exec: (args) => output(container, [...args]),
1385
+ stop: () => container.stop(STOP).then(() => void 0).catch(() => void 0)
1386
+ };
1387
+ },
724
1388
  async kill() {
725
1389
  if (target.kill === void 0) throw new Error(`${target.image} names no way to kill its server`);
726
1390
  await output(server, [...target.kill]);
@@ -740,25 +1404,30 @@ const openLab = async (target, kind = "tcp") => {
740
1404
  "2",
741
1405
  "-W",
742
1406
  "2",
743
- clientIp
1407
+ targetPeer
1408
+ ]);
1409
+ for (const hop of proxies) await output(hop.injector, [
1410
+ "ping",
1411
+ "-c",
1412
+ "2",
1413
+ "-W",
1414
+ "2",
1415
+ hop.peer.target.split(":")[0]
744
1416
  ]);
745
1417
  },
746
1418
  async setup(commands) {
747
1419
  for (const argv of commands) await output(server, [...argv]);
748
1420
  },
749
1421
  async heal(at, when) {
750
- const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
751
- const stamp = JSON.parse(await output(where, [
1422
+ const [where, peer, via] = placeOf(at);
1423
+ return stamped(where, [
752
1424
  "/opt/harness/inject",
753
1425
  "clear",
754
1426
  peer,
755
1427
  "--at",
756
- String(Math.round(when))
757
- ]));
758
- return {
759
- t0: stamp.t0,
760
- t1: stamp.t1
761
- };
1428
+ String(Math.round(when)),
1429
+ ...via
1430
+ ]);
762
1431
  },
763
1432
  async forget() {
764
1433
  await output(admin, [
@@ -773,6 +1442,12 @@ const openLab = async (target, kind = "tcp") => {
773
1442
  "flush",
774
1443
  "all"
775
1444
  ]);
1445
+ for (const hop of proxies) await output(hop.injector, [
1446
+ "ip",
1447
+ "tcp_metrics",
1448
+ "flush",
1449
+ "all"
1450
+ ]);
776
1451
  },
777
1452
  freeze: () => paused.pause().then(() => void 0),
778
1453
  thaw: () => paused.unpause().then(() => void 0),
@@ -806,6 +1481,34 @@ const openLab = async (target, kind = "tcp") => {
806
1481
  async manifest(sysctls, settings = {}, targetSysctls = {}) {
807
1482
  const config = await output(admin, ["zcat", "/proc/config.gz"]).catch(() => "");
808
1483
  const inspect = async (container) => runtime.container.inspect(runtime.container.getById(container.getId()));
1484
+ const hopsOf = async () => {
1485
+ const out = [];
1486
+ for (const hop of inChain) {
1487
+ const box = hop.hop;
1488
+ out.push({
1489
+ name: box.name,
1490
+ kind: box.kind,
1491
+ image: box.kind === "router" ? await routerImage() : box.image,
1492
+ id: (await inspect(hop.box)).Image,
1493
+ settings: box.kind === "router" ? {
1494
+ form: box.form,
1495
+ idle: String(valueIn(box.idle, "s")),
1496
+ pump: String(valueIn(box.pump ?? PUMP, "s")),
1497
+ slow: String(valueIn(LISTING, "s")),
1498
+ packages: (await output(hop.box, [
1499
+ "apk",
1500
+ "info",
1501
+ "-v"
1502
+ ])).split("\n").sort().join(" ")
1503
+ } : {
1504
+ ...box.settings,
1505
+ ...box.files
1506
+ },
1507
+ sysctls: hop.sysctls
1508
+ });
1509
+ }
1510
+ return out;
1511
+ };
809
1512
  return {
810
1513
  kernel: await output(client, ["uname", "-r"]),
811
1514
  architecture: await output(client, ["uname", "-m"]),
@@ -828,11 +1531,12 @@ const openLab = async (target, kind = "tcp") => {
828
1531
  ])).split("\n").sort()
829
1532
  },
830
1533
  client: {
831
- kind,
1534
+ kind: clientKind,
832
1535
  image: clientImage
833
1536
  },
834
1537
  netns,
835
- sysctls
1538
+ sysctls,
1539
+ ...inChain.length === 0 ? {} : { hops: await hopsOf() }
836
1540
  };
837
1541
  },
838
1542
  close
@@ -850,7 +1554,7 @@ const openLab = async (target, kind = "tcp") => {
850
1554
  * again later. Thus the records get a new analysis after a change to observe.ts or to the verdict.
851
1555
  */
852
1556
  const judge = (cell, runs) => {
853
- const measured = runs.map((run) => measure(cell, run));
1557
+ const measured = runs.filter((run) => run.warmup !== true).map((run) => measure(cell, run));
854
1558
  return {
855
1559
  measured,
856
1560
  verdict: verdictOf(cell.prediction, measured)
@@ -864,10 +1568,10 @@ const droppedSoFar = (records) => {
864
1568
  /** This function gives the argv of the client for a cell, with a deadline far after all that it waits for. */
865
1569
  const probeArgs = (cell, lab) => {
866
1570
  const { prediction, probe } = cell;
867
- const longest = prediction.detects ? valueIn(prediction.high, "s") : valueIn(prediction.window, "s");
1571
+ const longest = (prediction.detects ? valueIn(prediction.high, "s") : valueIn(prediction.window, "s")) + (cell.idleBefore === void 0 ? 0 : valueIn(cell.idleBefore, "s"));
868
1572
  const args = [
869
1573
  lab.targetIp,
870
- String(cell.target.port),
1574
+ String(lab.targetPort),
871
1575
  "--mode",
872
1576
  probe.mode
873
1577
  ];
@@ -885,19 +1589,21 @@ const probeArgs = (cell, lab) => {
885
1589
  return args;
886
1590
  };
887
1591
  /**
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.
1592
+ * This function waits for an error on the socket of the observer, or for its end of file
1593
+ * (CLOSE_WAIT, state 8). The prober records both. A cell whose observer is the server, or a proxy
1594
+ * of a topology, waits for that. The promise resolves after `within` ms at the latest.
892
1595
  */
893
- const serverError = (lab, local, within) => new Promise((resolve) => {
1596
+ const observerError = (cell, partial, within) => new Promise((resolve) => {
894
1597
  const started = Date.now();
895
1598
  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();
1599
+ const run = partial();
1600
+ const at = socketOf(cell, run);
1601
+ 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
1602
  else setTimeout(look, 50);
898
1603
  };
899
1604
  look();
900
1605
  });
1606
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
901
1607
  /**
902
1608
  * This function waits for the request that an in-flight client sends on the signal: its second
903
1609
  * send, after the PING that made it ready. Its stamp is when the request first met the fault.
@@ -912,25 +1618,28 @@ const requestSent = (probe, within) => new Promise((resolve, reject) => {
912
1618
  };
913
1619
  look();
914
1620
  });
1621
+ /** This function gives the netem specification of a cell: its latency, then the netem of its fault when it has one. */
1622
+ const netemSpec = (cell, fault) => {
1623
+ const parts = [...cell.latency === void 0 ? [] : [`delay ${valueIn(cell.latency, "ms")}ms`], ...fault === void 0 ? [] : [fault]];
1624
+ return parts.length === 0 ? null : parts.join(" ");
1625
+ };
915
1626
  /**
916
1627
  * This function does one run. The client connects and gets its reply, the fault occurs, and an
917
1628
  * in-flight client sends into it. The harness then waits for the observer to see the fault, or for
918
1629
  * the silent window to end. The observer is the client, or the socket of the target for a cell on
919
- * the server side.
1630
+ * the server side. It can also be a socket of a proxy of a topology.
920
1631
  *
921
1632
  * A stall stops by itself, at its length after the send of the request, and the
922
1633
  * run waits for the reply. Then the harness removes the fault, thus the environment can serve the
923
1634
  * next run. The run keeps the records of the prober from its start to its end. The frozen process
924
1635
  * is the peer of the observer: the target, or the client for the server.
1636
+ *
1637
+ * A dry run does all of this but the fault: the warm-up of a cell whose fault ends its environment.
925
1638
  */
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) => {
1639
+ const once = async (lab, cell, environment, index, dry = false) => {
932
1640
  await lab.forget();
933
1641
  const from = lab.kernel.length;
1642
+ const middleboxFrom = lab.middlebox.length;
934
1643
  const lostBefore = droppedSoFar(lab.kernel);
935
1644
  const probe = await lab.probe(probeArgs(cell, lab));
936
1645
  const server = cell.observer === "server";
@@ -945,89 +1654,128 @@ const once = async (lab, cell, environment, index) => {
945
1654
  const last = probe.events.slice(-3).map((event) => JSON.stringify(event));
946
1655
  throw new Error(`${error.message}; the client's last lines: ${last.join(" ")}`);
947
1656
  });
948
- let fault;
1657
+ const placed = lab.topology === null ? {} : { topology: lab.topology };
1658
+ /** This function gives the run as the records show it so far: the waits read it. */
1659
+ const partial = () => ({
1660
+ environment,
1661
+ index,
1662
+ events: probe.events,
1663
+ fault: null,
1664
+ kernel: lab.kernel.slice(from),
1665
+ netns: lab.netns.client,
1666
+ targetNetns: lab.netns.target,
1667
+ lost: 0,
1668
+ ...placed
1669
+ });
1670
+ let fault = null;
949
1671
  let heal = null;
950
1672
  try {
951
1673
  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);
1674
+ if (dry) {
1675
+ if (cell.probe.mode !== "idle") await go();
1676
+ await sleep(500);
1677
+ } else {
1678
+ const replyInFlight = cell.probe.mode === "inflight" && (server && cell.fault !== "mtu-blackhole" || cell.fault === "fin");
1679
+ if (replyInFlight) {
1680
+ await go();
1681
+ await sleep(200);
978
1682
  }
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;
1683
+ const faultAt = cell.faultAt ?? (server ? "client" : "target");
1684
+ const towardServerComesIn = faultAt === "target" || typeof faultAt === "object" && faultAt.toward === "client";
1685
+ const direction = cell.drops === void 0 ? void 0 : cell.drops === "to-server" === towardServerComesIn ? "in" : "out";
1686
+ const options = {
1687
+ ...direction === void 0 ? {} : { direction },
1688
+ ...cell.icmp === void 0 ? {} : { code: cell.icmp },
1689
+ ...cell.mtu === void 0 ? {} : { above: cell.mtu }
1690
+ };
1691
+ const toxicFirst = cell.toxic !== void 0 && cell.fault !== "toxic";
1692
+ if (toxicFirst) await lab.toxic(cell.toxic);
1693
+ const netemAt = cell.faultAt ?? "target";
1694
+ const netemOn = (fault) => netemAt === "target" ? lab.netem(netemSpec(cell, fault), "target") : lab.netem(fault ?? null, netemAt);
1695
+ const apply = async () => {
1696
+ switch (cell.fault) {
1697
+ case "freeze-process":
1698
+ await peer.freeze();
1699
+ return null;
1700
+ case "fin":
1701
+ await (server ? probe.kill() : lab.kill());
1702
+ return null;
1703
+ case "unreachable": return lab.fault("reject", faultAt, { code: cell.icmp ?? "host" });
1704
+ case "simplex": return lab.fault("blackhole", faultAt, options);
1705
+ case "mtu-blackhole": return lab.fault("mtu", "client", { above: cell.mtu ?? 1200 });
1706
+ case "netem": return netemOn(cell.netem);
1707
+ case "rst": return lab.fault("rst", faultAt);
1708
+ case "middlebox-forget":
1709
+ case "proxy-idle":
1710
+ case "none": return null;
1711
+ case "toxic":
1712
+ if (cell.toxic === void 0) throw new Error(`${cell.name} names no toxic`);
1713
+ return lab.toxic(cell.toxic);
1714
+ case "server-close":
1715
+ await lab.serverClose();
1716
+ return null;
1717
+ default: return lab.fault("blackhole", faultAt);
1718
+ }
1719
+ };
1720
+ const lift = async () => {
1721
+ switch (cell.fault) {
1722
+ case "freeze-process": return peer.thaw();
1723
+ case "fin":
1724
+ case "middlebox-forget":
1725
+ case "proxy-idle":
1726
+ case "server-close":
1727
+ case "none": return;
1728
+ case "unreachable": return lab.lift("reject", faultAt, { code: cell.icmp ?? "host" });
1729
+ case "simplex": return lab.lift("blackhole", faultAt, options);
1730
+ case "mtu-blackhole": return lab.lift("mtu", "client", { above: cell.mtu ?? 1200 });
1731
+ case "netem":
1732
+ await netemOn(void 0);
1733
+ return;
1734
+ case "rst": return lab.lift("rst", faultAt);
1735
+ case "toxic":
1736
+ await lab.toxic(null);
1737
+ return;
1738
+ default: return heal === null ? lab.lift("blackhole", faultAt) : void 0;
1739
+ }
1740
+ };
1741
+ fault = await apply();
1742
+ try {
1743
+ if (cell.idleBefore !== void 0 && cell.probe.mode !== "idle") await sleep(valueIn(cell.idleBefore, "ms"));
1744
+ if (cell.probe.mode !== "idle" && !replyInFlight) await go();
1745
+ const { prediction } = cell;
1746
+ if (cell.fault === "stall") {
1747
+ if (cell.stall === void 0) throw new Error(`${cell.name} says no stall's length`);
1748
+ const sent = await requestSent(probe, 5e3);
1749
+ heal = await lab.heal(faultAt, sent.t + valueIn(cell.stall, "s") * 1e9);
1750
+ const window = valueIn(prediction.detects ? prediction.high : prediction.window, "s");
1751
+ await probe.until([
1752
+ "data",
1753
+ "error",
1754
+ "eof"
1755
+ ], window * 1e3).catch(() => void 0);
1756
+ } else if (prediction.detects === null) {
1757
+ const window = valueIn(prediction.window, "s") * 1e3;
1758
+ await probe.until([
1759
+ "done",
1760
+ "error",
1761
+ "eof",
1762
+ "deadline"
1763
+ ], window).catch(() => void 0);
1764
+ } else if (prediction.detects) {
1765
+ const within = valueIn(prediction.high, "s") * 2e3 + 1e4;
1766
+ if (cell.observer !== void 0 && cell.observer !== "client") await observerError(cell, partial, within);
1767
+ else await probe.until([
1768
+ "error",
1769
+ "eof",
1770
+ "deadline"
1771
+ ], within).catch(() => void 0);
1772
+ } else await sleep(valueIn(prediction.window, "s") * 1e3);
1773
+ } finally {
1774
+ await lift();
1775
+ if (toxicFirst) await lab.toxic(null);
992
1776
  }
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();
1777
+ if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
1029
1778
  }
1030
- if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
1031
1779
  } finally {
1032
1780
  await probe.stop();
1033
1781
  }
@@ -1042,14 +1790,25 @@ const once = async (lab, cell, environment, index) => {
1042
1790
  kernel: lab.kernel.slice(from),
1043
1791
  netns: lab.netns.client,
1044
1792
  targetNetns: lab.netns.target,
1045
- lost
1793
+ lost,
1794
+ ...placed,
1795
+ ...lab.topology === null ? {} : { middlebox: lab.middlebox.slice(middleboxFrom) }
1046
1796
  };
1047
1797
  };
1048
1798
  /** This function divides a count of runs across environments, as equally as possible. */
1049
1799
  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. */
1800
+ /** This function tells if the fault of a cell ends its environment: a server that stopped cannot serve the next run. */
1801
+ const endsEnvironment = (cell) => cell.fault === "fin" && (cell.observer === void 0 || cell.observer === "client");
1802
+ /** This function says one line for a run. */
1803
+ 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})`}`;
1804
+ /**
1805
+ * This function does the part of the runs of a cell in one environment, and records its manifest.
1806
+ * A warm-up goes first, and the verdict does not count it. On a loaded machine, a new environment is
1807
+ * sometimes slow at first: its first exec, its first client, its first fault. The warm-up
1808
+ * of a cell whose fault ends the environment is a dry run, without the fault.
1809
+ */
1051
1810
  const inEnvironment = async (cell, environment, count, say) => {
1052
- const lab = await openLab(cell.target, cell.probe.driver?.name ?? "tcp");
1811
+ const lab = await openLab(cell.target, cell.client ?? cell.probe.driver?.name ?? "tcp", cell.topology);
1053
1812
  try {
1054
1813
  const settings = await lab.configure(cell.targetSettings ?? {});
1055
1814
  const targetSysctls = await lab.targetSysctl(cell.targetSysctls ?? {});
@@ -1057,21 +1816,27 @@ const inEnvironment = async (cell, environment, count, say) => {
1057
1816
  if (cell.fault === "mtu-blackhole" || cell.fault === "netem") {
1058
1817
  await lab.offload(false, "target");
1059
1818
  await lab.offload(false, "client");
1819
+ if (typeof cell.faultAt === "object") await lab.offload(false, cell.faultAt);
1060
1820
  }
1061
1821
  const latency = netemSpec(cell, void 0);
1062
1822
  if (latency !== null) await lab.netem(latency, "target");
1063
1823
  await lab.warm();
1064
1824
  const manifest = await lab.manifest(await lab.sysctl(cell.sysctls), settings, targetSysctls);
1825
+ const warmup = {
1826
+ ...await once(lab, cell, environment, -1, endsEnvironment(cell)),
1827
+ warmup: true
1828
+ };
1829
+ say(line(cell, warmup, measure(cell, warmup)));
1065
1830
  const runs = [];
1066
1831
  for (let index = 0; index < count; index += 1) {
1067
1832
  const run = await once(lab, cell, environment, index);
1068
1833
  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})`}`);
1834
+ say(line(cell, run, measure(cell, run)));
1071
1835
  }
1072
1836
  return {
1073
1837
  manifest,
1074
- runs
1838
+ runs,
1839
+ warmup
1075
1840
  };
1076
1841
  } finally {
1077
1842
  await lab.close();
@@ -1083,11 +1848,14 @@ const inEnvironment = async (cell, environment, count, say) => {
1083
1848
  * the cell from 11 runs to 22, and then to 44, each extension in new environments. With a
1084
1849
  * concurrency of more than one, that many environments operate at the same time, each with its
1085
1850
  * own prober. The runs then go across at least as many environments.
1851
+ *
1852
+ * Each environment does a warm-up first, and the result keeps it apart from the counted runs.
1086
1853
  */
1087
1854
  const runCell = async (cell, say = () => {}) => {
1088
1855
  const started = (/* @__PURE__ */ new Date()).toISOString();
1089
1856
  const manifests = [];
1090
1857
  const runs = [];
1858
+ const warmups = [];
1091
1859
  const concurrency = Math.max(1, cell.concurrency ?? 1);
1092
1860
  let target = cell.runs;
1093
1861
  let next = 0;
@@ -1102,6 +1870,7 @@ const runCell = async (cell, say = () => {}) => {
1102
1870
  for (const each of done) {
1103
1871
  manifests.push(each.manifest);
1104
1872
  runs.push(...each.runs);
1873
+ warmups.push(each.warmup);
1105
1874
  }
1106
1875
  }
1107
1876
  const { measured, verdict } = judge(cell, runs);
@@ -1110,6 +1879,7 @@ const runCell = async (cell, say = () => {}) => {
1110
1879
  cell,
1111
1880
  manifests,
1112
1881
  runs,
1882
+ warmups,
1113
1883
  measured,
1114
1884
  verdict,
1115
1885
  at: {
@@ -1121,6 +1891,6 @@ const runCell = async (cell, say = () => {}) => {
1121
1891
  }
1122
1892
  };
1123
1893
  //#endregion
1124
- export { NOISE, RELATIVE, STEPS, harnessImage, judge, measure, medianInterval, nodeClientImage, openLab, runCell, spread, verdictOf };
1894
+ export { LISTING, NOISE, PUMP, RELATIVE, STEPS, crossingsOf, deviceOf, endpointAfter, harnessImage, judge, measure, medianInterval, nodeClientImage, openLab, quantileOf, reorderingOf, routerImage, routesOf, runCell, socketOf, spread, spreadOf, templated, toxicBody, verdictOf };
1125
1895
 
1126
1896
  //# sourceMappingURL=index.js.map