@mark1russell7/harness 0.11.16 → 0.11.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/schema.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Y as Subject, i as ClientImage, r as Client, v as ImageSource } from "./cell-B6YHtKFb.js";
1
+ import { Q as Subject, i as ClientImage, r as Client, x as ImageSource } from "./cell-BBPhdtav.js";
2
2
  import { z } from "zod";
3
3
  //#region src/schema.d.ts
4
4
  /**
package/build/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { S as subjectProblems, a as SHIPPED_NAMES, d as imageProblems, t as CLIENT_NAMES } from "./image-RM60Paqe.js";
1
+ import { C as subjectProblems, a as SHIPPED_NAMES, f as imageProblems, t as CLIENT_NAMES } from "./image-67OW9r9u.js";
2
2
  import { z } from "zod";
3
3
  //#region src/schema.ts
4
4
  /** This function adds each problem that a check finds as an issue of a schema. */
@@ -1,6 +1,6 @@
1
1
  # The harness's RabbitMQ client: amqplib 2.2.0 on Node 24.21.0. It runs as a container in the client's
2
2
  # network namespace, as the harness's own TCP client does, and prints the same JSON lines, with the
3
- # events of the connection beside them.
3
+ # events of the connection beside them. The client of a cluster cell and the admin of its lab are here too.
4
4
  # lab.ts stages the build context: this folder, under harness/image/amqp. The lockfile is
5
5
  # npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
6
6
  FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
@@ -8,4 +8,4 @@ WORKDIR /opt/harness/amqp
8
8
  COPY harness/image/amqp/package.json ./
9
9
  COPY harness/image/amqp/npm-lock.json ./package-lock.json
10
10
  RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
