@mark1russell7/harness 0.7.0 → 0.8.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,13 @@
1
+ # The procedures image of the harness: @procedures/client 0.3.0 and ws 8.22.0 on Node 24.21.0. Its WebSocket
2
+ # client runs as a container in the client's network namespace, as the harness's own TCP client
3
+ # does, and prints the same JSON lines (client-ws.mjs). A target runs the same image with the
4
+ # script of the procedures server (server-ws.mjs), thus the client and the server share one
5
+ # release of the package. The digest of the image and an npm lockfile pin them.
6
+ # lab.ts stages the build context: this folder, under harness/image/procedures. The lockfile is
7
+ # npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
8
+ FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
9
+ WORKDIR /opt/harness/procedures
10
+ COPY harness/image/procedures/package.json ./
11
+ COPY harness/image/procedures/npm-lock.json ./package-lock.json
12
+ RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
13
+ COPY harness/image/procedures/lines.mjs harness/image/procedures/client-ws.mjs harness/image/procedures/server-ws.mjs ./
@@ -0,0 +1,234 @@
1
+ // client-ws HOST PORT --mode idle|inflight --websocket global|ws
2
+ // --heartbeat on|off [--heartbeat-interval-ms N --heartbeat-timeout-ms N --heartbeat-misses N]
3
+ // --reconnect on|off [--reconnect-initial-ms N --reconnect-multiplier X --reconnect-max-ms N
4
+ // --reconnect-attempts N --reconnect-jitter X]
5
+ // --request-timeout-ms N --connection-timeout-ms N [--hold-ms N] [--go PATH] [--max-s N]
6
+ //
7
+ // A cell's client: the WebSocket client of @procedures/client 0.3.0 (WebSocketTransport) on Node
8
+ // 24.21.0. Its sockets are of the global WebSocket of Node, which is undici 7.29.1, or of the
9
+ // WebSocket of the ws package (the option `webSocket`): --websocket. Each setting of the transport
10
+ // is a flag, and the client has no default: a missing flag is a failure. 0.3.0 takes each time as a
11
+ // duration, not a number. Both modes call `harness.echo` first; its reply is `ready`. idle: then
12
+ // hold the connection. inflight: then wait for the file --go, and call `harness.echo` (or
13
+ // `harness.hold` for --hold-ms) into the fault.
14
+ //
15
+ // The lines are those of the harness's TCP client, on CLOCK_MONOTONIC, and the program's own acts:
16
+ // dial {conn} before each new socket of the transport (conn counts from 1)
17
+ // connected {conn, local} the socket opened: its local address
18
+ // ping {conn, local} before each ping of the heartbeat
19
+ // write {conn, type} before each other frame: a hello, a request, a cancel or a credit
20
+ // drop {conn, local} before the transport ends a socket by itself: close() or terminate()
21
+ // reconnecting {attempt} the transport starts its reconnect timer (onReconnecting)
22
+ // status {status} connected, disconnected (with the reason) or error, from the callbacks
23
+ // conn-end {conn, code} the close event of each socket, with the errno of its socket error
24
+ // error {errno} / eof the end of socket 1, the socket of the run, when the transport did not drop it
25
+ // sent / ready / data / call-error each call, its reply, or its failure
26
+ //
27
+ // The client gives the transport a subclass of the class of its sockets. The subclass writes a
28
+ // line, then calls its class: the transport, undici and ws do what they do without it. Its
29
+ // listeners of `open` and `close` are its own, thus the transport cannot remove them. A drop line
30
+ // with no local address is a socket that never opened: a try that failed.
31
+
32
+ import { subscribe } from "node:diagnostics_channel";
33
+ import { WebSocket as WsWebSocket } from "ws";
34
+ import { bootId, commandLine, emit, now, waitFor } from "./lines.mjs";
35
+
36
+ const USAGE =
37
+ "client-ws HOST PORT --mode idle|inflight --websocket global|ws --heartbeat on|off --reconnect on|off --request-timeout-ms N --connection-timeout-ms N";
38
+ const line = commandLine(USAGE, 2);
39
+ const [host, port] = line.args;
40
+ const mode = line.required("--mode");
41
+ if (mode !== "idle" && mode !== "inflight") line.fail("--mode is idle or inflight");
42
+ const socketClass = line.required("--websocket");
43
+ if (socketClass !== "global" && socketClass !== "ws") line.fail("--websocket is global or ws");
44
+ /** This function gives a duration of milliseconds, as 0.3.0 takes each time. */
45
+ const ms = (value) => ({ value, unit: "ms" });
46
+ const heartbeat = line.toggle("--heartbeat")
47
+ ? {
48
+ enabled: true,
49
+ interval: line.number("--heartbeat-interval-ms"),
50
+ timeout: line.number("--heartbeat-timeout-ms"),
51
+ misses: line.number("--heartbeat-misses"),
52
+ }
53
+ : { enabled: false };
54
+ const reconnect = line.toggle("--reconnect")
55
+ ? {
56
+ enabled: true,
57
+ initialDelay: line.number("--reconnect-initial-ms"),
58
+ backoffMultiplier: line.number("--reconnect-multiplier"),
59
+ maxDelay: line.number("--reconnect-max-ms"),
60
+ maxAttempts: line.number("--reconnect-attempts"),
61
+ jitter: line.number("--reconnect-jitter"),
62
+ }
63
+ : { enabled: false };
64
+ const requestTimeout = line.number("--request-timeout-ms");
65
+ const connectionTimeout = line.number("--connection-timeout-ms");
66
+ const hold = line.flag("--hold-ms") === undefined ? undefined : line.number("--hold-ms");
67
+ const go = line.flag("--go") ?? "/tmp/go";
68
+ const max = Number(line.flag("--max-s") ?? 60) * 1000;
69
+
70
+ emit("start", {
71
+ boot_id: bootId(),
72
+ mode,
73
+ client: "procedures-ws",
74
+ package: "@procedures/client 0.3.0",
75
+ websocket: socketClass,
76
+ node: process.version,
77
+ undici: process.versions.undici ?? null,
78
+ settings: { heartbeat, reconnect, requestTimeout, connectionTimeout, hold: hold ?? null },
79
+ });
80
+
81
+ /** These are the sockets of the transport, by their WebSocket: the number of each, its local address, and whether the transport dropped it. */
82
+ const sockets = new WeakMap();
83
+ let tries = 0;
84
+ /** This is the last socket error that undici published. The close event of its socket follows it. */
85
+ let pending;
86
+
87
+ /** This function gives the type of a frame of the transport: its JSON `type`. */
88
+ const typeOf = (data) => {
89
+ if (typeof data !== "string") return "binary";
90
+ try {
91
+ return JSON.parse(data).type ?? "unknown";
92
+ } catch {
93
+ return "text";
94
+ }
95
+ };
96
+
97
+ /** This function writes the local address of a socket that opened, once. */
98
+ const opened = (socket, local) => {
99
+ if (socket === undefined || socket.local !== undefined || local === null) return;
100
+ socket.local = local;
101
+ emit("connected", { conn: socket.conn, local });
102
+ };
103
+
104
+ /** This function writes the drop of a socket by the transport, before its close() or its terminate(). */
105
+ const dropped = (websocket, how) => {
106
+ const socket = sockets.get(websocket);
107
+ if (socket !== undefined) socket.dropped = true;
108
+ emit("drop", { conn: socket?.conn ?? null, local: socket?.local ?? null, how });
109
+ };
110
+
111
+ /**
112
+ * This function gives a subclass of a class of WebSockets that writes the acts of the transport.
113
+ * It has terminate() only when its class has it: the transport calls terminate() when it exists.
114
+ */
115
+ const stamped = (Base) => {
116
+ class Stamped extends Base {
117
+ constructor(...args) {
118
+ const conn = ++tries;
119
+ emit("dial", { conn });
120
+ super(...args);
121
+ const socket = { conn, local: undefined, dropped: false };
122
+ sockets.set(this, socket);
123
+ // a socket of ws gives its local address at its open; undici gives it in a diagnostics channel
124
+ this.addEventListener("open", () => {
125
+ const raw = this._socket;
126
+ if (raw?.localAddress !== undefined) opened(socket, `${raw.localAddress}:${raw.localPort}`);
127
+ });
128
+ this.addEventListener("error", (event) => {
129
+ const code = event?.error?.code;
130
+ if (typeof code === "string") pending = { errno: code, at: now() };
131
+ });
132
+ this.addEventListener("close", (event) => {
133
+ const error = pending;
134
+ pending = undefined;
135
+ emit("conn-end", { conn, local: socket.local ?? null, code: event.code, clean: event.wasClean, errno: error?.errno ?? null });
136
+ // the end of the socket of the run, as the client saw it: an error, or the end of the file
137
+ if (conn !== 1 || socket.dropped) return;
138
+ if (error !== undefined) emit("error", { errno: error.errno, local: socket.local ?? null }, error.at);
139
+ else emit("eof", { local: socket.local ?? null });
140
+ });
141
+ }
142
+
143
+ send(...args) {
144
+ const socket = sockets.get(this);
145
+ const type = typeOf(args[0]);
146
+ emit(type === "ping" ? "ping" : "write", { conn: socket?.conn ?? null, local: socket?.local ?? null, ...(type === "ping" ? {} : { type }) });
147
+ return super.send(...args);
148
+ }
149
+
150
+ close(...args) {
151
+ dropped(this, "close");
152
+ return super.close(...args);
153
+ }
154
+ }
155
+ const terminate = Base.prototype.terminate;
156
+ if (typeof terminate === "function")
157
+ Stamped.prototype.terminate = function (...args) {
158
+ dropped(this, "terminate");
159
+ return terminate.apply(this, args);
160
+ };
161
+ return Stamped;
162
+ };
163
+
164
+ subscribe("undici:websocket:open", (message) => {
165
+ const address = message.address;
166
+ opened(sockets.get(message.websocket), address === null || address === undefined ? null : `${address.address}:${address.port}`);
167
+ });
168
+ subscribe("undici:websocket:socket_error", (error) => {
169
+ pending = { errno: error?.code ?? error?.name ?? "unknown", at: now() };
170
+ });
171
+
172
+ setTimeout(() => {
173
+ emit("deadline");
174
+ process.exit(0);
175
+ }, max);
176
+
177
+ const { Client, WebSocketTransport } = await import("@procedures/client");
178
+
179
+ let first;
180
+ const firstOpen = new Promise((resolve) => {
181
+ first = resolve;
182
+ });
183
+ const transport = new WebSocketTransport({
184
+ url: `ws://${host}:${port}/ws`,
185
+ // the global class has no terminate(): the subclass of it has none either
186
+ webSocket: socketClass === "ws" ? stamped(WsWebSocket) : stamped(globalThis.WebSocket),
187
+ heartbeat: heartbeat.enabled
188
+ ? { enabled: true, interval: ms(heartbeat.interval), timeout: ms(heartbeat.timeout), misses: heartbeat.misses }
189
+ : { enabled: false },
190
+ reconnect: reconnect.enabled
191
+ ? {
192
+ enabled: true,
193
+ initialDelay: ms(reconnect.initialDelay),
194
+ backoffMultiplier: reconnect.backoffMultiplier,
195
+ maxDelay: ms(reconnect.maxDelay),
196
+ maxAttempts: reconnect.maxAttempts,
197
+ jitter: reconnect.jitter,
198
+ }
199
+ : { enabled: false },
200
+ requestTimeout: ms(requestTimeout),
201
+ connectionTimeout: ms(connectionTimeout),
202
+ onConnect: () => {
203
+ emit("status", { status: "connected" });
204
+ first?.();
205
+ first = undefined;
206
+ },
207
+ onDisconnect: (reason) => emit("status", { status: "disconnected", reason: reason ?? null }),
208
+ onReconnecting: (attempt) => emit("reconnecting", { attempt }),
209
+ onError: (error) => emit("status", { status: "error", message: error?.message ?? null }),
210
+ });
211
+ const client = new Client({ transport });
212
+
213
+ const ECHO = { service: "harness", operation: "echo" };
214
+ const HOLD = { service: "harness", operation: "hold" };
215
+
216
+ await firstOpen;
217
+ try {
218
+ emit("sent");
219
+ const reply = await client.call(ECHO, {});
220
+ emit("ready", { reply: JSON.stringify(reply ?? null) });
221
+ } catch (error) {
222
+ emit("fail", { why: error?.message ?? String(error) });
223
+ process.exit(1);
224
+ }
225
+
226
+ if (mode === "inflight") {
227
+ await waitFor(go);
228
+ // into the fault: its reply never comes, or comes after the fault
229
+ emit("sent");
230
+ client.call(hold === undefined ? ECHO : HOLD, hold === undefined ? {} : { ms: hold }).then(
231
+ (reply) => emit("data", { n: JSON.stringify(reply ?? null).length }),
232
+ (error) => emit("call-error", { code: error?.code ?? error?.name ?? null, message: error?.message ?? String(error) }),
233
+ );
234
+ }
@@ -0,0 +1,68 @@
1
+ // The JSON lines of the procedures programs of the harness: the WebSocket client and the server.
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. A write to a pipe is synchronous on Linux, thus a line goes out
6
+ // before the program does the act that it announces.
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+
10
+ /** This function gives the time now, in nanoseconds on CLOCK_MONOTONIC. */
11
+ export const now = () => process.hrtime.bigint();
12
+
13
+ /** This function writes one line, with the time `at` (the time now when none is given). */
14
+ export const emit = (ev, fields = {}, at = now()) =>
15
+ process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
16
+
17
+ /** This function gives the boot id of the kernel, or null where the file does not exist. */
18
+ export const bootId = () => {
19
+ try {
20
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+
26
+ /**
27
+ * This function reads the command line: positional arguments first, then `--name value` pairs.
28
+ * A setting that the caller must give and does not is a failure: the program has no default.
29
+ */
30
+ export const commandLine = (usage, positional = 0) => {
31
+ const argv = process.argv.slice(2);
32
+ const flag = (name) => {
33
+ const at = argv.indexOf(name);
34
+ return at === -1 ? undefined : argv[at + 1];
35
+ };
36
+ const fail = (why) => {
37
+ emit("fail", { why: `${why}. Usage: ${usage}` });
38
+ process.exit(1);
39
+ };
40
+ const required = (name) => {
41
+ const value = flag(name);
42
+ if (value === undefined) fail(`no ${name}`);
43
+ return value;
44
+ };
45
+ const number = (name) => {
46
+ const value = Number(required(name));
47
+ if (!Number.isFinite(value)) fail(`${name} is not a number`);
48
+ return value;
49
+ };
50
+ const toggle = (name) => {
51
+ const value = required(name);
52
+ if (value !== "on" && value !== "off") fail(`${name} is on or off`);
53
+ return value === "on";
54
+ };
55
+ const args = argv.slice(0, positional);
56
+ if (args.length < positional || args.some((arg) => arg.startsWith("--"))) fail("the positional arguments are missing");
57
+ return { args, flag, required, number, toggle, fail };
58
+ };
59
+
60
+ /** This function waits until the file at `path` exists. It looks each 2 ms, as the other clients of the harness do. */
61
+ export const waitFor = (path) =>
62
+ new Promise((resolve) => {
63
+ const look = setInterval(() => {
64
+ if (!existsSync(path)) return;
65
+ clearInterval(look);
66
+ resolve();
67
+ }, 2);
68
+ });
@@ -0,0 +1,125 @@
1
+ {
2
+ "name": "loom-harness-procedures",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "loom-harness-procedures",
8
+ "dependencies": {
9
+ "@procedures/client": "0.3.0",
10
+ "ws": "8.22.0"
11
+ }
12
+ },
13
+ "node_modules/@mark1russell7/algebra": {
14
+ "version": "0.5.0",
15
+ "resolved": "https://registry.npmjs.org/@mark1russell7/algebra/-/algebra-0.5.0.tgz",
16
+ "integrity": "sha512-rNKCWvZBGDUYtA1BOPftPY7+zG6LWdISWrjkZwsHf3IS4bfz6JCeWVpJ0LV/vEvNRz1OnVjh9WgwoEUn1Y4cCQ==",
17
+ "license": "MIT",
18
+ "dependencies": {
19
+ "@mark1russell7/quantity": "^0.2.0"
20
+ },
21
+ "engines": {
22
+ "node": ">=22.19"
23
+ },
24
+ "peerDependencies": {
25
+ "zod": "^4.1.0"
26
+ }
27
+ },
28
+ "node_modules/@mark1russell7/quantity": {
29
+ "version": "0.2.0",
30
+ "resolved": "https://registry.npmjs.org/@mark1russell7/quantity/-/quantity-0.2.0.tgz",
31
+ "integrity": "sha512-pE2OganjbhstZgoeLVu5YRVQq6bOnV/5T4GYuJhIu0+PF77+DwpfrIZonhNNfEZHz/P4ioiwFowbIwxPq9VkSA==",
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=22.19"
35
+ },
36
+ "peerDependencies": {
37
+ "zod": "^4.1.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "zod": {
41
+ "optional": true
42
+ }
43
+ }
44
+ },
45
+ "node_modules/@mark1russell7/service": {
46
+ "version": "0.4.0",
47
+ "resolved": "https://registry.npmjs.org/@mark1russell7/service/-/service-0.4.0.tgz",
48
+ "integrity": "sha512-Zby/XIGWTWax927/pkQgeY9ljlzLmBkUnTlDcpGDKnNpEmXhM0WpnK948hnDqvUd2bwXM3/CcM2cYQDAaTA+2A==",
49
+ "license": "MIT",
50
+ "dependencies": {
51
+ "@mark1russell7/algebra": "^0.5.0",
52
+ "@mark1russell7/quantity": "^0.2.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.19"
56
+ },
57
+ "peerDependencies": {
58
+ "ioredis": "^6.0.0",
59
+ "zod": "^4.1.0"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "ioredis": {
63
+ "optional": true
64
+ }
65
+ }
66
+ },
67
+ "node_modules/@procedures/client": {
68
+ "version": "0.3.0",
69
+ "resolved": "https://registry.npmjs.org/@procedures/client/-/client-0.3.0.tgz",
70
+ "integrity": "sha512-DzOOZIhv1DH87zv3Je/D4gg+Jou386i69hPkQU5bz8Gs1vRzZoTrBcb0kojNMS4Ex1cwDxXYr0GDxVmlpVuEnA==",
71
+ "license": "MIT",
72
+ "dependencies": {
73
+ "@mark1russell7/algebra": "^0.5.0",
74
+ "@mark1russell7/quantity": "^0.2.0",
75
+ "@mark1russell7/service": "^0.4.0",
76
+ "ws": "^8.18.3",
77
+ "zod": "^4.6.5"
78
+ },
79
+ "bin": {
80
+ "client-registry": "dist/procedures/cli/index.js"
81
+ },
82
+ "engines": {
83
+ "node": ">=22"
84
+ },
85
+ "peerDependencies": {
86
+ "express": "^4.0.0 || ^5.0.0"
87
+ },
88
+ "peerDependenciesMeta": {
89
+ "express": {
90
+ "optional": true
91
+ }
92
+ }
93
+ },
94
+ "node_modules/ws": {
95
+ "version": "8.22.0",
96
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
97
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
98
+ "license": "MIT",
99
+ "engines": {
100
+ "node": ">=10.0.0"
101
+ },
102
+ "peerDependencies": {
103
+ "bufferutil": "^4.0.1",
104
+ "utf-8-validate": ">=5.0.2"
105
+ },
106
+ "peerDependenciesMeta": {
107
+ "bufferutil": {
108
+ "optional": true
109
+ },
110
+ "utf-8-validate": {
111
+ "optional": true
112
+ }
113
+ }
114
+ },
115
+ "node_modules/zod": {
116
+ "version": "4.6.7",
117
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
118
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
119
+ "license": "MIT",
120
+ "funding": {
121
+ "url": "https://github.com/sponsors/colinhacks"
122
+ }
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "loom-harness-procedures",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "@procedures/client": "0.3.0",
7
+ "ws": "8.22.0"
8
+ }
9
+ }
@@ -0,0 +1,94 @@
1
+ // server-ws --port N --tcp-keepalive off|N --heartbeat off|on [--heartbeat-interval-ms N --heartbeat-misses N]
2
+ //
3
+ // A cell's target: the procedures server of @procedures/client 0.3.0 (Server and
4
+ // WebSocketServerTransport) on the http.Server of Node 24.21.0, at the path /ws. It has two
5
+ // procedures: `harness.echo` answers at once, and `harness.hold` answers after `ms` milliseconds
6
+ // (a held reply, as BLPOP is for Redis).
7
+ //
8
+ // --tcp-keepalive is the keepalive of each accepted socket: off, or its idle time in ms. Node's
9
+ // default is off (keepAlive of http.Server), and the cell says it.
10
+ //
11
+ // --heartbeat is the heartbeat of the server (roadmap R3): a WebSocket ping to each connection at
12
+ // each interval, and terminate() for a connection that missed `misses` pongs. Each setting is a
13
+ // flag, and the server has no default. 0.3.0 takes each time as a duration, not a number.
14
+ //
15
+ // Each event is a JSON line on the clock of the clients: a connection (`accepted`), a request,
16
+ // a reply, and the close of a connection. The `ws` package does the WebSocket of the server. Its
17
+ // `ping` and `terminate` write a line first (`ping`, `drop`): the heartbeat of the server pings its
18
+ // clients and drops a silent one. Each line names the connection by its peer.
19
+
20
+ import http from "node:http";
21
+ import { WebSocket } from "ws";
22
+ import { Server, WebSocketServerTransport } from "@procedures/client";
23
+ import { bootId, commandLine, emit } from "./lines.mjs";
24
+
25
+ const line = commandLine("server-ws --port N --tcp-keepalive off|N --heartbeat off|on [--heartbeat-interval-ms N --heartbeat-misses N]");
26
+ const port = line.number("--port");
27
+ const keepalive = line.required("--tcp-keepalive");
28
+ const keepAliveMs = keepalive === "off" ? null : Number(keepalive);
29
+ if (keepAliveMs !== null && !Number.isFinite(keepAliveMs)) line.fail("--tcp-keepalive is off or a number of ms");
30
+ const heartbeat = line.toggle("--heartbeat")
31
+ ? { interval: line.number("--heartbeat-interval-ms"), misses: line.number("--heartbeat-misses") }
32
+ : null;
33
+
34
+ const peerOf = (socket) => `${socket?.remoteAddress}:${socket?.remotePort}`;
35
+
36
+ // the acts of a heartbeat of the server, in the package that the transport imports
37
+ const ping = WebSocket.prototype.ping;
38
+ WebSocket.prototype.ping = function (...args) {
39
+ emit("ping", { peer: peerOf(this._socket) });
40
+ return ping.apply(this, args);
41
+ };
42
+ const terminate = WebSocket.prototype.terminate;
43
+ WebSocket.prototype.terminate = function (...args) {
44
+ emit("drop", { peer: peerOf(this._socket) });
45
+ return terminate.apply(this, args);
46
+ };
47
+
48
+ const ok = (request, payload) => ({ id: request.id, status: { type: "success", code: 200 }, payload, metadata: {} });
49
+ const server = new Server();
50
+ server.register({ service: "harness", operation: "echo" }, async (request) => {
51
+ emit("request", { method: "harness.echo" });
52
+ return ok(request, { ok: true });
53
+ });
54
+ server.register({ service: "harness", operation: "hold" }, async (request) => {
55
+ const ms = Number(request.payload?.ms ?? 0);
56
+ emit("request", { method: "harness.hold", ms });
57
+ await new Promise((resolve) => {
58
+ const timer = setTimeout(resolve, ms);
59
+ request.signal?.addEventListener("abort", () => {
60
+ clearTimeout(timer);
61
+ resolve();
62
+ });
63
+ });
64
+ emit("replied", { method: "harness.hold", aborted: request.signal?.aborted === true });
65
+ return ok(request, { ok: true, held: ms });
66
+ });
67
+
68
+ const httpServer = http.createServer({
69
+ keepAlive: keepAliveMs !== null,
70
+ ...(keepAliveMs === null ? {} : { keepAliveInitialDelay: keepAliveMs }),
71
+ });
72
+ const transport = new WebSocketServerTransport(server, {
73
+ server: httpServer,
74
+ path: "/ws",
75
+ heartbeat: heartbeat === null ? false : { interval: { value: heartbeat.interval, unit: "ms" }, misses: heartbeat.misses },
76
+ onConnection: (ws, request) => {
77
+ const peer = peerOf(request.socket);
78
+ emit("accepted", { peer });
79
+ ws.on("close", (code) => emit("closed", { peer, code }));
80
+ },
81
+ });
82
+ server.addTransport(transport);
83
+ await server.start();
84
+
85
+ httpServer.listen(port, "0.0.0.0", () =>
86
+ emit("listening", {
87
+ port,
88
+ boot_id: bootId(),
89
+ node: process.version,
90
+ package: "@procedures/client 0.3.0",
91
+ tcpKeepalive: keepAliveMs,
92
+ heartbeat,
93
+ }),
94
+ );
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.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.",
4
+ "version": "0.8.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. 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",
8
8
  "chaos-engineering",
@@ -61,6 +61,12 @@
61
61
  "image/mongo/stepdown-mongo.mjs",
62
62
  "image/mongo/package.json",
63
63
  "image/mongo/npm-lock.json",
64
+ "image/procedures/Dockerfile",
65
+ "image/procedures/package.json",
66
+ "image/procedures/npm-lock.json",
67
+ "image/procedures/lines.mjs",
68
+ "image/procedures/client-ws.mjs",
69
+ "image/procedures/server-ws.mjs",
64
70
  "image/router/Dockerfile",
65
71
  "image/router/mbx.sh",
66
72
  "image/router/ctwatch.py",