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