@mark1russell7/harness 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,1126 @@
1
+ import { quantity, valueIn } from "@mark1russell7/quantity";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join, relative } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ //#region src/observe.ts
9
+ /** These are the errno numbers that the kernel reports to a socket, by the names that predictions use. */
10
+ const ERRNO = {
11
+ 32: "EPIPE",
12
+ 101: "ENETUNREACH",
13
+ 103: "ECONNABORTED",
14
+ 104: "ECONNRESET",
15
+ 110: "ETIMEDOUT",
16
+ 111: "ECONNREFUSED",
17
+ 113: "EHOSTUNREACH"
18
+ };
19
+ /** This function gives the interval between two instants on the one clock, in seconds to the microsecond. */
20
+ const between = (from, to) => quantity(Math.round((to - from) / 1e3) / 1e6, "s");
21
+ /** These functions give the states of a state record: before the change, and after it. TCP_ESTABLISHED is 1, TCP_CLOSE_WAIT 8. */
22
+ const oldState = (record) => record.old;
23
+ const newState = (record) => record.new;
24
+ const count = (event, field) => event === void 0 ? 0 : Number(event[field] ?? 0);
25
+ /** This function gives the view of the client on the same interval: TCP_INFO, with a sample each 2 ms. */
26
+ const socketVantage = (cell, events, ready, request) => {
27
+ if (!cell.prediction.detects || cell.prediction.from === "peer-closed") return null;
28
+ const ended = events.find((event) => event.ev === "error" || event.ev === "eof");
29
+ 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
+ return ended === void 0 || reference === void 0 ? null : between(reference.t, ended.t);
31
+ };
32
+ /**
33
+ * 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.
35
+ * - The fault must hold before the event that it must meet. That is before the request goes into
36
+ * it, and before the first keepalive probe. A live peer answers that probe, and the answer
37
+ * starts the idle time again. A run where the fault was late is void. A run whose records the
38
+ * prober lost is also void.
39
+ */
40
+ const measure = (cell, run) => {
41
+ const { events, kernel } = run;
42
+ const ready = events.find((event) => event.ev === "ready");
43
+ const local = events.find((event) => event.ev === "connected")?.["local"];
44
+ const inflight = cell.probe.mode !== "idle";
45
+ const request = inflight ? events.filter((event) => event.ev === "sent")[1] : ready;
46
+ 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;
49
+ 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;
52
+ const keepalives = socket.filter((record) => record.ev === "probe" && record.what === "keepalive");
53
+ const retransmitted = socket.filter((record) => record.ev === "retrans");
54
+ /**
55
+ * This function examines the timer that drives the measurement: keepalive or retransmit. It
56
+ * finds the first firing in an interval that was two jiffies or more behind its wheel slot. The
57
+ * wheel does a slot in the tick when it is due, thus such a delay comes from the CPU: the host
58
+ * stopped a vCPU. The prediction is about the timing of the kernel, and that firing measured
59
+ * the timing of the host. One jiffy is a tick that occurred while the wheel worked, and δ_abs
60
+ * includes it. The function ignores the other timer: the retransmit timer fires one time, idle,
61
+ * after a reply, and it does not move a keepalive.
62
+ */
63
+ const delayed = (from, to, timer) => {
64
+ const firings = socket.filter((record) => record.ev === "timer" && record.what === timer && record.mono >= from && record.mono <= to);
65
+ const late = firings.find((record) => (record.behind?.value ?? 0) >= 2);
66
+ if (late !== void 0) return `its ${timer} timer ran ${late.behind.value} jiffies behind its wheel slot: the VM delayed it, not the kernel`;
67
+ 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
+ };
69
+ const acks = request === void 0 ? 0 : socket.filter((record) => record.ev === "rx" && record.mono > request.t).length;
70
+ const base = {
71
+ environment: run.environment,
72
+ index: run.index,
73
+ probes: keepalives.length,
74
+ retransmissions: retransmitted.length,
75
+ acks
76
+ };
77
+ const nothing = {
78
+ outcome: { kind: "silent" },
79
+ elapsed: null,
80
+ socket: null,
81
+ noticed: null,
82
+ steps: [],
83
+ finding: null
84
+ };
85
+ if (ready === void 0) return {
86
+ ...base,
87
+ ...nothing,
88
+ void: "the client never had its reply"
89
+ };
90
+ if (request === void 0) return {
91
+ ...base,
92
+ ...nothing,
93
+ void: "the request was never sent into the fault"
94
+ };
95
+ if (run.lost === null) return {
96
+ ...base,
97
+ ...nothing,
98
+ void: "the prober wrote no clock line after the run, so nothing says what it dropped"
99
+ };
100
+ if (run.lost > 0) return {
101
+ ...base,
102
+ ...nothing,
103
+ void: `the prober dropped ${run.lost} records`
104
+ };
105
+ if (cookie === void 0) return {
106
+ ...base,
107
+ ...nothing,
108
+ void: "the prober saw nothing of this connection"
109
+ };
110
+ let voided = null;
111
+ if (run.fault !== null) {
112
+ const replyFirst = server && inflight && cell.fault !== "mtu-blackhole";
113
+ const replied = events.some((event) => event.ev === "data" && event.t > request.t && event.t < run.fault.t1);
114
+ if (inflight && !replyFirst && run.fault.t1 > request.t) voided = "the request went out before the fault held";
115
+ else if (replyFirst && run.fault.t1 < request.t) voided = "the request went out into the fault, so the server never had it";
116
+ else if (replyFirst && replied) voided = "the server's reply arrived before the fault held";
117
+ else if (keepalives[0] !== void 0 && keepalives[0].mono < run.fault.t1) voided = "a keepalive probe went out before the fault held";
118
+ }
119
+ 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");
121
+ const closeWait = socket.find((record) => record.ev === "state" && newState(record) === 8);
122
+ const outcome = reported !== void 0 ? {
123
+ kind: "error",
124
+ errno: ERRNO[reported.err] ?? `E${reported.err}`
125
+ } : closeWait !== void 0 || seen?.ev === "eof" ? { kind: "eof" } : seen !== void 0 ? {
126
+ kind: "error",
127
+ errno: String(seen["errno"])
128
+ } : { kind: "silent" };
129
+ const ended = reported?.mono ?? closeWait?.mono ?? seen?.t;
130
+ const noticed = reported !== void 0 && seen !== void 0 ? between(reported.mono, seen.t) : null;
131
+ const { prediction } = cell;
132
+ if (prediction.detects === null) {
133
+ const sends = events.filter((event) => event.ev === "sent").slice(1);
134
+ const samples = sends.flatMap((send, n) => {
135
+ const next = sends[n + 1]?.t ?? Infinity;
136
+ const reply = events.find((event) => event.ev === "data" && event.t > send.t && event.t < next);
137
+ return reply === void 0 ? [] : [between(send.t, reply.t)];
138
+ });
139
+ return {
140
+ ...base,
141
+ ...nothing,
142
+ outcome: ended !== void 0 ? outcome : { kind: "answered" },
143
+ steps: retransmitted.filter((record) => record.mono > request.t).map((record) => between(request.t, record.mono)),
144
+ samples,
145
+ void: voided
146
+ };
147
+ }
148
+ if (!prediction.detects && prediction.recovers !== void 0) {
149
+ const { recovers } = prediction;
150
+ const heal = run.heal ?? null;
151
+ if (voided === null && heal === null) voided = "the stall never ended";
152
+ else if (voided === null && heal !== null) {
153
+ const from = between(request.t, heal.t0);
154
+ const to = between(request.t, heal.t1);
155
+ if (valueIn(from, "s") <= valueIn(recovers.heal.low, "s") || valueIn(to, "s") >= valueIn(recovers.heal.high, "s")) voided = `the stall ended ${from.value}–${to.value} s after the send, outside the ${recovers.heal.low.value}–${recovers.heal.high.value} s its prediction assumed`;
156
+ }
157
+ const steps = retransmitted.filter((record) => record.mono > request.t).map((record) => between(request.t, record.mono));
158
+ if (ended !== void 0) return {
159
+ ...base,
160
+ ...nothing,
161
+ outcome,
162
+ steps,
163
+ void: voided,
164
+ finding: `detected after ${between(request.t, ended).value} s, where it should recover`
165
+ };
166
+ const answer = heal === null ? void 0 : socket.find((record) => record.ev === "rx" && record.mono > heal.t0);
167
+ if (answer === void 0) return {
168
+ ...base,
169
+ ...nothing,
170
+ steps,
171
+ void: voided,
172
+ finding: "it never moved again: nothing came back after the stall ended"
173
+ };
174
+ const reply = events.find((event) => event.ev === "data" && event.t > request.t);
175
+ voided ??= delayed(request.t, answer.mono, "retransmit");
176
+ return {
177
+ ...base,
178
+ outcome: { kind: "answered" },
179
+ elapsed: between(request.t, answer.mono),
180
+ socket: reply === void 0 ? null : between(request.t, reply.t),
181
+ noticed: reply === void 0 ? null : between(answer.mono, reply.t),
182
+ steps,
183
+ void: voided,
184
+ finding: null
185
+ };
186
+ }
187
+ if (!prediction.detects) {
188
+ const window = valueIn(prediction.window, "s") * 1e9;
189
+ if (ended === void 0 || ended - request.t > window) return {
190
+ ...base,
191
+ ...nothing,
192
+ void: voided
193
+ };
194
+ return {
195
+ ...base,
196
+ ...nothing,
197
+ outcome,
198
+ elapsed: between(request.t, ended),
199
+ void: voided,
200
+ finding: `detected after ${between(request.t, ended).value} s, where nothing should`
201
+ };
202
+ }
203
+ if (ended === void 0) return {
204
+ ...base,
205
+ ...nothing,
206
+ void: voided,
207
+ finding: `nothing detected it: ${prediction.timer} never fired`
208
+ };
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;
212
+ if (reference === void 0) return {
213
+ ...base,
214
+ ...nothing,
215
+ outcome,
216
+ noticed,
217
+ void: voided,
218
+ finding: `it ended without its reference event (${prediction.from})`
219
+ };
220
+ 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
+ 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");
223
+ return {
224
+ ...base,
225
+ outcome,
226
+ elapsed: between(reference, ended),
227
+ socket: server ? null : socketVantage(cell, events, ready, request),
228
+ noticed,
229
+ steps,
230
+ void: voided,
231
+ finding
232
+ };
233
+ };
234
+ //#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
+ //#region src/lab.ts
403
+ /**
404
+ * These are the roots of the two packages that the images read: this package, and the prober
405
+ * that Node resolves for it. In a workspace, they are two folders side by side. From npm, the
406
+ * prober is a link, and a Docker build context does not follow a link. Thus each image builds
407
+ * from a context that the harness copies from the two roots.
408
+ */
409
+ const ROOTS = {
410
+ harness: fileURLToPath(new URL("..", import.meta.url)),
411
+ prober: dirname(realpathSync(createRequire(import.meta.url).resolve("@mark1russell7/prober/package.json")))
412
+ };
413
+ /** This function gives the path of a part of an image, which is `<package>/<path>` in the context. */
414
+ const pathOf = (part) => {
415
+ const [root, ...rest] = part.split("/");
416
+ const base = ROOTS[root];
417
+ if (base === void 0) throw new Error(`${part} is in no package that the images read`);
418
+ return join(base, ...rest);
419
+ };
420
+ /** This function loads Testcontainers when a lab first opens. Code that only measures and judges does not load it. */
421
+ const containers = () => import("testcontainers");
422
+ /** This image holds the prober, the TCP client, the injector and the namespace admin. */
423
+ const HARNESS = {
424
+ name: "loom-harness",
425
+ dockerfile: "harness/image/Dockerfile",
426
+ from: [
427
+ "harness/image/Dockerfile",
428
+ "harness/image/native",
429
+ "prober/bpf",
430
+ "prober/native",
431
+ "prober/fixtures"
432
+ ]
433
+ };
434
+ /** This image holds the Node client: ioredis 6.0.0 on Node 24.21.0. */
435
+ const NODE = {
436
+ name: "loom-harness-node",
437
+ dockerfile: "harness/image/node/Dockerfile",
438
+ from: ["harness/image/node"]
439
+ };
440
+ /** This function gives each file under a path, in a stable sequence, but not the output of a local build. */
441
+ const sources = (path) => {
442
+ if (statSync(path).isFile()) return [path];
443
+ return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.name !== "target" && entry.name !== "node_modules").flatMap((entry) => sources(join(path, entry.name))).sort();
444
+ };
445
+ /** This function gives each file of an image, with its path in the build context. */
446
+ const filesOf = (image) => image.from.flatMap((part) => {
447
+ const base = pathOf(part);
448
+ return sources(base).map((file) => ({
449
+ file,
450
+ at: join(part, relative(base, file)).replaceAll("\\", "/")
451
+ }));
452
+ });
453
+ /** This function gives the tag of an image from its content. Thus a changed probe or client cannot operate as an old one. */
454
+ const imageTag = (image) => {
455
+ const hash = createHash("sha256");
456
+ for (const { file, at } of filesOf(image)) {
457
+ hash.update(at);
458
+ hash.update(readFileSync(file));
459
+ }
460
+ return `${image.name}:${hash.digest("hex").slice(0, 12)}`;
461
+ };
462
+ /** This function copies the files of an image into a new build context, and gives its path. */
463
+ const stage = (image) => {
464
+ const context = mkdtempSync(join(tmpdir(), `${image.name}-`));
465
+ for (const { file, at } of filesOf(image)) {
466
+ mkdirSync(dirname(join(context, at)), { recursive: true });
467
+ copyFileSync(file, join(context, at));
468
+ }
469
+ return context;
470
+ };
471
+ const built = /* @__PURE__ */ new Map();
472
+ /** This function builds an image one time for each process. Docker keeps its layers between runs. */
473
+ const build = (image) => {
474
+ let tag = built.get(image.name);
475
+ if (tag === void 0) {
476
+ tag = (async () => {
477
+ const name = imageTag(image);
478
+ const { GenericContainer } = await containers();
479
+ const context = stage(image);
480
+ try {
481
+ await GenericContainer.fromDockerfile(context, image.dockerfile).build(name, { deleteOnExit: false });
482
+ } finally {
483
+ rmSync(context, {
484
+ recursive: true,
485
+ force: true
486
+ });
487
+ }
488
+ return name;
489
+ })();
490
+ built.set(image.name, tag);
491
+ }
492
+ return tag;
493
+ };
494
+ /** This function gives the image of the harness. */
495
+ const harnessImage = () => build(HARNESS);
496
+ /** This function gives the image of the Node client. */
497
+ const nodeClientImage = () => build(NODE);
498
+ /** This function gives the arguments of the injector for the options of a fault. */
499
+ const optionArgs = (options) => [
500
+ ...options.direction === void 0 ? [] : ["--direction", options.direction],
501
+ ...options.code === void 0 ? [] : ["--code", options.code],
502
+ ...options.above === void 0 ? [] : ["--above", String(options.above)]
503
+ ];
504
+ /** These are the actions of the injector that lift each network fault. */
505
+ const LIFT = {
506
+ blackhole: "clear",
507
+ rst: "clear-rst",
508
+ reject: "clear-reject",
509
+ mtu: "clear-mtu"
510
+ };
511
+ const lines = () => {
512
+ const seen = [];
513
+ const other = [];
514
+ const waiting = [];
515
+ let partial = "";
516
+ const take = (text) => {
517
+ if (!text.startsWith("{")) {
518
+ if (text.length > 0) other.push(text);
519
+ return;
520
+ }
521
+ const line = JSON.parse(text);
522
+ seen.push(line);
523
+ for (const wait of waiting.splice(0)) if (wait.kinds.includes(line.ev)) wait.resolve(line);
524
+ else waiting.push(wait);
525
+ };
526
+ return {
527
+ seen,
528
+ other,
529
+ consume: (stream) => stream.on("data", (chunk) => {
530
+ partial += chunk.toString();
531
+ const split = partial.split("\n");
532
+ partial = split.pop() ?? "";
533
+ for (const text of split) take(text.trim());
534
+ }),
535
+ until(kinds, within, who) {
536
+ const found = seen.find((line) => kinds.includes(line.ev));
537
+ if (found !== void 0) return Promise.resolve(found);
538
+ return new Promise((resolve, reject) => {
539
+ const timer = setTimeout(() => {
540
+ const said = other.length === 0 ? "" : `; it wrote: ${other.slice(-3).join(" | ")}`;
541
+ reject(/* @__PURE__ */ new Error(`no ${kinds.join(" or ")} from the ${who} in ${within} ms${said}`));
542
+ }, within);
543
+ waiting.push({
544
+ kinds,
545
+ resolve: (line) => {
546
+ clearTimeout(timer);
547
+ resolve(line);
548
+ }
549
+ });
550
+ });
551
+ }
552
+ };
553
+ };
554
+ const STOP = { timeout: 0 };
555
+ const output = async (container, argv) => {
556
+ const result = await container.exec(argv);
557
+ if (result.exitCode !== 0) throw new Error(`${argv.join(" ")} exited ${result.exitCode}: ${result.output.trim()}`);
558
+ return result.stdout.trim();
559
+ };
560
+ /** This function gives the inode of the network namespace of a container: net:[4026532583]. */
561
+ const netnsOf = async (container) => {
562
+ const link = await output(container, ["readlink", "/proc/self/ns/net"]);
563
+ const inode = /^net:\[(\d+)\]$/.exec(link)?.[1];
564
+ if (inode === void 0) throw new Error(`not a network namespace: ${link}`);
565
+ return Number(inode);
566
+ };
567
+ /**
568
+ * This function opens one environment:
569
+ * - a new network and the stock target.
570
+ * - 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.
572
+ * - 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.
575
+ *
576
+ * 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.
579
+ */
580
+ const openLab = async (target, kind = "tcp") => {
581
+ 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"];
584
+ const { GenericContainer, Network, Wait, getContainerRuntimeClient } = await containers();
585
+ const opened = [];
586
+ let network;
587
+ const close = async () => {
588
+ for (const container of opened.reverse()) await container.stop(STOP).catch(() => void 0);
589
+ await network?.stop().catch(() => void 0);
590
+ };
591
+ try {
592
+ network = await new Network().start();
593
+ const stock = new GenericContainer(target.image).withNetwork(network).withWaitStrategy(Wait.forLogMessage(target.ready));
594
+ const server = await (target.command === void 0 ? stock : stock.withCommand([...target.command])).start();
595
+ opened.push(server);
596
+ const client = await new GenericContainer(image).withNetwork(network).start();
597
+ opened.push(client);
598
+ const admin = await new GenericContainer(image).withNetworkMode(`container:${client.getId()}`).withPrivilegedMode().start();
599
+ opened.push(admin);
600
+ const injector = await new GenericContainer(image).withNetworkMode(`container:${server.getId()}`).withAddedCapabilities("NET_ADMIN", "NET_RAW").start();
601
+ opened.push(injector);
602
+ const netns = {
603
+ client: await netnsOf(admin),
604
+ target: await netnsOf(injector)
605
+ };
606
+ const records = lines();
607
+ const prober = await new GenericContainer(image).withPrivilegedMode().withNetworkMode("none").withCommand([
608
+ "/opt/harness/prober",
609
+ String(netns.client),
610
+ String(netns.target)
611
+ ]).withLogConsumer(records.consume).start();
612
+ opened.push(prober);
613
+ const begun = await records.until(["begin", "fail"], 3e4, "prober");
614
+ 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();
618
+ const paused = runtime.container.getById(server.getId());
619
+ let targetAdmin;
620
+ return {
621
+ targetIp,
622
+ clientIp,
623
+ netns,
624
+ kernel: records.seen,
625
+ async sysctl(values) {
626
+ const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
627
+ if (settings.length > 0) await output(admin, [
628
+ "sysctl",
629
+ "-q",
630
+ "-w",
631
+ ...settings
632
+ ]);
633
+ const read = {};
634
+ for (const key of Object.keys(values)) read[key] = await output(admin, [
635
+ "sysctl",
636
+ "-n",
637
+ key
638
+ ]);
639
+ return read;
640
+ },
641
+ async targetSysctl(values) {
642
+ const read = {};
643
+ if (Object.keys(values).length === 0) return read;
644
+ targetAdmin ??= await new GenericContainer(image).withNetworkMode(`container:${server.getId()}`).withPrivilegedMode().start();
645
+ opened.push(targetAdmin);
646
+ const settings = Object.entries(values).map(([key, value]) => `${key}=${value}`);
647
+ await output(targetAdmin, [
648
+ "sysctl",
649
+ "-q",
650
+ "-w",
651
+ ...settings
652
+ ]);
653
+ for (const key of Object.keys(values)) read[key] = await output(targetAdmin, [
654
+ "sysctl",
655
+ "-n",
656
+ key
657
+ ]);
658
+ return read;
659
+ },
660
+ async configure(values) {
661
+ const read = {};
662
+ if (Object.keys(values).length === 0) return read;
663
+ const how = target.settings;
664
+ if (how === void 0) throw new Error(`${target.image} names no way to set its settings`);
665
+ for (const [name, value] of Object.entries(values)) {
666
+ await output(server, [
667
+ ...how.set,
668
+ name,
669
+ value
670
+ ]);
671
+ read[name] = (await output(server, [...how.get, name])).split("\n").at(-1) ?? "";
672
+ }
673
+ return read;
674
+ },
675
+ async fault(kind, at = "target", options = {}) {
676
+ const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
677
+ const args = [
678
+ "/opt/harness/inject",
679
+ kind,
680
+ 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
+ };
688
+ },
689
+ async lift(kind, at = "target", options = {}) {
690
+ const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
691
+ await output(where, [
692
+ "/opt/harness/inject",
693
+ LIFT[kind],
694
+ peer,
695
+ ...optionArgs(options)
696
+ ]);
697
+ },
698
+ async netem(spec, at = "target") {
699
+ const where = at === "client" ? admin : injector;
700
+ const args = spec === null ? [
701
+ "/opt/harness/inject",
702
+ "clear-netem",
703
+ "eth0"
704
+ ] : [
705
+ "/opt/harness/inject",
706
+ "netem",
707
+ "eth0",
708
+ ...spec.split(/\s+/)
709
+ ];
710
+ const stamp = JSON.parse(await output(where, args));
711
+ return {
712
+ t0: stamp.t0,
713
+ t1: stamp.t1
714
+ };
715
+ },
716
+ async offload(on, at = "target") {
717
+ await output(at === "client" ? admin : injector, [
718
+ "/opt/harness/inject",
719
+ "offload",
720
+ "eth0",
721
+ on ? "on" : "off"
722
+ ]);
723
+ },
724
+ async kill() {
725
+ if (target.kill === void 0) throw new Error(`${target.image} names no way to kill its server`);
726
+ await output(server, [...target.kill]);
727
+ },
728
+ async warm() {
729
+ await output(admin, [
730
+ "ping",
731
+ "-c",
732
+ "2",
733
+ "-W",
734
+ "2",
735
+ targetIp
736
+ ]);
737
+ await output(injector, [
738
+ "ping",
739
+ "-c",
740
+ "2",
741
+ "-W",
742
+ "2",
743
+ clientIp
744
+ ]);
745
+ },
746
+ async setup(commands) {
747
+ for (const argv of commands) await output(server, [...argv]);
748
+ },
749
+ async heal(at, when) {
750
+ const [where, peer] = at === "client" ? [admin, targetIp] : [injector, clientIp];
751
+ const stamp = JSON.parse(await output(where, [
752
+ "/opt/harness/inject",
753
+ "clear",
754
+ peer,
755
+ "--at",
756
+ String(Math.round(when))
757
+ ]));
758
+ return {
759
+ t0: stamp.t0,
760
+ t1: stamp.t1
761
+ };
762
+ },
763
+ async forget() {
764
+ await output(admin, [
765
+ "ip",
766
+ "tcp_metrics",
767
+ "flush",
768
+ "all"
769
+ ]);
770
+ await output(injector, [
771
+ "ip",
772
+ "tcp_metrics",
773
+ "flush",
774
+ "all"
775
+ ]);
776
+ },
777
+ freeze: () => paused.pause().then(() => void 0),
778
+ thaw: () => paused.unpause().then(() => void 0),
779
+ async probe(args) {
780
+ const events = lines();
781
+ const container = await new GenericContainer(clientImage).withNetworkMode(`container:${client.getId()}`).withCommand([...command, ...args]).withLogConsumer(events.consume).start();
782
+ return {
783
+ events: events.seen,
784
+ until: (kinds, within) => events.until(kinds, within, "client"),
785
+ go: () => output(container, ["touch", "/tmp/go"]).then(() => void 0),
786
+ freeze: () => runtime.container.getById(container.getId()).pause().then(() => void 0),
787
+ thaw: () => runtime.container.getById(container.getId()).unpause().then(() => void 0),
788
+ kill: () => runtime.container.getById(container.getId()).kill({ signal: "SIGKILL" }).then(() => void 0),
789
+ stop: () => container.stop(STOP).then(() => void 0).catch(() => void 0)
790
+ };
791
+ },
792
+ async settle() {
793
+ const after = records.seen.length;
794
+ const clock = await new Promise((resolve) => {
795
+ const started = Date.now();
796
+ const look = () => {
797
+ const line = records.seen.slice(after).find((record) => record.ev === "clock");
798
+ if (line !== void 0) resolve(line);
799
+ else if (Date.now() - started > 5e3) resolve(null);
800
+ else setTimeout(look, 50);
801
+ };
802
+ look();
803
+ });
804
+ return clock === null ? null : Number(clock["lost"] ?? 0);
805
+ },
806
+ async manifest(sysctls, settings = {}, targetSysctls = {}) {
807
+ const config = await output(admin, ["zcat", "/proc/config.gz"]).catch(() => "");
808
+ const inspect = async (container) => runtime.container.inspect(runtime.container.getById(container.getId()));
809
+ return {
810
+ kernel: await output(client, ["uname", "-r"]),
811
+ architecture: await output(client, ["uname", "-m"]),
812
+ hz: /^CONFIG_HZ=(\d+)$/m.exec(config)?.[1] ?? null,
813
+ boot_id: await output(client, ["cat", "/proc/sys/kernel/random/boot_id"]),
814
+ docker: `${runtime.info.containerRuntime.serverVersion} on ${runtime.info.containerRuntime.operatingSystem}`,
815
+ target: {
816
+ image: target.image,
817
+ id: (await inspect(server)).Image,
818
+ settings,
819
+ sysctls: targetSysctls
820
+ },
821
+ harness: {
822
+ image,
823
+ id: (await inspect(client)).Image,
824
+ packages: (await output(client, [
825
+ "apk",
826
+ "info",
827
+ "-v"
828
+ ])).split("\n").sort()
829
+ },
830
+ client: {
831
+ kind,
832
+ image: clientImage
833
+ },
834
+ netns,
835
+ sysctls
836
+ };
837
+ },
838
+ close
839
+ };
840
+ } catch (error) {
841
+ await close();
842
+ throw error;
843
+ }
844
+ };
845
+ //#endregion
846
+ //#region src/run.ts
847
+ /**
848
+ * This function measures and judges the raw records of a cell, with the code of the harness at
849
+ * this time. `runCell` uses it during a cell. A holder of the raw records of a cell can use it
850
+ * again later. Thus the records get a new analysis after a change to observe.ts or to the verdict.
851
+ */
852
+ const judge = (cell, runs) => {
853
+ const measured = runs.map((run) => measure(cell, run));
854
+ return {
855
+ measured,
856
+ verdict: verdictOf(cell.prediction, measured)
857
+ };
858
+ };
859
+ /** This function gives the count of lost records in the latest clock line of the prober, since it began. */
860
+ const droppedSoFar = (records) => {
861
+ for (let at = records.length - 1; at >= 0; at -= 1) if (records[at].ev === "clock") return Number(records[at]["lost"] ?? 0);
862
+ return 0;
863
+ };
864
+ /** This function gives the argv of the client for a cell, with a deadline far after all that it waits for. */
865
+ const probeArgs = (cell, lab) => {
866
+ const { prediction, probe } = cell;
867
+ const longest = prediction.detects ? valueIn(prediction.high, "s") : valueIn(prediction.window, "s");
868
+ const args = [
869
+ lab.targetIp,
870
+ String(cell.target.port),
871
+ "--mode",
872
+ probe.mode
873
+ ];
874
+ if (probe.request !== void 0) args.push("--request", probe.request);
875
+ if (probe.count !== void 0) args.push("--count", String(probe.count));
876
+ if (probe.interval !== void 0) args.push("--interval-ms", String(valueIn(probe.interval, "ms")));
877
+ if (probe.driver !== void 0) {
878
+ args.push("--keepalive-ms", String(valueIn(probe.driver.keepAlive, "ms")));
879
+ args.push("--max-s", String(Math.ceil(longest * 2 + 10)));
880
+ return args;
881
+ }
882
+ if (probe.keepalive) args.push("--keepalive");
883
+ if (probe.userTimeout !== void 0) args.push("--user-timeout-ms", String(valueIn(probe.userTimeout, "ms")));
884
+ args.push("--max-s", String(Math.ceil(longest * 2 + 10)));
885
+ return args;
886
+ };
887
+ /**
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.
892
+ */
893
+ const serverError = (lab, local, within) => new Promise((resolve) => {
894
+ const started = Date.now();
895
+ 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();
897
+ else setTimeout(look, 50);
898
+ };
899
+ look();
900
+ });
901
+ /**
902
+ * This function waits for the request that an in-flight client sends on the signal: its second
903
+ * send, after the PING that made it ready. Its stamp is when the request first met the fault.
904
+ */
905
+ const requestSent = (probe, within) => new Promise((resolve, reject) => {
906
+ const started = Date.now();
907
+ const look = () => {
908
+ const sent = probe.events.filter((event) => event.ev === "sent")[1];
909
+ if (sent !== void 0) resolve(sent);
910
+ else if (Date.now() - started > within) reject(/* @__PURE__ */ new Error(`the client sent no request within ${within} ms of being told to`));
911
+ else setTimeout(look, 1);
912
+ };
913
+ look();
914
+ });
915
+ /**
916
+ * This function does one run. The client connects and gets its reply, the fault occurs, and an
917
+ * in-flight client sends into it. The harness then waits for the observer to see the fault, or for
918
+ * the silent window to end. The observer is the client, or the socket of the target for a cell on
919
+ * the server side.
920
+ *
921
+ * A stall stops by itself, at its length after the send of the request, and the
922
+ * run waits for the reply. Then the harness removes the fault, thus the environment can serve the
923
+ * next run. The run keeps the records of the prober from its start to its end. The frozen process
924
+ * is the peer of the observer: the target, or the client for the server.
925
+ */
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) => {
932
+ await lab.forget();
933
+ const from = lab.kernel.length;
934
+ const lostBefore = droppedSoFar(lab.kernel);
935
+ const probe = await lab.probe(probeArgs(cell, lab));
936
+ const server = cell.observer === "server";
937
+ const peer = server ? {
938
+ freeze: () => probe.freeze(),
939
+ thaw: () => probe.thaw()
940
+ } : {
941
+ freeze: () => lab.freeze(),
942
+ thaw: () => lab.thaw()
943
+ };
944
+ const go = () => probe.go().catch((error) => {
945
+ const last = probe.events.slice(-3).map((event) => JSON.stringify(event));
946
+ throw new Error(`${error.message}; the client's last lines: ${last.join(" ")}`);
947
+ });
948
+ let fault;
949
+ let heal = null;
950
+ try {
951
+ 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);
978
+ }
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;
992
+ }
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();
1029
+ }
1030
+ if (cell.fault === "freeze-process" && !server) await probe.until(["data"], 5e3).catch(() => void 0);
1031
+ } finally {
1032
+ await probe.stop();
1033
+ }
1034
+ const settled = await lab.settle();
1035
+ const lost = settled === null ? null : settled - lostBefore;
1036
+ return {
1037
+ environment,
1038
+ index,
1039
+ events: [...probe.events],
1040
+ fault,
1041
+ ...cell.fault === "stall" ? { heal } : {},
1042
+ kernel: lab.kernel.slice(from),
1043
+ netns: lab.netns.client,
1044
+ targetNetns: lab.netns.target,
1045
+ lost
1046
+ };
1047
+ };
1048
+ /** This function divides a count of runs across environments, as equally as possible. */
1049
+ 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. */
1051
+ const inEnvironment = async (cell, environment, count, say) => {
1052
+ const lab = await openLab(cell.target, cell.probe.driver?.name ?? "tcp");
1053
+ try {
1054
+ const settings = await lab.configure(cell.targetSettings ?? {});
1055
+ const targetSysctls = await lab.targetSysctl(cell.targetSysctls ?? {});
1056
+ if (cell.targetSetup !== void 0) await lab.setup(cell.targetSetup);
1057
+ if (cell.fault === "mtu-blackhole" || cell.fault === "netem") {
1058
+ await lab.offload(false, "target");
1059
+ await lab.offload(false, "client");
1060
+ }
1061
+ const latency = netemSpec(cell, void 0);
1062
+ if (latency !== null) await lab.netem(latency, "target");
1063
+ await lab.warm();
1064
+ const manifest = await lab.manifest(await lab.sysctl(cell.sysctls), settings, targetSysctls);
1065
+ const runs = [];
1066
+ for (let index = 0; index < count; index += 1) {
1067
+ const run = await once(lab, cell, environment, index);
1068
+ 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})`}`);
1071
+ }
1072
+ return {
1073
+ manifest,
1074
+ runs
1075
+ };
1076
+ } finally {
1077
+ await lab.close();
1078
+ }
1079
+ };
1080
+ /**
1081
+ * This function does the runs of a cell. They go across new environments, and each environment records
1082
+ * its manifest. Then the verdict judges the runs. A median across the edge of the band extends
1083
+ * the cell from 11 runs to 22, and then to 44, each extension in new environments. With a
1084
+ * concurrency of more than one, that many environments operate at the same time, each with its
1085
+ * own prober. The runs then go across at least as many environments.
1086
+ */
1087
+ const runCell = async (cell, say = () => {}) => {
1088
+ const started = (/* @__PURE__ */ new Date()).toISOString();
1089
+ const manifests = [];
1090
+ const runs = [];
1091
+ const concurrency = Math.max(1, cell.concurrency ?? 1);
1092
+ let target = cell.runs;
1093
+ let next = 0;
1094
+ for (;;) {
1095
+ const counts = spread(target - runs.length, Math.max(cell.environments, concurrency));
1096
+ for (let at = 0; at < counts.length; at += concurrency) {
1097
+ const batch = counts.slice(at, at + concurrency).map((count) => ({
1098
+ count,
1099
+ env: next++
1100
+ }));
1101
+ const done = await Promise.all(batch.map(({ count, env }) => inEnvironment(cell, env, count, say)));
1102
+ for (const each of done) {
1103
+ manifests.push(each.manifest);
1104
+ runs.push(...each.runs);
1105
+ }
1106
+ }
1107
+ const { measured, verdict } = judge(cell, runs);
1108
+ const larger = STEPS.find((step) => step > runs.length);
1109
+ if (verdict.decision !== "extend" || larger === void 0) return {
1110
+ cell,
1111
+ manifests,
1112
+ runs,
1113
+ measured,
1114
+ verdict,
1115
+ at: {
1116
+ started,
1117
+ ended: (/* @__PURE__ */ new Date()).toISOString()
1118
+ }
1119
+ };
1120
+ target = larger;
1121
+ }
1122
+ };
1123
+ //#endregion
1124
+ export { NOISE, RELATIVE, STEPS, harnessImage, judge, measure, medianInterval, nodeClientImage, openLab, runCell, spread, verdictOf };
1125
+
1126
+ //# sourceMappingURL=index.js.map