Files
fips/testing/native-api/client.py
Johnathan Corgan 70002baf20 Settle the native-api counter reads instead of racing the reader task
The suite asserted a happens-before the native API does not offer. A client's
write on a flow descriptor lands in the kernel buffer of an AF_UNIX
socketpair and runs no daemon code; the counters advance only inside the
per-flow reader task, after that task's own recv().await. `stats` is answered
on a different task and loads the same atomics, and the daemon is a
single-threaded runtime, so a stats reply can be produced while the datagrams
are still queued and the reader task has not been polled. The reply is then a
well-formed status ok with a live flow_id and local_port and both counters at
zero, which is the shape that redded maint at c5aeef39 and next at 1c822ae9.

The crate's own tests already concede this. Connection::settle is a bounded
yield loop whose doc comment says it yields "rather than asserting into a
race a test would lose intermittently", and the in-crate assertions run
behind it. The shell harness had no equivalent, though it half knew: one
counter read is followed by a sleep whose comment calls it "the task hop",
which is why that step passed while its neighbours did not.

An RPC step may now carry "settle", which re-asks the command until its
expectations hold or a five-second deadline passes. A bounded re-ask is a
barrier where a fixed sleep is a guess, and a datagram that never arrives
still reds rather than hanging. Nothing here serializes anything.

Reproduced and measured rather than reasoned about. Against a daemon
throttled to 0.02 CPU with twenty concurrent clients, the unsettled read
failed 9 of 60 runs, every failure carrying the zero-counter signature, while
the settling read failed 0 of 60 interleaved under the same load. An
expectation that can never hold still reds, at the five-second bound. The
full suite passes 28 of 28.

The close scenario's one-second sleep is replaced by settling the release
check, since the same task hop delays the daemon noticing end of file.

Two verdict lines are corrected while here. Both were canned else-branch
strings that fire on any non-zero client exit, so each named a cause the run
never observed: one reported traffic crossing between flows when the evidence
was a zero counter, the other reported a flow not being released when the
failing assertion was a datagram count.

The two-flow scenario's second read is deliberately left alone, with a note
saying why: settling re-asks until an expectation holds, and "b counted 0"
holds on the first ask whether or not b's reader has run, so that assertion
stays a false green until it makes a positive claim.
2026-09-07 18:27:28 +00:00

