@mark1russell7/harness 0.0.0-stage → 0.2.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,263 @@
1
+ //! client HOST PORT --mode idle|inflight|stream [--keepalive] [--user-timeout-ms N] [--go PATH]
2
+ //! [--request TEXT] [--count N] [--interval-ms N] [--sample-ms N] [--max-s N]
3
+ //!
4
+ //! A cell's TCP client: one connection, every event a JSON line stamped on CLOCK_MONOTONIC. It
5
+ //! sets no per-socket TCP timer unless the cell asks, so the namespace's sysctls are what gets
6
+ //! measured: Rust's std::net turns nothing on by itself. While it waits, it samples the kernel's
7
+ //! view of its own socket (TCP_INFO) every --sample-ms and logs each change, a second vantage
8
+ //! beside the prober's records. It waits in poll(), never holding its socket's lock, so it never
9
+ //! puts off the socket's own keepalive timer.
10
+ //!
11
+ //! Both modes send one request and read its reply first; that reply is the last segment received.
12
+ //! idle: then hold the connection idle until it fails.
13
+ //! inflight: then wait for the file --go to appear, send the request again, and wait. --request
14
+ //! names that second request, a Redis inline command: PING unless it says otherwise. BLPOP with a
15
+ //! timeout makes the server answer only when the timeout runs out, so its reply can be the one in
16
+ //! flight when a fault lands.
17
+ //! stream: then wait for --go, and send the request --count times, each when the reply to the one
18
+ //! before it has come and --interval-ms has passed: a distribution of reply times, for a fault
19
+ //! whose outcome is one (loss, reordering). It ends with a done line.
20
+
21
+ use prober::{errno_name, monotonic_ns, quantity};
22
+ use serde_json::{json, Value};
23
+ use std::error::Error;
24
+ use std::io::{ErrorKind, Read, Write};
25
+ use std::net::TcpStream;
26
+ use std::os::fd::AsRawFd;
27
+ use std::path::Path;
28
+ use std::time::Duration;
29
+
30
+ /// a Redis inline command; any line-based server echoes or errors on it, which is a reply too
31
+ const REQUEST: &[u8] = b"PING\r\n";
32
+
33
+ fn emit(event: &str, fields: Value) {
34
+ emit_at(event, monotonic_ns(), fields);
35
+ }
36
+
37
+ fn emit_at(event: &str, t: u64, fields: Value) {
38
+ let mut line = json!({ "ev": event, "t": t });
39
+ if let (Some(line), Value::Object(fields)) = (line.as_object_mut(), fields) {
40
+ line.extend(fields);
41
+ }
42
+ println!("{line}");
43
+ }
44
+
45
+ /// Sends the request, stamped before the write: on a bridge, the kernel can take a reset to the
46
+ /// request before write() returns, and a stamp after it would come later than the reset's own.
47
+ fn send(stream: &mut TcpStream, request: &[u8]) -> std::io::Result<()> {
48
+ let t = monotonic_ns();
49
+ stream.write_all(request)?;
50
+ emit_at("sent", t, json!({}));
51
+ Ok(())
52
+ }
53
+
54
+ fn set_int(fd: i32, level: i32, name: i32, value: i32) -> std::io::Result<()> {
55
+ // SAFETY: the option value is an int on this stack, passed with its exact size
56
+ let status = unsafe {
57
+ libc::setsockopt(
58
+ fd,
59
+ level,
60
+ name,
61
+ (&value as *const i32).cast(),
62
+ std::mem::size_of::<i32>() as libc::socklen_t,
63
+ )
64
+ };
65
+ if status == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) }
66
+ }
67
+
68
+ fn tcp_info(fd: i32) -> std::io::Result<libc::tcp_info> {
69
+ // SAFETY: tcp_info is plain old data, and the kernel writes at most the length it is given
70
+ let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
71
+ let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
72
+ let status = unsafe {
73
+ libc::getsockopt(fd, libc::IPPROTO_TCP, libc::TCP_INFO, (&mut info as *mut libc::tcp_info).cast(), &mut len)
74
+ };
75
+ if status == 0 { Ok(info) } else { Err(std::io::Error::last_os_error()) }
76
+ }
77
+
78
+ /// Whether the socket has something to read, an error or an end, within `ms`: poll() asks the
79
+ /// socket without taking its lock.
80
+ fn readable(fd: i32, ms: i32) -> std::io::Result<bool> {
81
+ let mut wanted = libc::pollfd { fd, events: libc::POLLIN | libc::POLLRDHUP, revents: 0 };
82
+ // SAFETY: one pollfd on this stack, and its count
83
+ match unsafe { libc::poll(&mut wanted, 1, ms) } {
84
+ -1 if std::io::Error::last_os_error().kind() == ErrorKind::Interrupted => Ok(false),
85
+ -1 => Err(std::io::Error::last_os_error()),
86
+ _ => Ok(wanted.revents != 0),
87
+ }
88
+ }
89
+
90
+ /// what in TCP_INFO is worth a line when it changes: the timers' own state, never the ages
91
+ fn watched(info: &libc::tcp_info) -> [u32; 7] {
92
+ [
93
+ info.tcpi_state as u32,
94
+ info.tcpi_retransmits as u32,
95
+ info.tcpi_probes as u32,
96
+ info.tcpi_backoff as u32,
97
+ info.tcpi_rto,
98
+ info.tcpi_unacked,
99
+ info.tcpi_total_retrans,
100
+ ]
101
+ }
102
+
103
+ struct Args {
104
+ host: String,
105
+ port: u16,
106
+ inflight: bool,
107
+ /// in stream mode, how many requests, and the pause after each reply
108
+ stream: Option<u32>,
109
+ interval: Duration,
110
+ keepalive: bool,
111
+ user_timeout_ms: Option<i32>,
112
+ go: String,
113
+ sample: Duration,
114
+ max: Duration,
115
+ /// the request sent at --go, with its line ending
116
+ request: Vec<u8>,
117
+ }
118
+
119
+ fn args() -> Result<Args, Box<dyn Error>> {
120
+ let raw: Vec<String> = std::env::args().skip(1).collect();
121
+ let value = |flag: &str| raw.iter().position(|arg| arg == flag).and_then(|at| raw.get(at + 1)).cloned();
122
+ let mode = value("--mode").ok_or("--mode idle|inflight|stream is required")?;
123
+ if mode != "idle" && mode != "inflight" && mode != "stream" {
124
+ return Err(format!("--mode is idle, inflight or stream, not {mode}").into());
125
+ }
126
+ let count: u32 = value("--count").map(|v| v.parse()).transpose()?.unwrap_or(100);
127
+ Ok(Args {
128
+ host: raw.first().ok_or("the target's host comes first")?.clone(),
129
+ port: raw.get(1).ok_or("then its port")?.parse()?,
130
+ inflight: mode != "idle",
131
+ stream: (mode == "stream").then_some(count),
132
+ interval: Duration::from_millis(value("--interval-ms").map(|v| v.parse()).transpose()?.unwrap_or(20)),
133
+ keepalive: raw.iter().any(|arg| arg == "--keepalive"),
134
+ user_timeout_ms: value("--user-timeout-ms").map(|v| v.parse()).transpose()?,
135
+ go: value("--go").unwrap_or_else(|| "/tmp/go".into()),
136
+ sample: Duration::from_micros((value("--sample-ms").map(|v| v.parse::<f64>()).transpose()?.unwrap_or(2.0) * 1000.0) as u64),
137
+ max: Duration::from_secs_f64(value("--max-s").map(|v| v.parse::<f64>()).transpose()?.unwrap_or(60.0)),
138
+ request: value("--request").map_or_else(|| REQUEST.to_vec(), |r| format!("{r}\r\n").into_bytes()),
139
+ })
140
+ }
141
+
142
+ fn run() -> Result<(), Box<dyn Error>> {
143
+ let args = args()?;
144
+ let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?.trim().to_string();
145
+ let mode = match (args.inflight, args.stream) {
146
+ (_, Some(_)) => "stream",
147
+ (true, None) => "inflight",
148
+ (false, None) => "idle",
149
+ };
150
+ emit("start", json!({ "boot_id": boot_id, "mode": mode }));
151
+ let deadline = monotonic_ns() + args.max.as_nanos() as u64;
152
+
153
+ let mut stream = TcpStream::connect((args.host.as_str(), args.port))?;
154
+ stream.set_nodelay(true)?;
155
+ let fd = stream.as_raw_fd();
156
+ if args.keepalive {
157
+ set_int(fd, libc::SOL_SOCKET, libc::SO_KEEPALIVE, 1)?;
158
+ }
159
+ if let Some(ms) = args.user_timeout_ms {
160
+ set_int(fd, libc::IPPROTO_TCP, libc::TCP_USER_TIMEOUT, ms)?;
161
+ }
162
+ emit("connected", json!({ "local": stream.local_addr()?.to_string() }));
163
+ send(&mut stream, REQUEST)?;
164
+ let mut buf = [0u8; 65536];
165
+ let n = stream.read(&mut buf)?;
166
+ // the last segment received: a keepalive's idle time counts from here
167
+ let info = tcp_info(fd)?;
168
+ emit(
169
+ "ready",
170
+ json!({
171
+ "reply": String::from_utf8_lossy(&buf[..n.min(40)]),
172
+ "rtt": quantity(info.tcpi_rtt, "us"),
173
+ "rto": quantity(info.tcpi_rto, "us"),
174
+ }),
175
+ );
176
+
177
+ // the loop waits in poll(), which takes no lock on the socket, and reads only what is there. A
178
+ // read that waits takes the socket's lock each time it wakes, and a keepalive timer that finds
179
+ // the socket held puts itself off by HZ/20: the client would move the timer
180
+ // it measures
181
+ stream.set_nonblocking(true)?;
182
+ let tick = args.sample.as_millis().clamp(1, i32::MAX as u128) as i32;
183
+ let mut waiting = args.inflight;
184
+ let mut seen: Option<[u32; 7]> = None;
185
+ let mut age = info.tcpi_last_ack_recv;
186
+ // stream mode: requests sent, whether one waits for its reply, and when the next may go
187
+ let mut sent: u32 = 0;
188
+ let mut awaiting = false;
189
+ let mut next_at: u64 = 0;
190
+ loop {
191
+ if readable(fd, tick)? {
192
+ match stream.read(&mut buf) {
193
+ Ok(0) => {
194
+ emit("eof", json!({}));
195
+ return Ok(());
196
+ }
197
+ Ok(n) => {
198
+ emit("data", json!({ "n": n, "text": String::from_utf8_lossy(&buf[..n.min(40)]) }));
199
+ if awaiting {
200
+ awaiting = false;
201
+ next_at = monotonic_ns() + args.interval.as_nanos() as u64;
202
+ }
203
+ }
204
+ // nothing after all; ETIMEDOUT is the connection's own end
205
+ Err(error) if error.kind() == ErrorKind::WouldBlock || error.kind() == ErrorKind::Interrupted => {}
206
+ Err(error) => {
207
+ let code = error.raw_os_error().unwrap_or(0);
208
+ emit("error", json!({ "errno": errno_name(code) }));
209
+ return Ok(());
210
+ }
211
+ }
212
+ }
213
+ let info = tcp_info(fd)?;
214
+ let now = watched(&info);
215
+ if seen != Some(now) {
216
+ emit(
217
+ "kernel",
218
+ json!({
219
+ "state": now[0], "retransmits": now[1], "probes": now[2], "backoff": now[3],
220
+ "rto": quantity(now[4], "us"), "unacked": now[5], "total_retrans": now[6],
221
+ "last_ack_recv": quantity(info.tcpi_last_ack_recv, "ms"),
222
+ "last_data_recv": quantity(info.tcpi_last_data_recv, "ms"),
223
+ }),
224
+ );
225
+ seen = Some(now);
226
+ }
227
+ // an ACK arrived since the last sample: what a frozen process's kernel keeps sending
228
+ if info.tcpi_last_ack_recv < age {
229
+ emit("ack", json!({ "last_ack_recv": quantity(info.tcpi_last_ack_recv, "ms") }));
230
+ }
231
+ age = info.tcpi_last_ack_recv;
232
+ if waiting && Path::new(&args.go).exists() {
233
+ send(&mut stream, &args.request)?;
234
+ waiting = false;
235
+ sent = 1;
236
+ awaiting = args.stream.is_some();
237
+ }
238
+ if let Some(count) = args.stream {
239
+ if !waiting && !awaiting {
240
+ if sent >= count {
241
+ emit("done", json!({ "requests": sent }));
242
+ return Ok(());
243
+ }
244
+ if monotonic_ns() >= next_at {
245
+ send(&mut stream, &args.request)?;
246
+ sent += 1;
247
+ awaiting = true;
248
+ }
249
+ }
250
+ }
251
+ if monotonic_ns() > deadline {
252
+ emit("deadline", json!({}));
253
+ return Ok(());
254
+ }
255
+ }
256
+ }
257
+
258
+ fn main() {
259
+ if let Err(error) = run() {
260
+ emit("fail", json!({ "why": error.to_string() }));
261
+ std::process::exit(1);
262
+ }
263
+ }
@@ -0,0 +1,268 @@
1
+ //! inject ACTION ARGS... [--at NS]
2
+ //!
3
+ //! A cell's fault, applied inside the network namespace this sidecar shares with the target (or,
4
+ //! for the server's side, with the client), so the target's image stays stock. Each lands in one
5
+ //! commit, bracketed by CLOCK_MONOTONIC stamps taken in the VM: a docker exec from Windows lands
6
+ //! 190 ms late at the median, and the bracket does not care.
7
+ //!
8
+ //! The actions, each with its lift:
9
+ //!
10
+ //! - `blackhole PEER [--direction both|in|out]` / `clear PEER [--direction …]`: the peer's
11
+ //! segments drop, in both directions or one. `in` drops what comes from the peer, `out` what
12
+ //! goes to it: a simplex partition.
13
+ //! - `rst PEER` / `clear-rst PEER`: the peer's next segment is answered with a reset, so its next
14
+ //! send fails at once. A REJECT resets only the sender of the matched segment: the target's
15
+ //! process never hears of it.
16
+ //! - `reject PEER --code host|net|port|admin` / `clear-reject PEER --code …`: each segment from the
17
+ //! peer is answered with that ICMP unreachable. On an established socket Linux keeps it as a
18
+ //! soft error (tcp_v4_err) and goes on retransmitting.
19
+ //! - `mtu PEER --above N` / `clear-mtu PEER --above N`: each segment from the peer longer than N
20
+ //! bytes drops, as a path MTU black hole does, in the namespace that receives it. It acts on
21
+ //! segments only when the sender's TSO and GSO and the receiver's GRO are off (`offload`).
22
+ //! - `netem DEV SPEC…` / `clear-netem DEV`: tc netem on the device's egress, for example
23
+ //! `netem eth0 delay 100ms 10ms` or `netem eth0 loss 5%`.
24
+ //! - `offload DEV on|off`: the device's TSO, GSO and GRO, so that netem and length matches act on
25
+ //! segments rather than on GSO super-packets of up to 64 KB.
26
+ //!
27
+ //! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
28
+ //! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
29
+ //! its 190 ms never reach the stall's length.
30
+
31
+ use prober::monotonic_ns;
32
+ use serde_json::json;
33
+ use std::error::Error;
34
+ use std::io::Write;
35
+ use std::net::Ipv4Addr;
36
+ use std::process::{Command, Stdio};
37
+
38
+ /// sleeps until an instant on CLOCK_MONOTONIC, or returns at once if it has passed
39
+ fn until(at: u64) -> Result<(), Box<dyn Error>> {
40
+ let deadline = libc::timespec {
41
+ // the fields' own types: musl is moving time_t to 64 bits
42
+ tv_sec: (at / 1_000_000_000) as _,
43
+ tv_nsec: (at % 1_000_000_000) as _,
44
+ };
45
+ loop {
46
+ // SAFETY: a valid timespec, and no remainder is asked for with an absolute time
47
+ let code = unsafe {
48
+ libc::clock_nanosleep(
49
+ libc::CLOCK_MONOTONIC,
50
+ libc::TIMER_ABSTIME,
51
+ &deadline,
52
+ std::ptr::null_mut(),
53
+ )
54
+ };
55
+ match code {
56
+ 0 => return Ok(()),
57
+ libc::EINTR => continue,
58
+ other => return Err(format!("clock_nanosleep failed: {other}").into()),
59
+ }
60
+ }
61
+ }
62
+
63
+ /// The options after the positional arguments: `--at NS`, `--direction D`, `--code C`, `--above N`.
64
+ #[derive(Default)]
65
+ struct Options {
66
+ at: Option<u64>,
67
+ direction: Option<String>,
68
+ code: Option<String>,
69
+ above: Option<u32>,
70
+ }
71
+
72
+ fn options(args: &[String]) -> Result<Options, Box<dyn Error>> {
73
+ let mut out = Options::default();
74
+ let mut rest = args.iter();
75
+ while let Some(flag) = rest.next() {
76
+ let value = rest.next().ok_or_else(|| format!("{flag} takes a value"))?;
77
+ match flag.as_str() {
78
+ "--at" => out.at = Some(value.parse()?),
79
+ "--direction" => out.direction = Some(value.clone()),
80
+ "--code" => out.code = Some(value.clone()),
81
+ "--above" => out.above = Some(value.parse()?),
82
+ other => return Err(format!("unknown option {other}").into()),
83
+ }
84
+ }
85
+ Ok(out)
86
+ }
87
+
88
+ /// The iptables rules of an action that is a filter rule, or None for an action that is not.
89
+ fn rules(action: &str, peer: Ipv4Addr, opts: &Options) -> Result<String, Box<dyn Error>> {
90
+ let op = |set: &str| if action == set { "-I" } else { "-D" };
91
+ Ok(match action {
92
+ "blackhole" | "clear" => {
93
+ let op = op("blackhole");
94
+ let direction = opts.direction.as_deref().unwrap_or("both");
95
+ let input = format!("{op} INPUT -s {peer}/32 -j DROP\n");
96
+ let output = format!("{op} OUTPUT -d {peer}/32 -j DROP\n");
97
+ match direction {
98
+ "both" => format!("{input}{output}"),
99
+ "in" => input,
100
+ "out" => output,
101
+ other => return Err(format!("unknown direction {other}").into()),
102
+ }
103
+ }
104
+ "rst" | "clear-rst" => {
105
+ let op = op("rst");
106
+ format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with tcp-reset\n")
107
+ }
108
+ "reject" | "clear-reject" => {
109
+ let op = op("reject");
110
+ let with = match opts.code.as_deref().ok_or("--code host|net|port|admin")? {
111
+ "host" => "icmp-host-unreachable",
112
+ "net" => "icmp-net-unreachable",
113
+ "port" => "icmp-port-unreachable",
114
+ "admin" => "icmp-admin-prohibited",
115
+ other => return Err(format!("unknown code {other}").into()),
116
+ };
117
+ format!("{op} INPUT -s {peer}/32 -p tcp -j REJECT --reject-with {with}\n")
118
+ }
119
+ // the long segments drop where they arrive: in the sender's own namespace, an OUTPUT drop
120
+ // would fail its sends with EPERM rather than lose them on the way
121
+ "mtu" | "clear-mtu" => {
122
+ let op = op("mtu");
123
+ let above = opts.above.ok_or("--above N")?;
124
+ format!("{op} INPUT -s {peer}/32 -p tcp -m length --length {}:65535 -j DROP\n", above + 1)
125
+ }
126
+ other => return Err(format!("unknown action {other}").into()),
127
+ })
128
+ }
129
+
130
+ /// a token of a netem specification: a word, or a number with an optional unit
131
+ fn netem_token(token: &str) -> bool {
132
+ let word = !token.is_empty() && token.chars().all(|c| c.is_ascii_lowercase());
133
+ let number = token
134
+ .trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '%')
135
+ .parse::<f64>()
136
+ .is_ok()
137
+ && token
138
+ .trim_start_matches(|c: char| c.is_ascii_digit() || c == '.')
139
+ .chars()
140
+ .all(|c| c.is_ascii_alphabetic() || c == '%');
141
+ word || number
142
+ }
143
+
144
+ /// a device name: letters, digits, dots, dashes
145
+ fn device(name: &str) -> Result<&str, Box<dyn Error>> {
146
+ if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
147
+ Ok(name)
148
+ } else {
149
+ Err(format!("not a device: {name}").into())
150
+ }
151
+ }
152
+
153
+ /// runs a command and fails with its stderr when it fails
154
+ fn command(program: &str, args: &[&str]) -> Result<(), Box<dyn Error>> {
155
+ let out = Command::new(program).args(args).output()?;
156
+ if !out.status.success() {
157
+ return Err(format!(
158
+ "{program} {} exited {}: {}",
159
+ args.join(" "),
160
+ out.status,
161
+ String::from_utf8_lossy(&out.stderr).trim()
162
+ )
163
+ .into());
164
+ }
165
+ Ok(())
166
+ }
167
+
168
+ fn run() -> Result<(), Box<dyn Error>> {
169
+ let args: Vec<String> = std::env::args().skip(1).collect();
170
+ let action = args.first().ok_or("say an action: blackhole, clear, rst, reject, mtu, netem, offload …")?.as_str();
171
+ match action {
172
+ "netem" | "clear-netem" | "offload" => {
173
+ let dev = device(args.get(1).ok_or("and a device")?)?;
174
+ let rest: Vec<&str> = args[2..].iter().map(String::as_str).collect();
175
+ let (spec, at) = match rest.iter().position(|token| *token == "--at") {
176
+ Some(index) => (
177
+ rest[..index].to_vec(),
178
+ Some(rest.get(index + 1).ok_or("--at takes an instant in ns")?.parse::<u64>()?),
179
+ ),
180
+ None => (rest, None),
181
+ };
182
+ if let Some(at) = at {
183
+ until(at)?;
184
+ }
185
+ let t0 = monotonic_ns();
186
+ match action {
187
+ "netem" => {
188
+ if spec.is_empty() || !spec.iter().all(|token| netem_token(token)) {
189
+ return Err(format!("not a netem specification: {}", spec.join(" ")).into());
190
+ }
191
+ let mut tc = vec!["qdisc", "replace", "dev", dev, "root", "netem"];
192
+ tc.extend(spec.iter().copied());
193
+ command("tc", &tc)?;
194
+ }
195
+ "clear-netem" => command("tc", &["qdisc", "del", "dev", dev, "root"])?,
196
+ _ => {
197
+ let state = match spec.as_slice() {
198
+ ["on"] => "on",
199
+ ["off"] => "off",
200
+ _ => return Err("offload takes on or off".into()),
201
+ };
202
+ command("ethtool", &["-K", dev, "tso", state, "gso", state, "gro", state])?;
203
+ }
204
+ }
205
+ let t1 = monotonic_ns();
206
+ println!("{}", json!({ "action": action, "device": dev, "spec": spec, "t0": t0, "t1": t1 }));
207
+ Ok(())
208
+ }
209
+ _ => {
210
+ // parsed, never pasted: only an address reaches the rules
211
+ let peer: Ipv4Addr = args.get(1).ok_or("and the peer's address")?.parse()?;
212
+ let opts = options(&args[2..])?;
213
+ let script = format!("*filter\n{}COMMIT\n", rules(action, peer, &opts)?);
214
+ if let Some(at) = opts.at {
215
+ until(at)?;
216
+ }
217
+ let t0 = monotonic_ns();
218
+ let mut child =
219
+ Command::new("iptables-restore").arg("--noflush").stdin(Stdio::piped()).spawn()?;
220
+ child.stdin.take().ok_or("iptables-restore took no stdin")?.write_all(script.as_bytes())?;
221
+ let status = child.wait()?;
222
+ let t1 = monotonic_ns();
223
+ if !status.success() {
224
+ return Err(format!("iptables-restore exited {status}").into());
225
+ }
226
+ println!("{}", json!({ "action": action, "peer": peer.to_string(), "t0": t0, "t1": t1 }));
227
+ Ok(())
228
+ }
229
+ }
230
+ }
231
+
232
+ fn main() {
233
+ if let Err(error) = run() {
234
+ eprintln!("inject: {error}");
235
+ std::process::exit(1);
236
+ }
237
+ }
238
+
239
+ #[cfg(test)]
240
+ mod tests {
241
+ use super::*;
242
+
243
+ #[test]
244
+ fn netem_specifications_are_words_and_numbers_with_units() {
245
+ for token in ["delay", "100ms", "10ms", "loss", "5%", "25.5%", "distribution", "normal", "1"] {
246
+ assert!(netem_token(token), "{token}");
247
+ }
248
+ for token in ["", ";", "100ms;rm", "$(x)", "-1", "a b"] {
249
+ assert!(!netem_token(token), "{token}");
250
+ }
251
+ }
252
+
253
+ #[test]
254
+ fn rules_cover_each_direction_code_and_length() {
255
+ let peer: Ipv4Addr = "10.0.0.2".parse().unwrap();
256
+ let with = |direction: &str| Options { direction: Some(direction.into()), ..Options::default() };
257
+ assert_eq!(rules("blackhole", peer, &with("in")).unwrap(), "-I INPUT -s 10.0.0.2/32 -j DROP\n");
258
+ assert_eq!(rules("clear", peer, &with("out")).unwrap(), "-D OUTPUT -d 10.0.0.2/32 -j DROP\n");
259
+ let code = Options { code: Some("net".into()), ..Options::default() };
260
+ assert!(rules("reject", peer, &code).unwrap().contains("icmp-net-unreachable"));
261
+ let above = Options { above: Some(1200), ..Options::default() };
262
+ assert_eq!(
263
+ rules("mtu", peer, &above).unwrap(),
264
+ "-I INPUT -s 10.0.0.2/32 -p tcp -m length --length 1201:65535 -j DROP\n"
265
+ );
266
+ assert!(rules("reject", peer, &Options::default()).is_err());
267
+ }
268
+ }
@@ -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
+ }