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