11
- COPY harness/image/amqp/client-amqp.mjs ./
11
+ COPY harness/image/amqp/client-amqp.mjs harness/image/amqp/client-cluster.mjs harness/image/amqp/admin-cluster.mjs ./
@@ -0,0 +1,38 @@
1
+ // admin-cluster HOST:PORT declare|holds QUEUE
2
+ //
3
+ // The admin of the lab of a cluster cell of RabbitMQ: amqplib 2.2.0, as the user guest. It prints one
4
+ // JSON line with its result.
5
+ // - declare: declares the quorum queue on the node HOST, durable. Its leader locator is client-local,
6
+ // thus the member on HOST is the first leader. It prints { ok: true }.
7
+ // - holds: takes each message of the queue, and prints { held: [n, ...] }: the sequence numbers that
8
+ // the queue holds, in their order. The queue is empty after it.
9
+
10
+ const [at, command, queue] = process.argv.slice(2);
11
+ const print = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
12
+ if (at === undefined || queue === undefined || !["declare", "holds"].includes(command)) {
13
+ print({ ok: false, why: "admin-cluster HOST:PORT declare|holds QUEUE" });
14
+ process.exit(1);
15
+ }
16
+
17
+ const { default: amqp } = await import("amqplib");
18
+ const connection = await amqp.connect(`amqp://guest:guest@${at}/`);
19
+ const channel = await connection.createChannel();
20
+ try {
21
+ if (command === "declare") {
22
+ await channel.assertQueue(queue, { durable: true, arguments: { "x-queue-type": "quorum", "x-queue-leader-locator": "client-local" } });
23
+ print({ ok: true });
24
+ } else {
25
+ const held = [];
26
+ for (;;) {
27
+ const message = await channel.get(queue, { noAck: true });
28
+ if (message === false) break;
29
+ held.push(Number(message.content.toString()));
30
+ }
31
+ print({ held });
32
+ }
33
+ } catch (error) {
34
+ print({ ok: false, why: error.message });
35
+ process.exitCode = 1;
36
+ } finally {
37
+ await connection.close().catch(() => undefined);
38
+ }
@@ -0,0 +1,188 @@
1
+ // client-cluster HOSTS --queue NAME [--interval-ms N] [--max-s N] [--reconnect-ms N] [--options JSON]
2
+ //
3
+ // The client of a cluster cell of RabbitMQ: amqplib 2.2.0 on Node 24.21.0, as an application publishes
4
+ // with it. It publishes a stream of sequence numbers to one quorum queue on a confirm channel, one at
5
+ // a time, --interval-ms after the confirm of the one before. Each event is a JSON line with `ev` and
6
+ // `t`, the time on CLOCK_MONOTONIC in nanoseconds (process.hrtime, which libuv reads from it). The
7
+ // prober stamps the records of the kernel on the same clock, and the injector stamps each fault on it.
8
+ //
9
+ // HOSTS is host:port,host:port,... in the sequence of the connects: the first is the node of the
10
+ // client. amqplib connects to one node and does not connect again by itself. When the connection ends,
11
+ // this client connects to the next node of the list after --reconnect-ms (1000 ms), as an application
12
+ // does. Then it publishes again the sequence number that has no confirm.
13
+ //
14
+ // The options (--options JSON): `heartbeat` in seconds goes in the URL. Without it, amqplib takes the
15
+ // heartbeat that the broker proposes. `user` and `password` are the user of the broker: guest, as
16
+ // amqplib gives it, when the options name none.
17
+ //
18
+ // The lines:
19
+ // - start: the versions of amqplib and Node, the hosts and the options. clocks: the wall clock.
20
+ // - connecting: each connect, with its node. socket, socket-end, socket-error: the socket of each
21
+ // connection, with its local address and its node. connected: that socket.
22
+ // - connection, channel: each event of the connection (error, close, blocked, unblocked) and of the
23
+ // channel (error, close), with the error as data.
24
+ // - ready: the first channel is open, and the stream starts.
25
+ // - sent, ack, fail: each publish by its sequence number and the node that it went through. An ack is
26
+ // the confirm (basic.ack). A fail is a nack, or a channel that closed before the confirm.
27
+ // - done, deadline: the end of the client.
28
+
29
+ import { readFileSync } from "node:fs";
30
+
31
+ const now = () => process.hrtime.bigint();
32
+ const emit = (ev, fields = {}, at = now()) => process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
33
+
34
+ const argv = process.argv.slice(2);
35
+ const flag = (name) => {
36
+ const at = argv.indexOf(name);
37
+ return at === -1 ? undefined : argv[at + 1];
38
+ };
39
+ const hosts = (argv[0] ?? "").split(",").filter((each) => each !== "");
40
+ const queue = flag("--queue");
41
+ if (hosts.length === 0 || queue === undefined) {
42
+ emit("fail", { why: "client-cluster HOST:PORT,... --queue NAME" });
43
+ process.exit(1);
44
+ }
45
+ const interval = Number(flag("--interval-ms") ?? 50);
46
+ const max = Number(flag("--max-s") ?? 120) * 1000;
47
+ const reconnect = Number(flag("--reconnect-ms") ?? 1000);
48
+ const options = JSON.parse(flag("--options") ?? "{}");
49
+ const { heartbeat, user = "guest", password = "guest" } = options;
50
+
51
+ const { default: amqp } = await import("amqplib");
52
+ const versionOf = (name) => JSON.parse(readFileSync(new URL(`./node_modules/${name}/package.json`, import.meta.url), "utf8")).version;
53
+ const bootId = () => {
54
+ try {
55
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+ emit("start", { boot_id: bootId(), client: "amqp-cluster", driver: versionOf("amqplib"), node: process.versions.node, hosts, queue, options });
61
+ // the wall clock beside the monotonic one: the run puts the log lines of the nodes on the monotonic clock
62
+ const clocks = () => emit("clocks", { real: Date.now() });
63
+ clocks();
64
+ setInterval(clocks, 1000).unref();
65
+
66
+ const urlOf = (at) => `amqp://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${at}/${heartbeat === undefined ? "" : `?heartbeat=${heartbeat}`}`;
67
+ const failure = (error) => (error === undefined || error === null ? null : { name: error.name, code: error.code ?? null, message: error.message });
68
+ const errnoOf = (error) => error?.code ?? error?.message ?? null;
69
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
70
+
71
+ let stopped = false;
72
+ let connection = null;
73
+ let channel = null;
74
+ // the node of the connection that is open now, and the place of the next connect in the list
75
+ let at = null;
76
+ let next = 0;
77
+ // this promise ends when the connection that is open now ends
78
+ let ended = null;
79
+
80
+ /** This function connects to the next node of the list and opens a confirm channel. It gives false when the connect fails. */
81
+ const open = async () => {
82
+ const node = hosts[next % hosts.length];
83
+ next += 1;
84
+ emit("connecting", { at: node });
85
+ try {
86
+ connection = await amqp.connect(urlOf(node));
87
+ } catch (error) {
88
+ emit("connection", { event: "connect-failed", at: node, errno: errnoOf(error), failure: failure(error) });
89
+ return false;
90
+ }
91
+ const socket = connection.connection.stream;
92
+ const local = `${socket.localAddress}:${socket.localPort}`;
93
+ emit("socket", { role: "connection", local, server: node });
94
+ emit("connected", { local, at: node });
95
+ socket.once("close", (hadError) => emit("socket-end", { role: "connection", local, server: node, error: hadError }));
96
+ socket.once("error", (error) => emit("socket-error", { role: "connection", local, server: node, errno: errnoOf(error), message: error.message }));
97
+ let finish;
98
+ ended = new Promise((resolve) => (finish = resolve));
99
+ // amqplib emits each error once, then its close. Without a listener, Node throws the error
100
+ connection.on("error", (error) => emit("connection", { event: "error", at: node, failure: failure(error) }));
101
+ // a close of the broker with 320 (CONNECTION_FORCED) is not an error for amqplib: only its close tells
102
+ connection.on("close", (error) => {
103
+ emit("connection", { event: "close", at: node, failure: failure(error) });
104
+ finish();
105
+ });
106
+ connection.on("blocked", (reason) => emit("connection", { event: "blocked", at: node, reason }));
107
+ connection.on("unblocked", () => emit("connection", { event: "unblocked", at: node }));
108
+ try {
109
+ channel = await connection.createConfirmChannel();
110
+ } catch (error) {
111
+ emit("channel", { event: "open-failed", at: node, failure: failure(error) });
112
+ return false;
113
+ }
114
+ channel.on("error", (error) => emit("channel", { event: "error", at: node, failure: failure(error) }));
115
+ channel.on("close", () => emit("channel", { event: "close", at: node }));
116
+ at = node;
117
+ return true;
118
+ };
119
+
120
+ /** This function publishes n and waits for its confirm. It gives null for a confirm, or the error. */
121
+ const publish = (n) =>
122
+ new Promise((resolve) => {
123
+ const through = at;
124
+ emit("sent", { n, at: through });
125
+ try {
126
+ channel.sendToQueue(queue, Buffer.from(String(n)), { persistent: true }, (error) => {
127
+ if (error === null || error === undefined) {
128
+ emit("ack", { n, at: through });
129
+ resolve(null);
130
+ } else {
131
+ emit("fail", { n, at: through, ...failure(error) });
132
+ resolve(error);
133
+ }
134
+ });
135
+ } catch (error) {
136
+ emit("fail", { n, at: through, ...failure(error) });
137
+ resolve(error);
138
+ }
139
+ });
140
+
141
+ const stop = (code = 0) => {
142
+ if (stopped) return;
143
+ stopped = true;
144
+ emit("done");
145
+ const closing = connection === null ? Promise.resolve() : connection.close().catch(() => undefined);
146
+ // a dead connection does not answer its close: the client does not wait for it
147
+ Promise.race([closing, new Promise((resolve) => setTimeout(resolve, 1000))]).finally(() => setTimeout(() => process.exit(code), 50));
148
+ };
149
+ process.on("SIGTERM", () => stop(0));
150
+ setTimeout(() => {
151
+ emit("deadline");
152
+ stop(0);
153
+ }, max);
154
+
155
+ let ready = false;
156
+ let n = 1;
157
+ while (!stopped) {
158
+ if (channel === null) {
159
+ if (!(await open())) {
160
+ channel = null;
161
+ await sleep(reconnect);
162
+ continue;
163
+ }
164
+ if (!ready) {
165
+ ready = true;
166
+ emit("ready", { at });
167
+ }
168
+ }
169
+ // a publish ends at its confirm, at a nack, or at the close of its channel
170
+ const failed = await publish(n);
171
+ if (failed === null) {
172
+ n += 1;
173
+ await sleep(interval);
174
+ continue;
175
+ }
176
+ // the same sequence number goes again: on the same channel after a nack, or on the next node after an end
177
+ if (failed.message === "message nacked") {
178
+ await sleep(interval);
179
+ continue;
180
+ }
181
+ // the channel closed: its connection ends with it, or after one more wait the client ends the connection
182
+ const closing = connection;
183
+ await Promise.race([ended, sleep(reconnect)]);
184
+ closing?.close().catch(() => undefined);
185
+ channel = null;
186
+ connection = null;
187
+ if (!stopped) await sleep(reconnect);
188
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@mark1russell7/harness",
4
- "version": "0.11.16",
4
+ "version": "0.11.17",
5
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. The WebSocket client of @procedures/client drops a silent connection on its own heartbeat, and the harness times that detection and the reconnect from the program's lines and the kernel's stamps.",
6
6
  "keywords": [
7
7
  "fault-injection",
@@ -74,6 +74,8 @@
74
74
  "image/hikari/jars.sha256",
75
75
  "image/amqp/Dockerfile",
76
76
  "image/amqp/client-amqp.mjs",
77
+ "image/amqp/client-cluster.mjs",
78
+ "image/amqp/admin-cluster.mjs",
77
79
  "image/amqp/package.json",
78
80
  "image/amqp/npm-lock.json",
79
81
  "image/procedures/Dockerfile",