4"""Bench-host witness: what actually touched the hardware, and when.
6Why this exists separately from the lock
7----------------------------------------
8``scripts/hil/bench.sh`` and its journal are the lock's OWN bookkeeping. Asking
9the journal whether the lock worked is asking the defendant to testify. An
10actor that silently skipped its work looks, in the journal, exactly like one
11that waited politely and then did it -- both produce one ``acquire`` and one
14This file is the independent witness. It runs ON the bench host, samples
15``/proc``, and answers a question the lock cannot influence: **which process
16had the J-Link open, from which machine, between which two instants**. It takes
17no lock, opens no device and drives nothing; it only reads ``/proc``.
19Attribution without trusting anybody
20------------------------------------
21Every hardware tool on this bench is reached over ssh, so every such process is
22a descendant of an ``sshd`` session whose shell carries ``SSH_CONNECTION`` in
23its environment. Walking the ppid chain to that variable yields the PEER
24ADDRESS of the machine that started the tool -- read out of the kernel, not
25declared by the actor. Two tool processes with different peers alive at once is
26a direct observation of two machines on the board at the same time.
28Start instants are EXACT, not sampled: ``/proc/<pid>/stat`` field 22 is the
29process start in clock ticks since boot, and ``/proc/stat``'s ``btime`` turns
30that into a wall-clock instant on the bench host's clock -- the same clock the
31journal's timestamps come from. End instants are bracketed by the last sample
32that saw the process and the first that did not, so the analyser can widen
33every interval to its pessimistic bound and still conclude non-overlap.
37The client base64s this file onto an ssh command line, exactly the way
38``lib/bench_client.sh`` ships ``lib/bench_host.sh``. The bench Pi's copy of
39this tree is whatever a suite last left there; evidence must not depend on it.
43 bench_witness.py run --out FILE [--interval-ms 50] [--max-seconds 3600]
44 bench_witness.py sample # one census to stdout, for eyeballs
45 bench_witness.py --selftest # prove discovery both ways, offline
47``run`` stops when ``FILE.stop`` appears or ``--max-seconds`` elapses, so a
48witness can never outlive the run that started it.
50Every scan takes its procfs root as an argument rather than reaching for
51``/proc`` directly. That is what lets ``--selftest`` point the scanner at a
52SYNTHETIC procfs holding one planted tool and one planted decoy, and require it
53to find exactly one -- on any host, including a Mac with no ``/proc`` at all. A
54witness that had quietly stopped matching would otherwise report an empty
55timeline, and an empty timeline reads as "nothing overlapped".
57Exit 0 on a clean run, 1 on a failing selftest, 2 when /proc is not readable.
60from __future__
import annotations
68from pathlib
import Path
74DEFAULT_PROC = Path(
"/proc")
75DEFAULT_LOCK_DIR = Path(
"/var/lib/ra8-bench")
80TOOL_COMMS = frozenset(
84 "JLinkGDBServerCLExe",
96TOOL_CMDLINE_MARKS = (
"esptool",
"tapo_control.py")
99INTERPRETER_COMMS = frozenset({
"python3",
"python",
"python3.11",
"python3.12"})
105SCRIPT_FLAGS = (
"-commanderscript",
"-CommanderScript",
"-commandfile")
119STAT_STARTTIME_IDX = 19
124FAKE_BTIME = 1700000000.0
132def _read_text(path: Path) -> str:
133 """Read *path*, returning '' for anything that vanished or is forbidden."""
135 return path.read_text(errors=
"replace")
136 except (OSError, ValueError):
140def _read_bytes(path: Path) -> bytes:
141 """Read *path* as bytes, returning b'' for anything unreadable."""
143 return path.read_bytes()
144 except (OSError, ValueError):
148def boot_time_epoch(root: Path) -> float:
149 """Wall-clock instant of the last boot, from *root*/stat's ``btime``."""
150 for line
in _read_text(root /
"stat").splitlines():
151 if line.startswith(
"btime "):
152 return float(line.split()[1])
156def parse_stat(raw: str) -> tuple[int, int]:
157 """Return ``(ppid, starttime_ticks)`` from a /proc/<pid>/stat body.
159 Field 2 is the comm in parentheses and may itself contain spaces and
160 parentheses, so the split is anchored on the LAST ``)`` rather than on
161 whitespace. Returns ``(0, 0)`` when the line is not a usable stat line.
163 _, sep, rest = raw.rpartition(
")")
166 fields = rest.split()
169 if len(fields) < STAT_MIN_FIELDS:
172 return (int(fields[STAT_PPID_IDX]), int(fields[STAT_STARTTIME_IDX]))
177def ssh_peer(root: Path, pid: int, cache: dict[int, str]) -> str:
178 """Peer address of the ssh session *pid* belongs to, or '' when local.
180 Walks the ppid chain looking for ``SSH_CONNECTION`` in a process
181 environment. The value's first field is the client address as the KERNEL
182 saw it, so an actor cannot misreport where it is calling from. Results are
183 memoised per pid, and the walk is bounded.
185 chain: list[int] = []
188 for _
in range(PPID_WALK_LIMIT):
195 for item
in _read_bytes(root / str(cur) /
"environ").split(b
"\0"):
196 if item.startswith(b
"SSH_CONNECTION="):
197 value = item.split(b
"=", 1)[1].decode(
"ascii",
"replace").split()
198 found = value[0]
if value
else ""
202 cur, _ = parse_stat(_read_text(root / str(cur) /
"stat"))
208def _cmdline(root: Path, pid: int) -> list[str]:
209 """Argv of *pid* as a list; empty when the process is gone or a kthread."""
210 raw = _read_bytes(root / str(pid) /
"cmdline")
213 return [part.decode(
"utf-8",
"replace")
for part
in raw.split(b
"\0")
if part]
216def is_tool(comm: str, argv: list[str]) -> bool:
217 """True when (comm, argv) names a program that drives bench hardware."""
218 if comm
in TOOL_COMMS:
220 if comm
not in INTERPRETER_COMMS:
222 joined =
" ".join(argv)
223 return any(mark
in joined
for mark
in TOOL_CMDLINE_MARKS)
226def _script_body(argv: list[str]) -> str:
227 """Contents of the J-Link commander script named in *argv*, flattened."""
228 for flag
in SCRIPT_FLAGS:
231 idx = argv.index(flag)
232 if idx + 1 >= len(argv):
234 return " ".join(_read_text(Path(argv[idx + 1])).split())
239 """One tool process, from the instant it started to the instant it left."""
241 def __init__(self, pid: int, comm: str, argv: list[str], start: float, peer: str) ->
None:
242 """Record a tool process seen alive, with its exact start instant."""
248 self.script = _script_body(argv)
249 self.last_seen = start
251 def as_start_event(self, now: float) -> dict[str, object]:
252 """The NDJSON record written when this process is first observed."""
258 "start_epoch": round(self.start, 3),
259 "first_seen": round(now, 3),
260 "argv":
" ".join(self.argv),
261 "script": self.script,
264 def as_end_event(self, now: float) -> dict[str, object]:
265 """The NDJSON record written when this process is first missed."""
271 "start_epoch": round(self.start, 3),
272 "last_seen": round(self.last_seen, 3),
273 "gone_by": round(now, 3),
278 root: Path, btime: float, clk_tck: float, peers: dict[int, str]
279) -> dict[int, Sighting]:
280 """Every live bench-tool process under *root*, with exact start instants."""
281 found: dict[int, Sighting] = {}
283 entries = list(root.iterdir())
286 for entry
in entries:
287 if not entry.name.isdigit():
289 pid = int(entry.name)
290 comm = _read_text(entry /
"comm").strip()
293 argv = _cmdline(root, pid)
if (comm
in INTERPRETER_COMMS
or comm
in TOOL_COMMS)
else []
294 if not is_tool(comm, argv):
296 _, ticks = parse_stat(_read_text(entry /
"stat"))
297 start = btime + (ticks / clk_tck)
if ticks
else 0.0
298 found[pid] = Sighting(pid, comm, argv, start, ssh_peer(root, pid, peers))
302def scan_consoles(root: Path, peers: dict[int, str]) -> list[dict[str, object]]:
303 """Who currently holds a /dev/ttyACM* open, and from which machine.
305 The board console is the J-Link OB's VCOM: a second reader silently halves
306 everyone's bytes, which is one of the collisions #497 exists to stop. Cheap
307 enough at a fraction of the census rate, and far too expensive at all of it.
309 holders: list[dict[str, object]] = []
311 entries = list(root.iterdir())
314 for entry
in entries:
315 if not entry.name.isdigit():
318 fds = list((entry /
"fd").iterdir())
323 target = str(fd.readlink())
326 if not target.startswith(
"/dev/ttyACM"):
331 "pid": int(entry.name),
332 "comm": _read_text(entry /
"comm").strip(),
333 "peer": ssh_peer(root, int(entry.name), peers),
339def lock_snapshot(lock_dir: Path) -> dict[str, str]:
340 """The lock's own claim about who holds it -- correlation only, not proof.
342 Recorded so a reader can line the two accounts up, and so a DISAGREEMENT
343 between them is visible. Nothing in the verdict may rest on it: that is the
344 whole reason this file exists.
346 raw = _read_text(lock_dir /
"holder.json")
347 out = {
"lock_id":
"",
"holder_name":
""}
351 marker = f
'"{key}": "'
352 idx = raw.find(marker)
355 rest = raw[idx + len(marker) :]
356 out[key] = rest[: rest.find(
'"')]
if '"' in rest
else ""
361 """The sampling loop and its NDJSON output stream."""
363 def __init__(self, out: Path, interval_s: float, root: Path, lock_dir: Path) ->
None:
364 """Open the NDJSON stream and read the clock constants once."""
366 self.interval_s = interval_s
368 self.lock_dir = lock_dir
369 self.btime = boot_time_epoch(root)
370 self.clk_tck = float(os.sysconf(
"SC_CLK_TCK"))
371 self.peers: dict[int, str] = {}
372 self.live: dict[int, Sighting] = {}
373 self.last_census =
""
374 self.handle = out.open(
"a", buffering=1)
376 def emit(self, record: dict[str, object]) ->
None:
377 """Append one NDJSON record, timestamped on the bench host's clock."""
378 record.setdefault(
"t", round(time.time(), 3))
379 self.handle.
write(json.dumps(record, sort_keys=
True) +
"\n")
381 def census(self, now: float, seen: dict[int, Sighting]) ->
None:
382 """Emit the concurrently-alive set whenever it changes.
384 This is the direct observation of overlap: a single record naming two
385 pids with two different peers is two machines on the board at once, and
386 needs no interval arithmetic to interpret.
388 rows = sorted((s.pid, s.comm, s.peer)
for s
in seen.values())
390 if key == self.last_census:
392 self.last_census = key
393 lock = lock_snapshot(self.lock_dir)
398 "tools": [list(row)
for row
in rows],
399 "peers": sorted({s.peer
for s
in seen.values()
if s.peer}),
400 "lock_id": lock[
"lock_id"],
401 "lock_holder": lock[
"holder_name"],
405 def step(self, now: float, fd_scan: bool) ->
None:
406 """One sampling tick: diff the live set, then emit what changed."""
407 seen = scan_tools(self.root, self.btime, self.clk_tck, self.peers)
408 for pid, sighting
in seen.items():
409 if pid
not in self.live:
410 self.live[pid] = sighting
411 self.emit(sighting.as_start_event(now))
412 self.live[pid].last_seen = now
413 for pid
in [p
for p
in self.live
if p
not in seen]:
414 self.emit(self.live.pop(pid).as_end_event(now))
415 self.census(now, seen)
417 for holder
in scan_consoles(self.root, self.peers):
421 def run(self, max_seconds: float) -> int:
422 """Sample until the stop file appears or *max_seconds* elapses."""
423 stop = Path(str(self.out) +
".stop")
424 started = time.time()
427 "ev":
"witness_start",
428 "boot_id": _read_text(self.root /
"sys/kernel/random/boot_id").strip(),
430 "clk_tck": self.clk_tck,
431 "interval_s": self.interval_s,
432 "host": os.uname().nodename,
436 while not stop.exists()
and (time.time() - started) < max_seconds:
437 self.step(time.time(), tick % FD_SCAN_EVERY == 0)
439 time.sleep(self.interval_s)
441 for pid
in list(self.live):
442 self.emit(self.live.pop(pid).as_end_event(now))
443 self.emit({
"ev":
"witness_stop",
"ticks": tick})
448def _plant(root: Path, spec: tuple[int, str, list[str], int, int]) ->
None:
449 """Write a synthetic /proc/<pid> for the selftest's fake procfs.
451 *spec* is ``(pid, comm, argv, ppid, starttime_ticks)`` -- one tuple rather
452 than five positional arguments, because five in a row at a call site is
453 where a transposed pair hides.
455 pid, comm, argv, ppid, ticks = spec
457 d.mkdir(parents=
True, exist_ok=
True)
458 (d /
"comm").write_text(comm +
"\n")
459 (d /
"cmdline").write_bytes(b
"\0".join(a.encode()
for a
in argv) + b
"\0")
460 filler =
" ".join(
"0" for _
in range(17))
461 (d /
"stat").write_text(f
"{pid} ({comm}) S {ppid} {filler} {ticks}\n")
464def _selftest_fake_procfs(tmp: Path) -> list[str]:
465 """Plant a tool and two decoys, and require discovery to find only the tool."""
466 failures: list[str] = []
469 (root /
"stat").write_text(f
"cpu 1 2 3\nbtime {FAKE_BTIME:.0f}\nprocesses 42\n")
470 _plant(root, (TOOL_PID,
"JLinkExe", [
"JLinkExe",
"-nogui",
"1"], PARENT_PID, TOOL_TICKS))
473 _plant(root, (222,
"bash", [
"bash",
"-c",
"echo JLinkExe would run here"], PARENT_PID, 260))
474 _plant(root, (333,
"sshd", [
"sshd:",
"star@notty"], 1, 270))
476 (root / str(PARENT_PID)).mkdir()
477 (root / str(PARENT_PID) /
"comm").write_text(
"bash\n")
478 (root / str(PARENT_PID) /
"stat").write_text(
479 f
"{PARENT_PID} (bash) S 1 " +
" ".join(
"0" for _
in range(17)) +
" 240\n"
481 (root / str(PARENT_PID) /
"environ").write_bytes(
482 b
"HOME=/home/star\0SSH_CONNECTION=10.0.40.103 5 10.0.40.101 22\0"
485 btime = boot_time_epoch(root)
486 if btime != FAKE_BTIME:
487 failures.append(f
"boot_time_epoch on a synthetic procfs: got {btime}")
488 tools = scan_tools(root, btime, FAKE_CLK_TCK, {})
489 if sorted(tools) != [TOOL_PID]:
490 failures.append(f
"scan_tools should find exactly pid {TOOL_PID}, found {sorted(tools)}")
491 if TOOL_PID
in tools:
492 sighting = tools[TOOL_PID]
493 if sighting.peer !=
"10.0.40.103":
494 failures.append(f
"peer must come from the ancestor environ, got '{sighting.peer}'")
495 want = FAKE_BTIME + TOOL_TICKS / FAKE_CLK_TCK
496 if abs(sighting.start - want) > EPSILON_S:
497 failures.append(f
"exact start instant: want {want}, got {sighting.start}")
501def selftest() -> int:
502 """Prove discovery and parsing in BOTH directions, on any host.
504 This repo's dominant tooling defect is a checker that quietly stopped
505 matching and reported a clean tree forever. A witness in that state emits an
506 empty timeline, and an empty timeline reads as "no two actors ever
507 overlapped" -- a green verdict manufactured by a broken tool. So the scanner
508 is pointed at a synthetic procfs holding one real tool and two decoys and
509 required to find exactly the one, and the stat parser is required to reject
510 the lines it cannot understand rather than returning zeros.
512 failures: list[str] = []
516 line =
"42 (weird ) name) S 7 " +
" ".join(
"0" for _
in range(17)) +
" 99887766"
517 got = parse_stat(line)
518 if got != (7, 99887766):
519 failures.append(f
"parse_stat on a parenthesised comm: want (7, 99887766), got {got}")
520 if parse_stat(
"not a stat line") != (0, 0):
521 failures.append(
"parse_stat accepted a non-stat line")
522 if parse_stat(
"1 (init) S 0") != (0, 0):
523 failures.append(
"parse_stat accepted a truncated stat line")
525 if not is_tool(
"JLinkExe", []):
526 failures.append(
"is_tool missed a bare JLinkExe")
527 if not is_tool(
"python3", [
"python3",
"/x/esptool.py",
"flash"]):
528 failures.append(
"is_tool missed esptool under an interpreter")
529 if is_tool(
"bash", [
"bash",
"-c",
"echo JLinkExe"]):
530 failures.append(
"is_tool matched a mention of JLinkExe rather than an invocation")
531 if is_tool(
"sshd", []):
532 failures.append(
"is_tool matched sshd")
534 with tempfile.TemporaryDirectory()
as tmp:
535 failures.extend(_selftest_fake_procfs(Path(tmp)))
537 for text
in failures:
538 print(f
"bench_witness: SELFTEST FAIL -- {text}", file=sys.stderr)
541 print(
"bench_witness: selftest OK -- parser and discovery proven both ways")
545def _cmd_sample(args: argparse.Namespace) -> int:
546 """One census to stdout, for a human looking at a live bench."""
547 peers: dict[int, str] = {}
548 btime = boot_time_epoch(args.proc)
549 tools = scan_tools(args.proc, btime, float(os.sysconf(
"SC_CLK_TCK")), peers)
553 "tools": [t.as_start_event(time.time())
for t
in tools.values()],
554 "consoles": scan_consoles(args.proc, peers),
555 "lock": lock_snapshot(args.lock_dir),
564def main(argv: list[str]) -> int:
565 """Parse arguments and dispatch. See the module docstring for the verbs."""
566 parser = argparse.ArgumentParser(description=
"bench-host hardware witness")
567 parser.add_argument(
"verb", nargs=
"?", default=
"sample", choices=(
"run",
"sample"))
568 parser.add_argument(
"--out", type=Path, help=
"NDJSON output path (run)")
569 parser.add_argument(
"--interval-ms", type=int, default=50)
570 parser.add_argument(
"--max-seconds", type=float, default=3600.0)
571 parser.add_argument(
"--proc", type=Path, default=DEFAULT_PROC)
572 parser.add_argument(
"--lock-dir", type=Path, default=DEFAULT_LOCK_DIR)
573 parser.add_argument(
"--selftest", action=
"store_true")
574 args = parser.parse_args(argv)
578 if not args.proc.is_dir():
580 f
"bench_witness: {args.proc} is not readable -- not a Linux bench host", file=sys.stderr
583 if args.verb ==
"sample":
584 return _cmd_sample(args)
586 print(
"bench_witness: run needs --out", file=sys.stderr)
588 return Witness(args.out, args.interval_ms / 1000.0, args.proc, args.lock_dir).run(
593if __name__ ==
"__main__":
594 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
int abs(int j)
Compute absolute value of integer.