AtlatestRepositoryapiary
1
# Transport failure harness3
Constructs network failures that a socket close cannot imitate, and4
drives apiary against them.6
Everything here talks to a **throwaway local enclave-server**. Nothing7
in this directory should ever be pointed at a deployment anyone is8
using: the whole point is to break the link on purpose.10
## Why it exists12
Apiary's unit tests cover the keepalive state machine, and `smoke.sh`13
covers a live connect. Neither can produce the failure that matters14
most in practice: a **blackholed route**, where bytes simply stop15
arriving and no FIN or RST is ever sent. The peer's kernel keeps the16
socket `ESTABLISHED` forever and every local write still succeeds. That17
is what a laptop changing networks looks like from the other end, and18
it is the condition behind both of the defects this harness measured.20
## Files22
| file | what it does |23
|---|---|24
| `proxy.py` | freeze-proxy: blackholes live sockets, or drops new SYNs |25
| `proxy_control_test.py` | proves the SYN-drop control can go RED |26
| `mcpdrv.py` | JSON-RPC driver for apiary's MCP-stdio interface |27
| `env-up.sh` / `env-down.sh` | throwaway enclave-server lifecycle |28
| `exp_confirm_latency.py` | post-confirmation latency distribution |29
| `exp_silent_loss.py` | does a post during an outage claim delivery? |30
| `exp_chatty_vs_silent.py` | does a posting bot still detect a dead link? |31
| `exp_fd_flatness.py` | do the failure paths leak file descriptors? |33
Apiary has no CLI — the binary speaks MCP JSON-RPC on stdin/stdout — so34
`mcpdrv.py` is how a test script calls a tool and observes what the bot35
received, without needing an MCP client.37
## Running39
```sh40
ENCLAVE_BIN=/path/to/enclave ./test/harness/env-up.sh41
APIARY_BIN=$PWD/build/release/bin/apiary \42
python3 test/harness/exp_silent_loss.py43
./test/harness/env-down.sh44
```46
`SIGIL_LOG_LEVEL=debug` makes apiary's per-post confirmation lines47
visible, which several experiments parse.49
### Always run an experiment as a matched PAIR51
Point `APIARY_BIN` at the pre-change binary, then at the post-change52
one, and compare. A single reading tells you what a binary does; it53
cannot tell you what a change did. `APIARY_BIN` is deliberately54
required with no default, because a pair in which both arms silently55
ran the same binary is worse than no measurement — it looks like a56
result.58
Watch the **internal control** in each experiment (the arm that should59
NOT change) as closely as the headline number. If it moves between60
arms, the two runs are not comparable and the delta is not61
attributable to the code.63
## THE TEARDOWN HAZARD — read this before stopping anything65
**Stop servers by the exact PID recorded in `state.env`. Never by66
pattern.**68
`env-down.sh` does this. Do not replace it with `pkill -f enclave`,69
`pkill -f apiary`, or anything similar, for two independent reasons:71
1. **Concurrent harnesses on one host are normal, not an accident.**72
More than one of these environments has been found running at once,73
from the same binary path, belonging to different work. A pattern74
kill takes the other one down silently.75
2. **A pattern can match the killing process itself.** An agent76
session's own command line typically contains its task name, its77
worktree path and its launch arguments. `pkill -f <task-keyword>`78
has terminated the very session that ran it, mid-measurement, with79
no output explaining why.81
If you need to stop something whose PID you did not record, kill by82
port (`fuser -k <port>/tcp`) rather than by pattern.84
## Gotchas that will otherwise cost you an hour86
**The bot quota is 3 per owner and the leader bot counts.** So one87
leader plus **two** workers is the ceiling, and `exp_chatty_vs_silent`88
sits exactly at it. Revoking a bot DISABLES the row rather than89
deleting it, so nicks stay burned and quota is only released when the90
row expires. Bring the environment up fresh between experiments rather91
than debugging `quota-exceeded`.93
**A single-token message body is dropped in transit.** apiary writes94
`PRIVMSG #chan :SPACELESS`, and the server relays it as95
`PRIVMSG #chan SPACELESS` — no colon — so the receiving parser reads96
the body as a middle parameter and the text arrives EMPTY. Measured on97
the wire on both legs; `two words` relays intact in the same run. Every98
payload in this directory is multi-word for that reason. A harness99
using single-token tags would see its messages "lost" for a reason100
that has nothing to do with what it is testing.102
**Extra CAPs are lost on the reconnect path.** After a reconnect,103
`acked=sasl` only, with a `CAP REQ timed out — falling back` warning.104
So anything you build on `echo-message`, `draft/multiline` or105
`message-tags` is unavailable on exactly the path that follows an106
outage. Verify against a reconnected connection, not just a fresh one.108
**`sever_existing()` and `freeze_existing()` are different failures.**109
Severing closes cleanly and the peer sees EOF within milliseconds;110
freezing sends nothing at all and detection takes the keepalive's full111
window. Use severing only when you want the reconnect path itself,112
not the detection that precedes it.114
**The proxy still forwards the server's FIN in frozen mode.** So a bot115
can be rescued by the server giving up on it, which a real blackhole116
would never deliver — that close travels the same dead path. Never cite117
a `reconnected` line as evidence that a bot detected anything itself;118
only its own `PONG timeout` is that evidence. The experiments here119
report the two separately for this reason.121
## The SYN-drop positive control — do not remove it123
`blackhole_new()` stops new connections by filling the accept queue and124
never accepting, so the kernel drops further SYNs and a connect hangs125
for its full retransmit budget (~135s).127
If the queue does not actually fill, the listening socket **refuses**128
instead, and a connect fails in about 5 seconds. Refused and dropped129
are **different failures**, not a strong and a weak version of one. An130
experiment that believes it is measuring the first while measuring the131
second still produces numbers, and the numbers still look like results.133
So the mode carries two controls, and they answer different questions:135
- `assert_accepting()` — the **negative control**. Proves the probe can136
see a HEALTHY port. Without it, a probe that reported "hung"137
unconditionally would pass the positive control for free.138
- `assert_blackholing()` — the **positive control**. Proves new139
connections HANG, and raises loudly and specifically if they are140
merely refused.142
`python3 test/harness/proxy.py` runs both and prints the143
classification either side of the switch:145
```146
before blackhole_new(): fresh connect -> 'connected'147
[PASS] negative control: probe sees a HEALTHY port148
after blackhole_new(): fresh connect -> 'hung'149
[PASS] positive control: new connections are DROPPED, not refused150
```152
`python3 test/harness/proxy_control_test.py` then breaks the mode three153
ways and asserts the control goes red for each — listener closed154
(→ refused), `blackhole_new()` a no-op, and a backlog large enough that155
the kernel completes the handshake by itself. A control that has only156
ever been seen passing is indistinguishable from one that always157
passes.159
Both controls score their own single probe. An earlier version probed160
separately and compared the answers, which is a race rather than a161
check — two connects can land on different port states, and one did.