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