474 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Native datagram API client for the increment checks.
Speaks the line-delimited JSON command protocol on the daemon's native API
socket. A run takes a script: a list of steps sent over ONE connection. The
connection owns nothing — a flow lives until its own descriptor is closed, and a
listener until its own is — so the single connection is a convenience for the
checks rather than a lifetime the daemon respects. Descriptors are what keep
things alive, and this tool holds them until the step that closes them or until
it exits.
Kinds of step:
RPC step: {"command": str, "params": {...}?, "expect": {"dotted.key": val}?,
"settle": bool?,
"keep_fd": name?, "keep_listener": name?, "keep_flow": name?}
Sends a command and checks the reply. `keep_fd` stores a flow
descriptor under that name, `keep_listener` a listener descriptor;
every reply that carries one must name it, because a descriptor
nothing named is a flow or a port silently dropped. `keep_flow`
stores the reply's data.flow_id.
A parameter or expectation whose value is the string "@name" is
replaced by the flow identifier stored under `name`. Identifiers
are assigned by the node and keep counting up for its lifetime, so
a check that asserted a literal 1 would hold only for the first
flow the daemon ever made.
Accept step: {"accept": listener, "keep_fd": name, "expect": {...}?,
"keep_flow": name?}
One recvmsg on a stored listener descriptor. There is no accept
command: an arriving flow is one SOCK_SEQPACKET message on the
listener itself, carrying the flow's descriptor as ancillary data
and the arrival object as its payload. Expectations are checked
against that object, whose peer is an npub and never a hex address.
Sleep step: {"sleep": seconds}
Holds every descriptor open for a while, which is what a check that
reads the daemon's own view of a live flow needs.
Flow step: {"fd": name, ...} operating on a stored descriptor:
"write": hex, "repeat": n? send n datagrams of those bytes
"read": n, "expect_bytes": hex?, "sizes": [..]?
read n datagrams and check them
"readable": bool check poll readability now
"close": true close the descriptor
`readable` and `close` work on a listener descriptor too: a
listener is pollable, and closing it unbinds its port.
Reading is per-datagram: the descriptor is SOCK_SEQPACKET, so one recv is one
datagram. A check that reads three and gets one concatenated blob is a real
failure, not a quirk of the tool.
Usage:
client.py --socket PATH --script '<json list of steps>'
client.py --socket PATH --script-file steps.json
Exit 0 when every expectation holds, 1 otherwise, 2 on a connection failure.
"""
from __future__ import annotations
import argparse
import array
import json
import os
import select
import socket
import sys
import time
from typing import Any
# A flow's descriptor and a listener's are both AF_UNIX SOCK_SEQPACKET, so the
# wrap happens to be the same for both. Naming the roles anyway is the point:
# the next descriptor kind that is not one of these must not be wrapped
# correctly by accident.
FLOW = "flow"
LISTENER = "listener"
# How long a settling step keeps re-asking, and how long it pauses between
# tries. The wait is for a task hop on a host that may be loaded, so it is
# seconds rather than milliseconds; it is bounded because a datagram that
# never arrives has to end the run red rather than hold it open.
SETTLE_SECONDS = 5.0
SETTLE_PAUSE = 0.02
def recvfds(sock: socket.socket, bufsize: int, maxfds: int) -> tuple[bytes, list[int]]:
"""One recvmsg, returning its payload and whatever descriptors it carried.
Written out rather than calling `socket.recv_fds`, which takes a `flags`
argument and never forwards it to `recvmsg`: MSG_CMSG_CLOEXEC passed to that
helper does nothing, and a descriptor the harness kept would then survive
into any child process it forked. Measured on CPython 3.12 by reading
FD_CLOEXEC back with `fcntl.F_GETFD` after each of the two calls.
"""
fds = array.array("i")
data, ancillary, _flags, _addr = sock.recvmsg(
bufsize, socket.CMSG_LEN(maxfds * fds.itemsize), socket.MSG_CMSG_CLOEXEC
)
for level, kind, payload in ancillary:
if level == socket.SOL_SOCKET and kind == socket.SCM_RIGHTS:
# Truncated to whole descriptors: the kernel may cut the array
# short, and a partial one names nothing.
fds.frombytes(payload[: len(payload) - (len(payload) % fds.itemsize)])
return data, list(fds)
class Protocol(Exception):
"""The daemon broke the local protocol, so the run cannot continue."""
class Client:
"""One connection to the native API socket, plus the descriptors it holds."""
def __init__(self, path: str, timeout: float) -> None:
"""Connect to the socket at `path`, failing after `timeout` seconds."""
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(timeout)
self.sock.connect(path)
self.timeout = timeout
self.buf = b""
# Complete lines, oldest first, each with the descriptor it arrived
# with. See `fill` for the rule that decides which line that is.
self.lines: list[list[Any]] = []
self.fds: dict[str, tuple[socket.socket, str]] = {}
self.flows: dict[str, int] = {}
def call(self, command: str, params: dict | None) -> tuple[dict, int | None]:
"""Send one command; return the decoded reply and any descriptor.
The socket carries replies only, in command order, so the next complete
line is this command's answer and there is nothing to separate out.
"""
request: dict[str, Any] = {"command": command}
if params is not None:
request["params"] = params
self.sock.sendall(json.dumps(request).encode() + b"\n")
line, fd = self.line()
return json.loads(line), fd
def line(self) -> tuple[bytes, int | None]:
"""Take the next complete line, reading until one is available."""
while not self.lines:
self.fill()
line, fd = self.lines.pop(0)
return line, fd
def fill(self) -> None:
"""One recvmsg, split into lines, with any descriptor placed by the rule.
A DESCRIPTOR BELONGS TO THE LAST COMPLETE LINE OF THE READ THAT CARRIED
IT, never to the next line the reader assembles. A recvmsg returning
ancillary data ends exactly at the end of the sendmsg that carried it,
but it may begin with any amount of data written before it, so a reader
that attached the descriptor to the first line it completed would hand a
flow to the wrong reply. Both reply kinds carry a descriptor now, so
this is reachable rather than theoretical.
A read that carries a descriptor and completes no line is reported
rather than guessed at: holding it would mean choosing a later line for
it, and choosing wrong loses a flow with no error anywhere.
"""
chunk, fds = recvfds(self.sock, 65536, 4)
if not chunk:
for stray in fds:
# Closed rather than leaked: nothing can name it now.
os.close(stray)
raise ConnectionError("daemon closed the connection")
self.buf += chunk
produced = 0
while b"\n" in self.buf:
line, self.buf = self.buf.split(b"\n", 1)
self.lines.append([line, None])
produced += 1
if not fds:
return
# This protocol never sends two at once. Extras are closed rather than
# left open with no owner.
for stray in fds[1:]:
os.close(stray)
if produced == 0:
os.close(fds[0])
raise Protocol("a descriptor arrived on a read that completed no line")
self.lines[-1][1] = fds[0]
def accept(self, listener: str) -> tuple[dict, int]:
"""Take the next arriving flow off a stored listener descriptor.
One recvmsg, one arrival: SOCK_SEQPACKET means the message carries
exactly its own descriptor, so the association rule the RPC socket needs
does not arise here. The payload has no trailing newline, because the
message boundary is the framing.
"""
sock = self.held(listener, LISTENER)
data, fds = recvfds(sock, 65536, 1)
if not fds:
raise Protocol(f"{listener!r} produced an arrival with no descriptor")
if not data:
os.close(fds[0])
raise Protocol(f"{listener!r} produced a descriptor with no arrival")
return json.loads(data), fds[0]
def held(self, name: str, want: str) -> socket.socket:
"""Return a stored descriptor, refusing one of the wrong kind."""
if name not in self.fds:
raise Protocol(f"no descriptor named {name!r}")
sock, role = self.fds[name]
if role != want:
raise Protocol(f"{name!r} is a {role} descriptor, not a {want} one")
return sock
def keep(self, name: str, fd: int, role: str) -> None:
"""Store a received descriptor under `name`, wrapped for its kind."""
sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET, fileno=fd)
sock.settimeout(self.timeout)
self.fds[name] = (sock, role)
def close(self) -> None:
"""Close every descriptor, then the connection itself."""
for sock, _role in self.fds.values():
sock.close()
self.sock.close()
def substitute(value: Any, flows: dict[str, int]) -> Any:
"""Replace every "@name" with the flow identifier stored under `name`."""
if isinstance(value, str) and value.startswith("@"):
name = value[1:]
if name not in flows:
raise KeyError(f"no flow captured as {name!r}")
return flows[name]
if isinstance(value, dict):
return {key: substitute(item, flows) for key, item in value.items()}
if isinstance(value, list):
return [substitute(item, flows) for item in value]
return value
def dig(value: Any, dotted: str) -> Any:
"""Read a dotted path out of a decoded reply, or None where it is absent."""
for key in dotted.split("."):
if not isinstance(value, dict) or key not in value:
return None
value = value[key]
return value
def check(reply: dict, expect: dict) -> list[str]:
"""Return one message per expectation the reply does not satisfy."""
problems = []
for dotted, wanted in expect.items():
got = dig(reply, dotted)
if got != wanted:
problems.append(f"{dotted}: wanted {wanted!r}, got {got!r}")
return problems
def store(client: Client, step: dict, body: dict, fd: int | None) -> list[str]:
"""Store what a step asked to keep, reporting a descriptor nobody named."""
problems: list[str] = []
keep = step.get("keep_flow")
if keep is not None:
# A reply nests the identifier under `data`; an arrival message is the
# object itself. One reader for both, because a step should not have to
# know which produced it.
flow = dig(body, "data.flow_id")
if flow is None:
flow = body.get("flow_id")
if flow is None:
problems.append("keep_flow: nothing carried a flow_id")
else:
client.flows[keep] = flow
wanted = [(step.get("keep_fd"), FLOW), (step.get("keep_listener"), LISTENER)]
named = [(name, role) for name, role in wanted if name is not None]
if len(named) > 1:
if fd is not None:
os.close(fd)
problems.append("a step named both keep_fd and keep_listener")
elif named and fd is None:
problems.append(f"{named[0][0]!r}: no descriptor arrived to keep")
elif named:
client.keep(named[0][0], fd, named[0][1])
elif fd is not None:
# Leaving it unnamed would leak a flow or a held port for the rest of
# the run, with nothing to say so.
os.close(fd)
problems.append("a descriptor arrived that the step did not name")
return problems
def run_rpc(client: Client, step: dict) -> list[str]:
"""Send one command and report what did not hold.
A step naming `settle` is asked again until its expectations hold or the
deadline passes. What `stats` reports is advanced by the daemon's per-flow
reader task, and nothing orders that task against the client's write on the
flow descriptor: one ask can be answered while a datagram is still queued in
the kernel socket buffer, and it reads back as zero. Re-asking is the only
barrier this protocol offers, and the deadline is what keeps a datagram that
never arrives a failure rather than a hang.
"""
command = step["command"]
try:
params = substitute(step.get("params"), client.flows)
expect = substitute(step.get("expect", {}), client.flows)
except KeyError as error:
return [str(error)]
settle = bool(step.get("settle"))
if settle and (step.get("keep_fd") or step.get("keep_listener")):
# Every ask but the last is discarded, and a discarded reply's
# descriptor has no owner. Refusing beats closing one a later step
# meant to keep.
return ["settle: a step that keeps a descriptor cannot be re-asked"]
deadline = time.monotonic() + SETTLE_SECONDS
while True:
reply, fd = client.call(command, params)
problems = check(reply, expect)
if not problems or not settle or time.monotonic() >= deadline:
break
if fd is not None:
os.close(fd)
time.sleep(SETTLE_PAUSE)
problems += store(client, step, reply, fd)
if problems:
problems.append(f"reply: {json.dumps(reply)}")
return problems
def run_accept(client: Client, step: dict) -> list[str]:
"""Take one arrival off a listener and report what did not hold."""
try:
arrival, fd = client.accept(step["accept"])
except socket.timeout:
return [f"timed out waiting for an arrival on {step['accept']!r}"]
problems = check(arrival, substitute(step.get("expect", {}), client.flows))
problems += store(client, step, arrival, fd)
if problems:
problems.append(f"arrival: {json.dumps(arrival)}")
return problems
def run_sleep(step: dict) -> list[str]:
"""Hold every descriptor open for a while, failing nothing."""
time.sleep(float(step["sleep"]))
return []
def run_flow(client: Client, step: dict) -> list[str]:
"""Operate on a stored descriptor and report what did not hold."""
name = step["fd"]
if name not in client.fds:
return [f"no descriptor named {name!r}"]
flow, _role = client.fds[name]
problems: list[str] = []
if "readable" in step:
ready, _, _ = select.select([flow], [], [], 0.25)
got = bool(ready)
if got != step["readable"]:
problems.append(f"readable: wanted {step['readable']}, got {got}")
if "write" in step:
payload = bytes.fromhex(step["write"])
for _ in range(step.get("repeat", 1)):
flow.send(payload)
if "read" in step:
wanted = bytes.fromhex(step["expect_bytes"]) if "expect_bytes" in step else None
sizes = []
for index in range(step["read"]):
try:
got = flow.recv(65536)
except socket.timeout:
problems.append(f"read {index}: timed out waiting for a datagram")
break
sizes.append(len(got))
if wanted is not None and got != wanted:
problems.append(
f"read {index}: wanted {wanted.hex()}, got {got.hex()}"
)
if "sizes" in step and sizes != step["sizes"]:
problems.append(f"sizes: wanted {step['sizes']}, got {sizes}")
if step.get("close"):
flow.close()
del client.fds[name]
return problems
def label_of(step: dict) -> str:
"""The name a step is reported under, which callers wait on by substring."""
if "fd" in step:
return f"fd {step['fd']}"
if "accept" in step:
return f"accept {step['accept']}"
if "sleep" in step:
return f"sleep {step['sleep']}"
return step.get("command", "?")
def main() -> int:
"""Run the script against the socket and report every failing step."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--socket", required=True, help="native API socket path")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--script", help="steps as a JSON list")
group.add_argument("--script-file", help="file holding the steps as a JSON list")
parser.add_argument(
"--timeout",
type=float,
default=5.0,
help="socket timeout in seconds (default: 5)",
)
args = parser.parse_args()
text = args.script
if text is None:
with open(args.script_file, encoding="utf-8") as handle:
text = handle.read()
steps = json.loads(text)
try:
client = Client(args.socket, args.timeout)
except OSError as error:
print(f"connect to {args.socket} failed: {error}", file=sys.stderr)
return 2
failures = 0
try:
for index, step in enumerate(steps):
label = label_of(step)
try:
if "fd" in step:
problems = run_flow(client, step)
elif "accept" in step:
problems = run_accept(client, step)
elif "sleep" in step:
problems = run_sleep(step)
else:
problems = run_rpc(client, step)
except (OSError, ConnectionError, Protocol, json.JSONDecodeError) as error:
print(f"step {index} ({label}): {error}", file=sys.stderr)
return 2
if problems:
failures += 1
print(f"step {index} ({label}) FAILED", file=sys.stderr)
for problem in problems:
print(f" {problem}", file=sys.stderr)
else:
print(f"step {index} ({label}) ok")
finally:
client.close()
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())