@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.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/build/index.d.ts +546 -0
- package/build/index.js +1126 -0
- package/build/index.js.map +1 -0
- package/image/Dockerfile +31 -0
- package/image/native/Cargo.lock +123 -0
- package/image/native/Cargo.toml +16 -0
- package/image/native/src/bin/client.rs +263 -0
- package/image/native/src/bin/inject.rs +268 -0
- package/image/node/Dockerfile +11 -0
- package/image/node/client-ioredis.mjs +105 -0
- package/image/node/npm-lock.json +96 -0
- package/image/node/package.json +8 -0
- package/package.json +76 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Russell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,112 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mark1russell7/harness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A fault-injection harness for connection timers. In a cell, a client connects to a stock target in new Docker environments. The harness injects a fault, and it measures what the kernel did. Then the harness judges the measurement against the prediction of the cell.
|
|
4
|
+
|
|
5
|
+
The harness does not compute predictions. The author of a cell computes the prediction, for example from a timer algebra, and puts it in the cell.
|
|
6
|
+
|
|
7
|
+
## The cell
|
|
8
|
+
|
|
9
|
+
A **cell** (`cell.ts`) is all that one measurement needs, as data:
|
|
10
|
+
|
|
11
|
+
- **The topology.** A stock target image, pinned by digest.
|
|
12
|
+
- **The settings.** The sysctls of the client namespace, and the socket options of the client.
|
|
13
|
+
- **The workload.** A connection that stays idle, or a connection with a request in flight.
|
|
14
|
+
- **The fault.** A black hole, a reset, a frozen process, or a stall.
|
|
15
|
+
- **The prediction.** An interval [T_lo, T_hi] after the event that arms the timer, or silence in a window. After a stall, the prediction also tells when the connection recovers.
|
|
16
|
+
|
|
17
|
+
## The environment
|
|
18
|
+
|
|
19
|
+
`openLab(target)` (`lab.ts`) builds one environment with Testcontainers:
|
|
20
|
+
|
|
21
|
+
- A new network and the target.
|
|
22
|
+
- A client container that holds its network namespace.
|
|
23
|
+
- A privileged sidecar in the namespace of the client, for its sysctls. Docker Desktop keeps `/proc/sys` read-only in a container that operates.
|
|
24
|
+
- An injector sidecar in the namespace of the target, with NET_ADMIN. Thus the image of the target stays stock.
|
|
25
|
+
- The prober ([`@mark1russell7/prober`](https://www.npmjs.com/package/@mark1russell7/prober)): a privileged container that loads the kernel program and watches the two namespaces. Each run keeps its records from its start to its end. A run whose records the prober lost is void.
|
|
26
|
+
|
|
27
|
+
Each command is an argv array.
|
|
28
|
+
|
|
29
|
+
## The images
|
|
30
|
+
|
|
31
|
+
The harness builds two images. The tag of each image comes from the content of all the files that its Dockerfile reads. The harness copies these files into a new build context, from its own package and from the prober package. Thus the build works in a workspace and from npm.
|
|
32
|
+
|
|
33
|
+
- **The harness image** (`image/Dockerfile`) has two stages, each a digest-pinned Alpine. `Cargo.lock` pins the crates. The image holds:
|
|
34
|
+
- the kernel program of the prober, compiled against the BTF of the kernel that builds it, and its Rust collector.
|
|
35
|
+
- `client`, the TCP client of a cell. Rust's `std::net` sets no socket option by itself, thus the measurement gets the timers of the namespace. The client also samples `TCP_INFO` each 2 ms, a second vantage on its socket. It waits in `poll()`, which does not lock the socket. A keepalive timer that finds its socket held tries again HZ/20 later.
|
|
36
|
+
- `inject`, which applies a fault in one `iptables-restore` commit, with monotonic stamps around the commit. Thus the start of a fault is known in the VM. A `docker exec` from Windows comes 190 ms late at the median.
|
|
37
|
+
- **The Node client image** (`image/node`) is a driver client: ioredis 6.0.0 on Node 24.21.0. The digest of the image and an npm lockfile pin them. `client-ioredis.mjs` prints the lines of the TCP client on the same clock, and it adds the status changes of ioredis as `status` lines.
|
|
38
|
+
|
|
39
|
+
The build does the `cargo test` of the two crates. Thus the record decoder passes its tests before an image exists. Each amount that the native programs print is a quantity in a canonical unit. For example, an RTO is in `jiffy`, a round trip in `us`, and a segment in `B`.
|
|
40
|
+
|
|
41
|
+
ioredis sets the keepalive of its socket itself, after it connects. Thus a cell sets the keepalive of a driver through the keepAlive option of ioredis (`probe.driver`). The keepalive sysctls of the namespace do not apply to it.
|
|
42
|
+
|
|
43
|
+
## The faults
|
|
44
|
+
|
|
45
|
+
- **A black hole** is DROP rules in the two directions, in the namespace of the peer of the observer. Thus the measurement gets the kernel timers of the observer. For the client, the rules go in the namespace of the target, through the injector. For the server, they go in the namespace of the client, through its admin sidecar. Rules in the namespace of the observer make its sends fail with EPERM, and the kernel records only a retransmission that goes out (`FaultSide`).
|
|
46
|
+
- **A reset** is a `REJECT --reject-with tcp-reset` rule in the namespace of the target, for the segments of the client. The next send of the client gets a reset one round trip later. It is the reference fault of the calibration.
|
|
47
|
+
- **A frozen process** is the cgroup freezer (`docker pause`), while the kernel stays up.
|
|
48
|
+
- **A stall** is a black hole that stops by itself, `Cell.stall` after the send of the request. The run reads the stamp of the client on the send. Then `inject clear PEER --at NS` holds the end in the VM until that instant (`lab.heal`). Thus the latency of a docker exec does not change the length of the stall.
|
|
49
|
+
- **An ICMP unreachable** (`unreachable`, `Cell.icmp`) answers each segment from the observer with host, net or port unreachable, or admin prohibited. Linux keeps it as a soft error, continues to retransmit, and gives the errno of the ICMP at the timeout. Host and net unreachable also revert the backoff (RFC 6069).
|
|
50
|
+
- **A simplex partition** (`simplex`, `Cell.drops`) drops the segments to the server, or the segments to the client, but not both.
|
|
51
|
+
- **A FIN** (`fin`) stops the process of the peer with SIGKILL while its kernel stays up. For the client, the target starts its server as the child of a shell (`Target.command`), and `Target.kill` stops it. For the server, the harness stops the container of the client.
|
|
52
|
+
- **An MTU black hole** (`mtu-blackhole`, `Cell.mtu`) drops each segment longer than the length, in the namespace that receives it.
|
|
53
|
+
- **netem** (`netem`, `Cell.netem`) is tc netem on the egress of the target: loss, duplication, reordering or corruption. The outcome is a distribution.
|
|
54
|
+
- **A latency** (`Cell.latency`) is tc netem delay on the egress of the target before the client connects. Thus the round trip and the RTO include it.
|
|
55
|
+
|
|
56
|
+
A length match and netem act on GSO super-packets of up to 64 KB, not on segments, while TSO, GSO and GRO are on. Thus the cells of `mtu-blackhole` and `netem` turn them off on the two ends (`lab.offload`).
|
|
57
|
+
|
|
58
|
+
Each run starts from kernels that keep nothing from the run before it (`lab.forget()`). When a connection closes, each kernel keeps TCP metrics for the address of its peer. These metrics set the first round trip of the next connection, and thus its RTO. The harness removes the `tcp_metrics` of the two namespaces before each run.
|
|
59
|
+
|
|
60
|
+
## The measurement
|
|
61
|
+
|
|
62
|
+
`measure(cell, run)` (`observe.ts`) reads a run from the records of the kernel:
|
|
63
|
+
|
|
64
|
+
- **The socket.** The socket of the run is the socket that the client connected from, in its namespace.
|
|
65
|
+
- **The time.** It times the detection from the reference event of the prediction to the error report of the kernel. eBPF stamps the two events. For keepalive, the reference is the last received segment. For the give-up, it is the first retransmission of the RTO timer, not the tail loss probe one RTO before it. For a reset, it is the send of the client, which no kernel record marks. The client stamps the send before its write.
|
|
66
|
+
- **The other views.** It also gives the same interval from the `TCP_INFO` samples of the client, and the delay before the client saw the error. It also gives the steps of the kernel: each keepalive probe, or each expiry of the RTO timer.
|
|
67
|
+
- **Void runs.** A run is void when the timer that drives the measurement was two jiffies or more behind its wheel slot in the timed interval. The wheel does a slot in the tick when it is due. Thus such a firing measured the host, which stopped a vCPU, not the kernel. A keepalive timer that found its socket held also makes the run void. A run whose fault came too late is void too.
|
|
68
|
+
- **Findings.** A finding is a wrong errno, a detector that did not fire, or a detection where the prediction is silence.
|
|
69
|
+
- **Recovery.** After a stall (`prediction.recovers`), it times the send to the first segment back after the stall stopped. That segment is the answer of the server to the retransmission that got through. A run whose stall stopped outside the window of its prediction is void. A run where nothing came back is a finding.
|
|
70
|
+
- **An end of file.** A FIN counts from the change of state of the socket of the peer when it closes (`peer-closed`). It ends at the change of the socket of the observer to CLOSE_WAIT. The kernel stamps the two changes.
|
|
71
|
+
- **A stream.** In a stream (`probe.mode` is `stream`), the client sends `count` requests. The run keeps the time from each send to the first data after it (`samples`).
|
|
72
|
+
|
|
73
|
+
## The verdict
|
|
74
|
+
|
|
75
|
+
`verdictOf(prediction, runs)` (`verdict.ts`) judges a cell:
|
|
76
|
+
|
|
77
|
+
- **Containment.** Each run lands in [T_lo − δ_abs, T_hi + δ_abs]. `NOISE` (δ_abs) is 4 ms, from the calibration: the worst reset took 1.141 ms, and δ_abs is one jiffy at the minimum.
|
|
78
|
+
- **Median equivalence.** The order-statistic interval of the median must be in the band, made 5 % wider.
|
|
79
|
+
- **Extension.** A median across the edge of the band extends the cell from 11 runs to 22, then 44. Then the verdict is inconclusive.
|
|
80
|
+
- **Recovery.** The recovery after a stall gets the same test as a detection, by its band. A detection is then a finding.
|
|
81
|
+
- **Measurements.** A cell whose outcome is a distribution (`detects: null`) has no band. Its decision is `measured`, and the verdict gives the median and the slowest reply time.
|
|
82
|
+
|
|
83
|
+
The distribution test needs a simulator, which does not exist at this time.
|
|
84
|
+
|
|
85
|
+
## Runs
|
|
86
|
+
|
|
87
|
+
- **`runCell(cell)`** (`run.ts`) divides the 11 runs of a cell across two new environments. It records the manifest of each environment, and it removes the fault after each run.
|
|
88
|
+
- The `concurrency` of a cell sets how many environments operate at the same time, each with its own prober.
|
|
89
|
+
- The manifest holds the kernel release, the architecture, the boot id and the Docker version. It also holds the image ids, the packages of the image, the client and the sysctls as read back.
|
|
90
|
+
- A cell whose probe names a `driver` opens its labs with the client of that driver (`openLab(target, "ioredis")`).
|
|
91
|
+
- The harness sets the `targetSettings` of a cell in the target before a client connects, with the `settings` commands of the target (`redis-cli CONFIG SET`, then `CONFIG GET`).
|
|
92
|
+
- **The observer.** A cell observes the client, or the server (`observer`). A cell on the server side measures the socket of the target for the connection of the client. The run waits for the error of that socket. `freeze-process` freezes the peer of the observer: the target for the client, the client for the server.
|
|
93
|
+
- A cell on the server side sets the sysctls of the target through `targetSysctls`, with a privileged sidecar in the namespace of the target. That sidecar starts only when a cell asks for it.
|
|
94
|
+
- Its in-flight data is the reply of the server. Thus the client sends `probe.request` first, for example a BLPOP with a timeout, and the fault occurs while the server holds it.
|
|
95
|
+
- **`judge(cell, runs)`** (`run.ts`) measures and judges the raw records of a cell with the code at this time. `runCell` uses it during a cell. A holder of raw records can judge them again after a change to `observe.ts` or to the verdict.
|
|
96
|
+
- **Testcontainers loads when a lab first opens.** Code that only measures and judges does not load it.
|
|
97
|
+
|
|
98
|
+
## Installation
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
pnpm add @mark1russell7/harness zod
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A run needs Docker, and privileged containers for the prober and the sidecars. The kernel must have BTF (`/sys/kernel/btf/vmlinux`).
|
|
105
|
+
|
|
106
|
+
## Tests
|
|
107
|
+
|
|
108
|
+
`pnpm --filter @mark1russell7/harness test` starts the tests. They measure test records with the shape of real kernel records (keepalive, and the tail loss probe before the retransmissions of the RTO timer). They also test the containment, the median equivalence, the extension and the silence of the verdict.
|
|
109
|
+
|
|
110
|
+
The cells themselves need Docker and privileged containers. CI does not start them.
|
|
111
|
+
|
|
112
|
+
`pnpm --filter @mark1russell7/harness build` writes the package for npm into `build/`.
|