@mark1russell7/harness 0.5.0 → 0.7.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.
@@ -0,0 +1,163 @@
1
+ {
2
+ "name": "loom-harness-mongo-client",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "loom-harness-mongo-client",
8
+ "dependencies": {
9
+ "mongodb": "7.7.0"
10
+ }
11
+ },
12
+ "node_modules/@mongodb-js/saslprep": {
13
+ "version": "1.6.1",
14
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.1.tgz",
15
+ "integrity": "sha512-D4CQtk9IwjCLAvCf1xvHEod3960q8XMUzfmdRU9qjUNlHnesGuTOvJcOTaPm9koZ1caUKA2aTbszZiAkcmSgmg==",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "sparse-bitfield": "^3.0.3"
19
+ }
20
+ },
21
+ "node_modules/@types/webidl-conversions": {
22
+ "version": "7.0.3",
23
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
24
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
25
+ "license": "MIT"
26
+ },
27
+ "node_modules/@types/whatwg-url": {
28
+ "version": "13.0.0",
29
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
30
+ "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@types/webidl-conversions": "*"
34
+ }
35
+ },
36
+ "node_modules/bson": {
37
+ "version": "7.3.3",
38
+ "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.3.tgz",
39
+ "integrity": "sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==",
40
+ "license": "Apache-2.0",
41
+ "engines": {
42
+ "node": ">=20.19.0"
43
+ }
44
+ },
45
+ "node_modules/memory-pager": {
46
+ "version": "1.5.0",
47
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
48
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
49
+ "license": "MIT"
50
+ },
51
+ "node_modules/mongodb": {
52
+ "version": "7.7.0",
53
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.7.0.tgz",
54
+ "integrity": "sha512-XG1xXEqyouAV8GCb3PuRaujofeXKpAJAPvXYWZgHl6Dz4QHSJ1nRLl0+GpqaLTwKvC0RFE9truOur34RNFY1nw==",
55
+ "license": "Apache-2.0",
56
+ "dependencies": {
57
+ "@mongodb-js/saslprep": "^1.5.0",
58
+ "bson": "^7.3.2",
59
+ "mongodb-connection-string-url": "^7.0.2"
60
+ },
61
+ "engines": {
62
+ "node": ">=20.19.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@aws-sdk/credential-providers": "^3.806.0",
66
+ "@mongodb-js/zstd": "^7.0.0",
67
+ "gcp-metadata": "^7.0.1",
68
+ "kerberos": "^7.0.0",
69
+ "mongodb-client-encryption": "^7.2.0",
70
+ "snappy": "^7.3.2",
71
+ "socks": "^2.8.6"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@aws-sdk/credential-providers": {
75
+ "optional": true
76
+ },
77
+ "@mongodb-js/zstd": {
78
+ "optional": true
79
+ },
80
+ "gcp-metadata": {
81
+ "optional": true
82
+ },
83
+ "kerberos": {
84
+ "optional": true
85
+ },
86
+ "mongodb-client-encryption": {
87
+ "optional": true
88
+ },
89
+ "snappy": {
90
+ "optional": true
91
+ },
92
+ "socks": {
93
+ "optional": true
94
+ }
95
+ }
96
+ },
97
+ "node_modules/mongodb-connection-string-url": {
98
+ "version": "7.0.2",
99
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz",
100
+ "integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==",
101
+ "license": "Apache-2.0",
102
+ "dependencies": {
103
+ "@types/whatwg-url": "^13.0.0",
104
+ "whatwg-url": "^14.1.0"
105
+ },
106
+ "engines": {
107
+ "node": ">=20.19.0"
108
+ }
109
+ },
110
+ "node_modules/punycode": {
111
+ "version": "2.3.1",
112
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
113
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
114
+ "license": "MIT",
115
+ "engines": {
116
+ "node": ">=6"
117
+ }
118
+ },
119
+ "node_modules/sparse-bitfield": {
120
+ "version": "3.0.3",
121
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
122
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
123
+ "license": "MIT",
124
+ "dependencies": {
125
+ "memory-pager": "^1.0.2"
126
+ }
127
+ },
128
+ "node_modules/tr46": {
129
+ "version": "5.1.1",
130
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
131
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
132
+ "license": "MIT",
133
+ "dependencies": {
134
+ "punycode": "^2.3.1"
135
+ },
136
+ "engines": {
137
+ "node": ">=18"
138
+ }
139
+ },
140
+ "node_modules/webidl-conversions": {
141
+ "version": "7.0.0",
142
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
143
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
144
+ "license": "BSD-2-Clause",
145
+ "engines": {
146
+ "node": ">=12"
147
+ }
148
+ },
149
+ "node_modules/whatwg-url": {
150
+ "version": "14.2.0",
151
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
152
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
153
+ "license": "MIT",
154
+ "dependencies": {
155
+ "tr46": "^5.1.0",
156
+ "webidl-conversions": "^7.0.0"
157
+ },
158
+ "engines": {
159
+ "node": ">=18"
160
+ }
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "loom-harness-mongo-client",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "mongodb": "7.7.0"
7
+ }
8
+ }
@@ -0,0 +1,37 @@
1
+ // stepdown-mongo HOST:PORT STEP_DOWN_SECS CATCH_UP_SECS
2
+ //
3
+ // A planned step-down of a replica set: replSetStepDown to its primary, as a client sends it. The
4
+ // script connects to the member and pings it first. Then it stamps t0 on CLOCK_MONOTONIC and sends
5
+ // the command. It stamps t1 on the next turn of the event loop after the driver starts the command,
6
+ // when the driver wrote it. Its last line is the stamps, as the injector prints them.
7
+
8
+ import { MongoClient } from "mongodb";
9
+
10
+ const now = () => Number(process.hrtime.bigint());
11
+ const [address, stepDownSecs = "60", catchUpSecs = "10"] = process.argv.slice(2);
12
+ const client = new MongoClient(`mongodb://${address}/?directConnection=true`, { serverSelectionTimeoutMS: 10000, monitorCommands: true });
13
+ let written = null;
14
+ const sent = new Promise((resolve) => {
15
+ client.on("commandStarted", (event) => {
16
+ if (event.commandName === "replSetStepDown") setImmediate(() => resolve(now()));
17
+ });
18
+ });
19
+ try {
20
+ await client.connect();
21
+ const admin = client.db("admin");
22
+ await admin.command({ ping: 1 });
23
+ const t0 = now();
24
+ const reply = admin.command({ replSetStepDown: Number(stepDownSecs), secondaryCatchUpPeriodSecs: Number(catchUpSecs) });
25
+ written = await sent;
26
+ let ok = false;
27
+ let error = null;
28
+ try {
29
+ ok = (await reply).ok === 1;
30
+ } catch (failure) {
31
+ // a primary that steps down closes its connections: the reply can be a network error
32
+ error = failure.message;
33
+ }
34
+ process.stdout.write(`${JSON.stringify({ action: "step-down", t0, t1: written, replied: now(), ok, error })}\n`);
35
+ } finally {
36
+ await client.close().catch(() => undefined);
37
+ }
@@ -0,0 +1,12 @@
1
+ # The Node HTTP clients of the harness: the http.Agent of Node 24.21.0, and an HTTP/1.1 Client of
2
+ # undici 8.12.2. Each runs as a container in the client's network namespace, as the harness's own
3
+ # TCP client does, and prints the same JSON lines. The digest of the image and an npm lockfile pin
4
+ # them. The image is apart from the ioredis image, thus the cells of ioredis keep their image.
5
+ # lab.ts stages the build context: this folder, under harness/image/node-http. The lockfile is
6
+ # npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
7
+ FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
8
+ WORKDIR /opt/harness/node-http
9
+ COPY harness/image/node-http/package.json ./
10
+ COPY harness/image/node-http/npm-lock.json ./package-lock.json
11
+ RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
12
+ COPY harness/image/node-http/lines.mjs harness/image/node-http/client-agent.mjs harness/image/node-http/client-undici.mjs ./
@@ -0,0 +1,163 @@
1
+ // client-agent HOST PORT --mode idle|inflight|gap [--gap-ms N] [--method GET|POST] [--hold-ms N]
2
+ // [--bytes N] [--go PATH] [--max-s N] [--linger-ms N]
3
+ // [--timeout-ms N] [--keepalive-msecs N] [--buffer-ms N]
4
+ //
5
+ // The client of the HTTP cells that uses the http.Agent of Node 24.21.0. Without an agent flag it
6
+ // uses http.globalAgent: keep-alive on, a free socket timeout of 5 s, LIFO
7
+ // (lib/internal/http.js:263-265). With a flag, it uses a new Agent with the same options and the
8
+ // flag. Each event is a JSON line, as the TCP client of the harness writes them.
9
+ //
10
+ // The first request is GET /, and the end of its reply is the `ready` line. Then the client holds
11
+ // the connection idle (idle), sends the second request when the file --go appears (inflight), or
12
+ // sends it after the gap (gap). For each request it writes the connection that it went on (the
13
+ // local address and port), whether the agent reused the socket, and the outcome. For each socket it
14
+ // writes the connect, the end of file, the timeout of the agent, and the close.
15
+
16
+ import http from "node:http";
17
+ import { bootId, commandLine, emit, now, pathOf, sleep, waitFor } from "./lines.mjs";
18
+
19
+ const usage = "client-agent HOST PORT --mode idle|inflight|gap";
20
+ const cli = commandLine(usage);
21
+ const mode = cli.flag("--mode");
22
+ if (mode !== "idle" && mode !== "inflight" && mode !== "gap") {
23
+ emit("fail", { why: usage });
24
+ process.exit(1);
25
+ }
26
+ const method = cli.flag("--method") ?? "GET";
27
+ const second = pathOf({ hold: cli.number("--hold-ms"), bytes: cli.number("--bytes") });
28
+ const max = cli.number("--max-s", 60) * 1000;
29
+ const linger = cli.number("--linger-ms", 500);
30
+
31
+ // the options of http.globalAgent, and each flag over them
32
+ const custom = cli.has("--timeout-ms") || cli.has("--keepalive-msecs") || cli.has("--buffer-ms");
33
+ const options = { keepAlive: true, scheduling: "lifo", timeout: 5000 };
34
+ if (cli.has("--timeout-ms")) options.timeout = cli.number("--timeout-ms");
35
+ if (cli.has("--keepalive-msecs")) options.keepAliveMsecs = cli.number("--keepalive-msecs");
36
+ if (cli.has("--buffer-ms")) options.agentKeepAliveTimeoutBuffer = cli.number("--buffer-ms");
37
+ const agent = custom ? new http.Agent(options) : http.globalAgent;
38
+
39
+ emit("start", {
40
+ boot_id: bootId(),
41
+ mode,
42
+ client: "node-http-agent",
43
+ node: process.version,
44
+ agent: {
45
+ global: !custom,
46
+ keepAlive: agent.keepAlive,
47
+ timeout: agent.options.timeout ?? 0,
48
+ keepAliveMsecs: agent.keepAliveMsecs,
49
+ agentKeepAliveTimeoutBuffer: agent.agentKeepAliveTimeoutBuffer,
50
+ scheduling: agent.scheduling,
51
+ },
52
+ });
53
+
54
+ setTimeout(() => {
55
+ emit("deadline");
56
+ process.exit(0);
57
+ }, max);
58
+
59
+ // the sockets of the agent, each with its number, its local address, and what happened to it
60
+ const sockets = new Map();
61
+ const isFree = (socket) => Object.values(agent.freeSockets).some((list) => list.includes(socket));
62
+
63
+ /** This function follows a socket of the agent, one time. */
64
+ const track = (socket) => {
65
+ const known = sockets.get(socket);
66
+ if (known !== undefined) return known;
67
+ const entry = { n: sockets.size + 1, local: null, timedOut: false, pooled: false };
68
+ sockets.set(socket, entry);
69
+ const connected = () => {
70
+ entry.local = `${socket.localAddress}:${socket.localPort}`;
71
+ emit("conn", { n: entry.n, local: entry.local });
72
+ if (entry.n === 1) emit("connected", { local: entry.local });
73
+ };
74
+ if (socket.connecting) socket.once("connect", connected);
75
+ else connected();
76
+ // the listeners of the agent come first: after its free handler, the socket is in the pool or destroyed
77
+ socket.on("free", () => {
78
+ entry.pooled = isFree(socket);
79
+ emit(entry.pooled ? "pooled" : "not-pooled", { local: entry.local });
80
+ });
81
+ socket.on("timeout", () => {
82
+ entry.timedOut = entry.pooled;
83
+ });
84
+ socket.on("end", () => emit("eof", { local: entry.local }));
85
+ socket.on("error", (error) => emit("conn-error", { local: entry.local, errno: error.code ?? "ERROR", message: error.message }));
86
+ socket.on("close", (hadError) => {
87
+ // the agent destroys a free socket on its own timeout, or on any read: an end of file or a
88
+ // reset from the peer (lib/_http_agent.js:101-112). The guard hides the end event
89
+ const by = entry.timedOut ? "client-timer" : hadError ? "error" : entry.pooled ? "peer" : "client";
90
+ if (by === "peer") emit("eof", { local: entry.local, free: true });
91
+ emit("closed", { local: entry.local, by });
92
+ });
93
+ return entry;
94
+ };
95
+
96
+ /** This function sends request `i` and waits for its outcome. */
97
+ const request = (i, how, path) =>
98
+ new Promise((resolve) => {
99
+ const at = now();
100
+ const req = http.request({
101
+ host: cli.host,
102
+ port: cli.port,
103
+ method: how,
104
+ path,
105
+ agent,
106
+ headers: how === "POST" ? { "content-length": "1" } : {},
107
+ });
108
+ emit("sent", { i }, at);
109
+ let ended = false;
110
+ const end = (ok) => {
111
+ if (ended) return;
112
+ ended = true;
113
+ resolve(ok);
114
+ };
115
+ req.on("socket", (socket) => {
116
+ const entry = track(socket);
117
+ entry.pooled = false;
118
+ entry.timedOut = false;
119
+ const show = () => emit("request", { i, local: entry.local, reused: req.reusedSocket });
120
+ if (socket.connecting) socket.once("connect", show);
121
+ else show();
122
+ });
123
+ req.on("response", (res) => {
124
+ emit("response", { i, status: res.statusCode, local: `${res.socket.localAddress}:${res.socket.localPort}` });
125
+ let n = 0;
126
+ res.on("data", (chunk) => {
127
+ n += chunk.length;
128
+ if (i > 1) emit("data", { i, n: chunk.length });
129
+ });
130
+ res.on("end", () => {
131
+ if (i === 1) emit("ready", { reply: `HTTP ${res.statusCode}` });
132
+ else emit("replied", { i, n: { value: n, unit: "B" } });
133
+ end(true);
134
+ });
135
+ res.on("error", (error) => {
136
+ emit("error", { i, errno: error.code ?? "ERROR", message: error.message });
137
+ end(false);
138
+ });
139
+ });
140
+ req.on("error", (error) => {
141
+ emit("error", { i, errno: error.code ?? "ERROR", message: error.message });
142
+ end(false);
143
+ });
144
+ req.end(how === "POST" ? "x" : undefined);
145
+ });
146
+
147
+ const finish = async () => {
148
+ emit("done", { i: 2 });
149
+ await sleep(linger);
150
+ process.exit(0);
151
+ };
152
+
153
+ if (!(await request(1, "GET", "/"))) process.exit(1);
154
+ if (mode === "inflight") {
155
+ await waitFor(cli.flag("--go") ?? "/tmp/go");
156
+ await request(2, method, second);
157
+ await finish();
158
+ } else if (mode === "gap") {
159
+ await sleep(cli.number("--gap-ms", 0));
160
+ await request(2, method, second);
161
+ await finish();
162
+ }
163
+ // idle: the connection stays in the pool until it ends, or until the deadline
@@ -0,0 +1,141 @@
1
+ // client-undici HOST PORT --mode idle|inflight|gap [--gap-ms N] [--method GET|POST] [--hold-ms N]
2
+ // [--bytes N] [--go PATH] [--max-s N] [--linger-ms N]
3
+ // [--keepalive-timeout-ms N] [--keepalive-max-timeout-ms N] [--keepalive-threshold-ms N]
4
+ // [--headers-timeout-ms N] [--body-timeout-ms N] [--connect-timeout-ms N]
5
+ // [--keepalive-initial-delay-ms N]
6
+ //
7
+ // The client of the HTTP cells that uses an HTTP/1.1 Client of undici 8.12.2, at its defaults
8
+ // with each flag over them. Each event is a JSON line, as the TCP client of the harness writes them.
9
+ //
10
+ // The workload is the workload of client-agent.mjs. A connector around the connector of undici
11
+ // follows each socket. The channel undici:client:sendHeaders tells which socket each request goes
12
+ // on. The disconnect event of the client tells why a socket ended: an end of file (other side
13
+ // closed), a reset, or the keep-alive timer of the client (socket idle timeout).
14
+
15
+ import diagnostics from "node:diagnostics_channel";
16
+ import { Client, buildConnector } from "undici";
17
+ import { bootId, commandLine, emit, now, pathOf, sleep, waitFor } from "./lines.mjs";
18
+
19
+ const usage = "client-undici HOST PORT --mode idle|inflight|gap";
20
+ const cli = commandLine(usage);
21
+ const mode = cli.flag("--mode");
22
+ if (mode !== "idle" && mode !== "inflight" && mode !== "gap") {
23
+ emit("fail", { why: usage });
24
+ process.exit(1);
25
+ }
26
+ const method = cli.flag("--method") ?? "GET";
27
+ const second = pathOf({ hold: cli.number("--hold-ms"), bytes: cli.number("--bytes") });
28
+ const max = cli.number("--max-s", 60) * 1000;
29
+ const linger = cli.number("--linger-ms", 500);
30
+
31
+ // the options of the client: undici's defaults, and each flag over them
32
+ const options = {};
33
+ const set = (option, name) => {
34
+ if (cli.has(name)) options[option] = cli.number(name);
35
+ };
36
+ set("keepAliveTimeout", "--keepalive-timeout-ms");
37
+ set("keepAliveMaxTimeout", "--keepalive-max-timeout-ms");
38
+ set("keepAliveTimeoutThreshold", "--keepalive-threshold-ms");
39
+ set("headersTimeout", "--headers-timeout-ms");
40
+ set("bodyTimeout", "--body-timeout-ms");
41
+ const connect = {};
42
+ if (cli.has("--connect-timeout-ms")) connect.timeout = cli.number("--connect-timeout-ms");
43
+ if (cli.has("--keepalive-initial-delay-ms")) connect.keepAliveInitialDelay = cli.number("--keepalive-initial-delay-ms");
44
+
45
+ emit("start", { boot_id: bootId(), mode, client: "undici", node: process.version, options, connect });
46
+
47
+ setTimeout(() => {
48
+ emit("deadline");
49
+ process.exit(0);
50
+ }, max);
51
+
52
+ // the sockets of the client, each with its number, its local address, and its use
53
+ const sockets = new Map();
54
+ let current = null;
55
+
56
+ /** This function follows a socket of the client, one time. */
57
+ const track = (socket) => {
58
+ const known = sockets.get(socket);
59
+ if (known !== undefined) return known;
60
+ const entry = { n: sockets.size + 1, local: `${socket.localAddress}:${socket.localPort}`, used: false };
61
+ sockets.set(socket, entry);
62
+ current = entry;
63
+ emit("conn", { n: entry.n, local: entry.local });
64
+ if (entry.n === 1) emit("connected", { local: entry.local });
65
+ socket.on("end", () => emit("eof", { local: entry.local }));
66
+ socket.on("error", (error) =>
67
+ // undici destroys a socket with an error of its own when it closes the socket itself
68
+ OWN.has(error.code)
69
+ ? emit("client-close", { local: entry.local, errno: error.code, message: error.message })
70
+ : emit("conn-error", { local: entry.local, errno: error.code ?? "ERROR", message: error.message }),
71
+ );
72
+ socket.on("close", () => emit("closed", { local: entry.local }));
73
+ return entry;
74
+ };
75
+
76
+ // the errors of undici for a close by the client itself: no reuse, its keep-alive timer, its deadlines
77
+ const OWN = new Set(["UND_ERR_INFO", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_CONNECT_TIMEOUT"]);
78
+
79
+ // the default connector of undici with the connect options, around a function that follows each socket
80
+ const connector = buildConnector(connect);
81
+ let requestIndex = 0;
82
+ const client = new Client(`http://${cli.host}:${cli.port}`, {
83
+ ...options,
84
+ connect: (opts, callback) =>
85
+ connector(opts, (error, socket) => {
86
+ if (error === null && socket !== undefined && socket !== null) track(socket);
87
+ callback(error, socket);
88
+ }),
89
+ });
90
+
91
+ diagnostics.subscribe("undici:client:sendHeaders", ({ socket }) => {
92
+ const entry = track(socket);
93
+ emit("request", { i: requestIndex, local: entry.local, reused: entry.used });
94
+ entry.used = true;
95
+ });
96
+ client.on("disconnect", (_origin, _targets, error) => {
97
+ // UND_ERR_INFO is a close by the client: no reuse (reset), or its keep-alive timer (socket idle timeout)
98
+ const by = OWN.has(error?.code) ? "client" : "peer";
99
+ emit("disconnect", { local: current?.local ?? null, by, errno: error?.code ?? null, message: error?.message ?? null });
100
+ });
101
+
102
+ /** This function sends request `i` and waits for its outcome. */
103
+ const request = async (i, how, path) => {
104
+ requestIndex = i;
105
+ const at = now();
106
+ const pending = client.request({ path, method: how, body: how === "POST" ? "x" : undefined });
107
+ emit("sent", { i }, at);
108
+ try {
109
+ const { statusCode, body } = await pending;
110
+ emit("response", { i, status: statusCode });
111
+ let n = 0;
112
+ for await (const chunk of body) {
113
+ n += chunk.length;
114
+ if (i > 1) emit("data", { i, n: chunk.length });
115
+ }
116
+ if (i === 1) emit("ready", { reply: `HTTP ${statusCode}` });
117
+ else emit("replied", { i, n: { value: n, unit: "B" } });
118
+ return true;
119
+ } catch (error) {
120
+ emit("error", { i, errno: error.code ?? "ERROR", cause: error.cause?.code ?? null, message: error.message });
121
+ return false;
122
+ }
123
+ };
124
+
125
+ const finish = async () => {
126
+ emit("done", { i: 2 });
127
+ await sleep(linger);
128
+ process.exit(0);
129
+ };
130
+
131
+ if (!(await request(1, "GET", "/"))) process.exit(1);
132
+ if (mode === "inflight") {
133
+ await waitFor(cli.flag("--go") ?? "/tmp/go");
134
+ await request(2, method, second);
135
+ await finish();
136
+ } else if (mode === "gap") {
137
+ await sleep(cli.number("--gap-ms", 0));
138
+ await request(2, method, second);
139
+ await finish();
140
+ }
141
+ // idle: the connection stays open until it ends, or until the deadline
@@ -0,0 +1,59 @@
1
+ // The JSON lines of the Node programs of the HTTP cells: the server and its two Node clients.
2
+ //
3
+ // Each line has `ev` and `t`, as the TCP client of the harness writes them. `t` is
4
+ // process.hrtime, which libuv reads from CLOCK_MONOTONIC: the clock that the prober stamps the
5
+ // records of the kernel with. The arguments are the host and the port, then flags with a value.
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+
9
+ /** This function gives the time now, in nanoseconds on CLOCK_MONOTONIC. */
10
+ export const now = () => process.hrtime.bigint();
11
+
12
+ /** This function writes one line, with the time `at` (the time now when none is given). */
13
+ export const emit = (ev, fields = {}, at = now()) =>
14
+ process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
15
+
16
+ /** This function gives the boot id of the kernel, or null where the file does not exist. */
17
+ export const bootId = () => {
18
+ try {
19
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ /** This function reads the command line: the host and the port first, then `--name value` pairs. */
26
+ export const commandLine = (usage) => {
27
+ const argv = process.argv.slice(2);
28
+ const [host, port] = argv;
29
+ const flag = (name) => {
30
+ const at = argv.indexOf(name);
31
+ return at === -1 ? undefined : argv[at + 1];
32
+ };
33
+ const number = (name, otherwise) => {
34
+ const value = flag(name);
35
+ return value === undefined ? otherwise : Number(value);
36
+ };
37
+ if (host === undefined || port === undefined) {
38
+ emit("fail", { why: usage });
39
+ process.exit(1);
40
+ }
41
+ return { host, port: Number(port), flag, number, has: (name) => argv.includes(name) };
42
+ };
43
+
44
+ /** This function waits until the file at `path` exists. It looks each 2 ms, as the ioredis client does. */
45
+ export const waitFor = (path) =>
46
+ new Promise((resolve) => {
47
+ const look = setInterval(() => {
48
+ if (!existsSync(path)) return;
49
+ clearInterval(look);
50
+ resolve();
51
+ }, 2);
52
+ });
53
+
54
+ /** This function waits `ms` milliseconds on a Node timer. */
55
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
56
+
57
+ /** This function gives the path of the second request: a held reply, a long body, or the root. */
58
+ export const pathOf = ({ hold, bytes }) =>
59
+ hold !== undefined ? `/hold?ms=${hold}` : bytes !== undefined ? `/big?bytes=${bytes}` : "/";
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "loom-harness-http-clients",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "loom-harness-http-clients",
8
+ "dependencies": {
9
+ "undici": "8.12.2"
10
+ }
11
+ },
12
+ "node_modules/undici": {
13
+ "version": "8.12.2",
14
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz",
15
+ "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==",
16
+ "license": "MIT",
17
+ "engines": {
18
+ "node": ">=22.19.0"
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "loom-harness-http-clients",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "undici": "8.12.2"
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/harness",
4
- "version": "0.5.0",
5
- "description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, through a topology of routers and proxies or a replicated group under Sentinels, injects a fault, measures the kernel's own records and judges a prediction.",
4
+ "version": "0.7.0",
5
+ "description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, through a topology of routers and proxies, a replicated group under Sentinels or a replica set, injects a fault, measures the kernel's own records and judges a prediction. HTTP clients of Go, Node and undici race the keep-alive of a Node server, and the MongoDB driver meets the faults of a replica set.",
6
6
  "keywords": [
7
7
  "fault-injection",
8
8
  "chaos-engineering",
@@ -47,6 +47,20 @@
47
47
  "image/node/observer.mjs",
48
48
  "image/node/package.json",
49
49
  "image/node/npm-lock.json",
50
+ "image/go-http/Dockerfile",
51
+ "image/go-http/go.mod",
52
+ "image/go-http/main.go",
53
+ "image/node-http/Dockerfile",
54
+ "image/node-http/package.json",
55
+ "image/node-http/npm-lock.json",
56
+ "image/node-http/lines.mjs",
57
+ "image/node-http/client-agent.mjs",
58
+ "image/node-http/client-undici.mjs",
59
+ "image/mongo/Dockerfile",
60
+ "image/mongo/client-mongo.mjs",
61
+ "image/mongo/stepdown-mongo.mjs",
62
+ "image/mongo/package.json",
63
+ "image/mongo/npm-lock.json",
50
64
  "image/router/Dockerfile",
51
65
  "image/router/mbx.sh",
52
66
  "image/router/ctwatch.py",