@mark1russell7/harness 0.0.0-stage → 0.1.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,91 @@
1
+ //! inject blackhole PEER | inject clear PEER [--at NS] | inject rst PEER | inject clear-rst PEER
2
+ //!
3
+ //! A cell's fault, applied inside the network namespace this sidecar shares with the target, so
4
+ //! the target's image stays stock. Each lands in one iptables-restore commit,
5
+ //! bracketed by CLOCK_MONOTONIC stamps taken in the VM: a docker exec from Windows lands 190 ms late
6
+ //! at the median, and the bracket does not care.
7
+ //!
8
+ //! - blackhole: both directions drop, so nothing comes back;
9
+ //! - rst: the peer's next segment is answered with a reset, so its next send fails at once. A
10
+ //! REJECT resets only the sender of the matched segment: the target's process
11
+ //! never hears of it.
12
+ //!
13
+ //! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
14
+ //! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
15
+ //! its 190 ms never reach the stall's length.
16
+
17
+ use prober::monotonic_ns;
18
+ use serde_json::json;
19
+ use std::error::Error;
20
+ use std::io::Write;
21
+ use std::net::Ipv4Addr;
22
+ use std::process::{Command, Stdio};
23
+
24
+ /// sleeps until an instant on CLOCK_MONOTONIC, or returns at once if it has passed
25
+ fn until(at: u64) -> Result<(), Box<dyn Error>> {
26
+ let deadline = libc::timespec {
27
+ // the fields' own types: musl is moving time_t to 64 bits
28
+ tv_sec: (at / 1_000_000_000) as _,
29
+ tv_nsec: (at % 1_000_000_000) as _,
30
+ };
31
+ loop {
32
+ // SAFETY: a valid timespec, and no remainder is asked for with an absolute time
33
+ let code = unsafe {
34
+ libc::clock_nanosleep(
35
+ libc::CLOCK_MONOTONIC,
36
+ libc::TIMER_ABSTIME,
37
+ &deadline,
38
+ std::ptr::null_mut(),
39
+ )
40
+ };
41
+ match code {
42
+ 0 => return Ok(()),
43
+ libc::EINTR => continue,
44
+ other => return Err(format!("clock_nanosleep failed: {other}").into()),
45
+ }
46
+ }
47
+ }
48
+
49
+ fn run() -> Result<(), Box<dyn Error>> {
50
+ let args: Vec<String> = std::env::args().skip(1).collect();
51
+ let action = args.first().ok_or("say blackhole, clear, rst or clear-rst")?.as_str();
52
+ // parsed, never pasted: only an address reaches the rules
53
+ let peer: Ipv4Addr = args.get(1).ok_or("and the peer's address")?.parse()?;
54
+ let at: Option<u64> = match args.get(2).map(String::as_str) {
55
+ None => None,
56
+ Some("--at") => Some(args.get(3).ok_or("--at takes an instant in ns")?.parse()?),
57
+ Some(other) => return Err(format!("unknown option {other}").into()),
58
+ };
59
+ let rules = match action {
60
+ "blackhole" | "clear" => {
61
+ let op = if action == "blackhole" { "-I" } else { "-D" };
62
+ format!("{op} INPUT -s {peer}/32 -j DROP\n{op} OUTPUT -d {peer}/32 -j DROP\n")
63
+ }
64
+ "rst" | "clear-rst" => {
65
+ let op = if action == "rst" { "-I" } else { "-D" };
66
+ format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with tcp-reset\n")
67
+ }
68
+ other => return Err(format!("unknown action {other}").into()),
69
+ };
70
+ let script = format!("*filter\n{rules}COMMIT\n");
71
+ if let Some(at) = at {
72
+ until(at)?;
73
+ }
74
+ let t0 = monotonic_ns();
75
+ let mut child = Command::new("iptables-restore").arg("--noflush").stdin(Stdio::piped()).spawn()?;
76
+ child.stdin.take().ok_or("iptables-restore took no stdin")?.write_all(script.as_bytes())?;
77
+ let status = child.wait()?;
78
+ let t1 = monotonic_ns();
79
+ if !status.success() {
80
+ return Err(format!("iptables-restore exited {status}").into());
81
+ }
82
+ println!("{}", json!({ "action": action, "peer": peer.to_string(), "t0": t0, "t1": t1 }));
83
+ Ok(())
84
+ }
85
+
86
+ fn main() {
87
+ if let Err(error) = run() {
88
+ eprintln!("inject: {error}");
89
+ std::process::exit(1);
90
+ }
91
+ }
@@ -0,0 +1,11 @@
1
+ # The harness's Node client: ioredis 6.0.0 on Node 24.21.0, the releases the corpus's claims about
2
+ # ioredis and Node's keepalive name. It runs as a container in the client's
3
+ # network namespace, as the harness's own TCP client does, and prints the same JSON lines.
4
+ # lab.ts stages the build context: this folder, under harness/image/node. The lockfile is
5
+ # npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
6
+ FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
7
+ WORKDIR /opt/harness/node
8
+ COPY harness/image/node/package.json ./
9
+ COPY harness/image/node/npm-lock.json ./package-lock.json
10
+ RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
11
+ COPY harness/image/node/client-ioredis.mjs ./
@@ -0,0 +1,105 @@
1
+ // client-ioredis HOST PORT --mode idle|inflight [--keepalive-ms N] [--go PATH] [--max-s N]
2
+ //
3
+ // A cell's client one layer up: an ioredis 6.0.0 connection, every event a JSON line stamped on
4
+ // CLOCK_MONOTONIC (process.hrtime, which libuv reads from it), the clock the prober stamps the
5
+ // kernel's records with. Its lines are the harness's TCP client's, so the harness reads both the
6
+ // same way (image/native/src/bin/client.rs), with ioredis's own status changes
7
+ // beside them as `status` lines.
8
+ //
9
+ // ioredis sets the socket's keepalive itself, once it connects: setKeepAlive(true, keepAlive), and
10
+ // Node fixes the probe interval and count. It sets no other timer the kernel would see. Both modes
11
+ // send a PING and read its reply first; that reply is the last segment received. idle: then hold
12
+ // the connection until it fails. inflight: then wait for the file --go to appear, send a PING into
13
+ // the fault, and wait.
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import Redis from "ioredis";
17
+
18
+ const now = () => process.hrtime.bigint();
19
+ const emit = (ev, fields = {}, at = now()) =>
20
+ process.stdout.write(`${JSON.stringify({ ev, t: Number(at), ...fields })}\n`);
21
+
22
+ const argv = process.argv.slice(2);
23
+ const flag = (name) => {
24
+ const at = argv.indexOf(name);
25
+ return at === -1 ? undefined : argv[at + 1];
26
+ };
27
+ const [host, port] = argv;
28
+ const mode = flag("--mode");
29
+ if (host === undefined || port === undefined || (mode !== "idle" && mode !== "inflight")) {
30
+ emit("fail", { why: "client-ioredis HOST PORT --mode idle|inflight" });
31
+ process.exit(1);
32
+ }
33
+ const keepAlive = Number(flag("--keepalive-ms") ?? 30000);
34
+ const go = flag("--go") ?? "/tmp/go";
35
+ const max = Number(flag("--max-s") ?? 60) * 1000;
36
+
37
+ emit("start", {
38
+ boot_id: readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(),
39
+ mode,
40
+ client: "ioredis",
41
+ });
42
+
43
+ // one connection, whose end is what is measured: ioredis would reconnect, so the client ends first
44
+ const redis = new Redis({ host, port: Number(port), keepAlive, lazyConnect: true });
45
+ for (const status of ["connect", "ready", "close", "reconnecting", "end"]) {
46
+ redis.on(status, () => emit("status", { status }));
47
+ }
48
+
49
+ let ended = false;
50
+ const end = (code = 0) => {
51
+ if (ended) return;
52
+ ended = true;
53
+ // a moment for ioredis's own close to be written after the error that caused it
54
+ setTimeout(() => {
55
+ redis.disconnect(false);
56
+ process.exit(code);
57
+ }, 50);
58
+ };
59
+ redis.on("error", (error) => {
60
+ emit("error", { errno: error.code ?? error.errno ?? "unknown", message: error.message });
61
+ end();
62
+ });
63
+ redis.on("close", () => {
64
+ if (!ended) emit("eof");
65
+ end();
66
+ });
67
+ setTimeout(() => {
68
+ emit("deadline");
69
+ end();
70
+ }, max);
71
+
72
+ /** a PING, stamped before it is written: the kernel can answer before the call returns */
73
+ const ping = () => {
74
+ const at = now();
75
+ const reply = redis.ping();
76
+ emit("sent", {}, at);
77
+ return reply;
78
+ };
79
+
80
+ redis.once("connect", () => {
81
+ const { localAddress, localPort } = redis.stream;
82
+ emit("connected", { local: `${localAddress}:${localPort}` });
83
+ });
84
+
85
+ try {
86
+ await redis.connect();
87
+ const reply = await ping();
88
+ emit("ready", { reply: `+${reply}\r\n` });
89
+ } catch (error) {
90
+ // a connection that never became ready: what ended it was written as it happened
91
+ if (!ended) emit("fail", { why: error.message });
92
+ end(1);
93
+ }
94
+
95
+ if (mode === "inflight") {
96
+ const wait = setInterval(() => {
97
+ if (!existsSync(go)) return;
98
+ clearInterval(wait);
99
+ // into the fault: its reply never comes, or comes once the target thaws
100
+ ping().then(
101
+ (late) => emit("data", { n: late.length, text: late }),
102
+ () => undefined,
103
+ );
104
+ }, 2);
105
+ }
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "loom-harness-ioredis-client",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "loom-harness-ioredis-client",
8
+ "dependencies": {
9
+ "ioredis": "6.0.0"
10
+ }
11
+ },
12
+ "node_modules/@ioredis/commands": {
13
+ "version": "2.0.0",
14
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
15
+ "integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
16
+ "license": "MIT"
17
+ },
18
+ "node_modules/cluster-key-slot": {
19
+ "version": "1.1.1",
20
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
21
+ "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
22
+ "license": "Apache-2.0",
23
+ "engines": {
24
+ "node": ">=0.10.0"
25
+ }
26
+ },
27
+ "node_modules/debug": {
28
+ "version": "4.4.3",
29
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
30
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "ms": "^2.1.3"
34
+ },
35
+ "engines": {
36
+ "node": ">=6.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "supports-color": {
40
+ "optional": true
41
+ }
42
+ }
43
+ },
44
+ "node_modules/denque": {
45
+ "version": "2.1.0",
46
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
47
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
48
+ "license": "Apache-2.0",
49
+ "engines": {
50
+ "node": ">=0.10"
51
+ }
52
+ },
53
+ "node_modules/ioredis": {
54
+ "version": "6.0.0",
55
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
56
+ "integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
57
+ "license": "MIT",
58
+ "dependencies": {
59
+ "@ioredis/commands": "2.0.0",
60
+ "cluster-key-slot": "1.1.1",
61
+ "debug": "4.4.3",
62
+ "denque": "2.1.0",
63
+ "redis-errors": "1.2.0",
64
+ "standard-as-callback": "2.1.0"
65
+ },
66
+ "engines": {
67
+ "node": ">=20.0.0"
68
+ },
69
+ "funding": {
70
+ "type": "opencollective",
71
+ "url": "https://opencollective.com/ioredis"
72
+ }
73
+ },
74
+ "node_modules/ms": {
75
+ "version": "2.1.3",
76
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
77
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
78
+ "license": "MIT"
79
+ },
80
+ "node_modules/redis-errors": {
81
+ "version": "1.2.0",
82
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
83
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
84
+ "license": "MIT",
85
+ "engines": {
86
+ "node": ">=4"
87
+ }
88
+ },
89
+ "node_modules/standard-as-callback": {
90
+ "version": "2.1.0",
91
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
92
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
93
+ "license": "MIT"
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "loom-harness-ioredis-client",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "ioredis": "6.0.0"
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package",
2
3
  "name": "@mark1russell7/harness",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
4
+ "version": "0.1.0",
5
+ "description": "A fault-injection harness for connection timers: a cell runs a client against a stock target in Docker, injects a fault, measures the kernel's own records and judges a prediction.",
6
+ "keywords": [
7
+ "fault-injection",
8
+ "chaos-engineering",
9
+ "tcp",
10
+ "keepalive",
11
+ "timeouts",
12
+ "testcontainers",
13
+ "docker",
14
+ "ebpf"
15
+ ],
16
+ "homepage": "https://mark1russell7.github.io/systems/",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/mark1russell7/systems.git",
20
+ "directory": "packages/harness"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/mark1russell7/systems/issues"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Mark Russell (https://github.com/mark1russell7)",
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "main": "./build/index.js",
30
+ "types": "./build/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./build/index.d.ts",
34
+ "default": "./build/index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "build",
40
+ "image/Dockerfile",
41
+ "image/native/Cargo.toml",
42
+ "image/native/Cargo.lock",
43
+ "image/native/src",
44
+ "image/node/Dockerfile",
45
+ "image/node/client-ioredis.mjs",
46
+ "image/node/package.json",
47
+ "image/node/npm-lock.json",
48
+ "README.md",
49
+ "LICENSE"
50
+ ],
51
+ "engines": {
52
+ "node": ">=22.19"
53
+ },
54
+ "dependencies": {
55
+ "@mark1russell7/prober": "^0.1.0",
56
+ "@mark1russell7/quantity": "^0.1.0",
57
+ "testcontainers": "12.2.0"
58
+ },
59
+ "peerDependencies": {
60
+ "zod": "^4.1.0"
61
+ },
62
+ "devDependencies": {
63
+ "@mark1russell7/cue": "github:mark1russell7/cue",
64
+ "@types/node": "^26.6.4",
65
+ "tsdown": "^0.23.1",
66
+ "typescript": "^7.0.2",
67
+ "vitest": "^5.0.3",
68
+ "zod": "^4.6.6"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "scripts": {
74
+ "build": "tsdown",
75
+ "test": "vitest run",
76
+ "test:watch": "vitest",
77
+ "typecheck": "tsc -p tsconfig.json --noEmit --emitDeclarationOnly false && tsc -p tsconfig.test.json"
78
+ }
6
79
  }