ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
bench_witness.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Bench-host witness: what actually touched the hardware, and when.
5
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
12``release``.
13
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``.
18
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.
27
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.
34
35Shipped BY VALUE
36----------------
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.
40
41Usage::
42
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
46
47``run`` stops when ``FILE.stop`` appears or ``--max-seconds`` elapses, so a
48witness can never outlive the run that started it.
49
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".
56
57Exit 0 on a clean run, 1 on a failing selftest, 2 when /proc is not readable.
58"""
59
60from __future__ import annotations
61
62import argparse
63import json
64import os
65import sys
66import tempfile
67import time
68from pathlib import Path
69
70EXIT_OK = 0
71EXIT_FAIL = 1
72EXIT_CONFIG = 2
73
74DEFAULT_PROC = Path("/proc")
75DEFAULT_LOCK_DIR = Path("/var/lib/ra8-bench")
76
77# Every program on this bench that drives the target, the C6, the hub or the
78# plug. Matched against the process COMM first (one cheap read), then against
79# the full command line for the interpreted ones, whose comm is `python3`.
80TOOL_COMMS = frozenset(
81 {
82 "JLinkExe",
83 "JLinkGDBServer",
84 "JLinkGDBServerCLExe",
85 "JLinkRTTClient",
86 "JLinkRTTLogger",
87 "rfp-cli",
88 "openocd",
89 "uhubctl",
90 "esptool",
91 "esptool.py",
92 }
93)
94
95# Command-line substrings that identify a tool whose comm is an interpreter.
96TOOL_CMDLINE_MARKS = ("esptool", "tapo_control.py")
97
98# Interpreters worth reading a cmdline for. Anything else is judged on comm.
99INTERPRETER_COMMS = frozenset({"python3", "python", "python3.11", "python3.12"})
100
101# The J-Link commander script is passed as a path; its CONTENTS name the hex
102# being programmed, and each actor in a contention run programs a different
103# app. That is a second attribution axis alongside the ssh peer -- one from the
104# kernel, one from the payload -- so neither is taken on trust alone.
105SCRIPT_FLAGS = ("-commanderscript", "-CommanderScript", "-commandfile")
106
107# How often the far more expensive /proc/*/fd scan runs, as a divisor of the
108# sample rate. Console ownership changes on a human timescale; the J-Link
109# census is what needs 20 Hz.
110FD_SCAN_EVERY = 20
111
112# Bound on the ppid walk. A corrupted or racing /proc must not loop forever.
113PPID_WALK_LIMIT = 64
114
115# /proc/<pid>/stat, split after the last ')', starts at field 3. starttime is
116# field 22 of the whole line, so index 19 here, and a shorter split is a line
117# this parser does not understand.
118STAT_MIN_FIELDS = 20
119STAT_STARTTIME_IDX = 19
120STAT_PPID_IDX = 1
121
122# The selftest's synthetic procfs: one planted tool, its parent shell carrying
123# the ssh peer, and a boot time that makes the expected start instant exact.
124FAKE_BTIME = 1700000000.0
125FAKE_CLK_TCK = 100.0
126TOOL_PID = 111
127PARENT_PID = 110
128TOOL_TICKS = 250
129EPSILON_S = 1e-6
130
131
132def _read_text(path: Path) -> str:
133 """Read *path*, returning '' for anything that vanished or is forbidden."""
134 try:
135 return path.read_text(errors="replace")
136 except (OSError, ValueError):
137 return ""
138
139
140def _read_bytes(path: Path) -> bytes:
141 """Read *path* as bytes, returning b'' for anything unreadable."""
142 try:
143 return path.read_bytes()
144 except (OSError, ValueError):
145 return b""
146
147
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])
153 return 0.0
154
155
156def parse_stat(raw: str) -> tuple[int, int]:
157 """Return ``(ppid, starttime_ticks)`` from a /proc/<pid>/stat body.
158
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.
162 """
163 _, sep, rest = raw.rpartition(")")
164 if not sep:
165 return (0, 0)
166 fields = rest.split()
167 # `rest` starts at field 3 (state), so ppid is fields[1] and starttime --
168 # field 22 of the whole line -- is fields[19].
169 if len(fields) < STAT_MIN_FIELDS:
170 return (0, 0)
171 try:
172 return (int(fields[STAT_PPID_IDX]), int(fields[STAT_STARTTIME_IDX]))
173 except ValueError:
174 return (0, 0)
175
176
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.
179
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.
184 """
185 chain: list[int] = []
186 cur = pid
187 found = ""
188 for _ in range(PPID_WALK_LIMIT):
189 if cur <= 1:
190 break
191 if cur in cache:
192 found = cache[cur]
193 break
194 chain.append(cur)
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 ""
199 break
200 if found:
201 break
202 cur, _ = parse_stat(_read_text(root / str(cur) / "stat"))
203 for node in chain:
204 cache[node] = found
205 return found
206
207
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")
211 if not raw:
212 return []
213 return [part.decode("utf-8", "replace") for part in raw.split(b"\0") if part]
214
215
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:
219 return True
220 if comm not in INTERPRETER_COMMS:
221 return False
222 joined = " ".join(argv)
223 return any(mark in joined for mark in TOOL_CMDLINE_MARKS)
224
225
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:
229 if flag not in argv:
230 continue
231 idx = argv.index(flag)
232 if idx + 1 >= len(argv):
233 continue
234 return " ".join(_read_text(Path(argv[idx + 1])).split())
235 return ""
236
237
238class Sighting:
239 """One tool process, from the instant it started to the instant it left."""
240
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."""
243 self.pid = pid
244 self.comm = comm
245 self.argv = argv
246 self.start = start
247 self.peer = peer
248 self.script = _script_body(argv)
249 self.last_seen = start
250
251 def as_start_event(self, now: float) -> dict[str, object]:
252 """The NDJSON record written when this process is first observed."""
253 return {
254 "ev": "proc_start",
255 "pid": self.pid,
256 "tool": self.comm,
257 "peer": self.peer,
258 "start_epoch": round(self.start, 3),
259 "first_seen": round(now, 3),
260 "argv": " ".join(self.argv),
261 "script": self.script,
262 }
263
264 def as_end_event(self, now: float) -> dict[str, object]:
265 """The NDJSON record written when this process is first missed."""
266 return {
267 "ev": "proc_end",
268 "pid": self.pid,
269 "tool": self.comm,
270 "peer": self.peer,
271 "start_epoch": round(self.start, 3),
272 "last_seen": round(self.last_seen, 3),
273 "gone_by": round(now, 3),
274 }
275
276
277def scan_tools(
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] = {}
282 try:
283 entries = list(root.iterdir())
284 except OSError:
285 return found
286 for entry in entries:
287 if not entry.name.isdigit():
288 continue
289 pid = int(entry.name)
290 comm = _read_text(entry / "comm").strip()
291 if not comm:
292 continue
293 argv = _cmdline(root, pid) if (comm in INTERPRETER_COMMS or comm in TOOL_COMMS) else []
294 if not is_tool(comm, argv):
295 continue
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))
299 return found
300
301
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.
304
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.
308 """
309 holders: list[dict[str, object]] = []
310 try:
311 entries = list(root.iterdir())
312 except OSError:
313 return holders
314 for entry in entries:
315 if not entry.name.isdigit():
316 continue
317 try:
318 fds = list((entry / "fd").iterdir())
319 except OSError:
320 continue
321 for fd in fds:
322 try:
323 target = str(fd.readlink())
324 except OSError:
325 continue
326 if not target.startswith("/dev/ttyACM"):
327 continue
328 holders.append(
329 {
330 "dev": target,
331 "pid": int(entry.name),
332 "comm": _read_text(entry / "comm").strip(),
333 "peer": ssh_peer(root, int(entry.name), peers),
334 }
335 )
336 return holders
337
338
339def lock_snapshot(lock_dir: Path) -> dict[str, str]:
340 """The lock's own claim about who holds it -- correlation only, not proof.
341
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.
345 """
346 raw = _read_text(lock_dir / "holder.json")
347 out = {"lock_id": "", "holder_name": ""}
348 if not raw:
349 return out
350 for key in out:
351 marker = f'"{key}": "'
352 idx = raw.find(marker)
353 if idx < 0:
354 continue
355 rest = raw[idx + len(marker) :]
356 out[key] = rest[: rest.find('"')] if '"' in rest else ""
357 return out
358
359
360class Witness:
361 """The sampling loop and its NDJSON output stream."""
362
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."""
365 self.out = out
366 self.interval_s = interval_s
367 self.root = root
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)
375
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")
380
381 def census(self, now: float, seen: dict[int, Sighting]) -> None:
382 """Emit the concurrently-alive set whenever it changes.
383
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.
387 """
388 rows = sorted((s.pid, s.comm, s.peer) for s in seen.values())
389 key = repr(rows)
390 if key == self.last_census:
391 return
392 self.last_census = key
393 lock = lock_snapshot(self.lock_dir)
394 self.emit(
395 {
396 "ev": "census",
397 "t": round(now, 3),
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"],
402 }
403 )
404
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)
416 if fd_scan:
417 for holder in scan_consoles(self.root, self.peers):
418 holder["ev"] = "tty"
419 self.emit(holder)
420
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()
425 self.emit(
426 {
427 "ev": "witness_start",
428 "boot_id": _read_text(self.root / "sys/kernel/random/boot_id").strip(),
429 "btime": self.btime,
430 "clk_tck": self.clk_tck,
431 "interval_s": self.interval_s,
432 "host": os.uname().nodename,
433 }
434 )
435 tick = 0
436 while not stop.exists() and (time.time() - started) < max_seconds:
437 self.step(time.time(), tick % FD_SCAN_EVERY == 0)
438 tick += 1
439 time.sleep(self.interval_s)
440 now = time.time()
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})
444 self.handle.close()
445 return EXIT_OK
446
447
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.
450
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.
454 """
455 pid, comm, argv, ppid, ticks = spec
456 d = root / str(pid)
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")
462
463
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] = []
467 root = tmp / "proc"
468 root.mkdir()
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))
471 # A decoy that MENTIONS the tool without being it, and one unrelated
472 # daemon. A substring matcher would take the first; a comm matcher must not.
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))
475 # The peer must come from an ANCESTOR's environ, not the tool's own.
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"
480 )
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"
483 )
484
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}")
498 return failures
499
500
501def selftest() -> int:
502 """Prove discovery and parsing in BOTH directions, on any host.
503
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.
511 """
512 failures: list[str] = []
513
514 # A comm containing spaces AND parentheses is the case a whitespace split
515 # gets wrong; JLinkExe's is tame, but a wrapper's need not be.
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")
524
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")
533
534 with tempfile.TemporaryDirectory() as tmp:
535 failures.extend(_selftest_fake_procfs(Path(tmp)))
536
537 for text in failures:
538 print(f"bench_witness: SELFTEST FAIL -- {text}", file=sys.stderr)
539 if failures:
540 return EXIT_FAIL
541 print("bench_witness: selftest OK -- parser and discovery proven both ways")
542 return EXIT_OK
543
544
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)
550 print(
551 json.dumps(
552 {
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),
556 },
557 indent=2,
558 sort_keys=True,
559 )
560 )
561 return EXIT_OK
562
563
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)
575
576 if args.selftest:
577 return selftest()
578 if not args.proc.is_dir():
579 print(
580 f"bench_witness: {args.proc} is not readable -- not a Linux bench host", file=sys.stderr
581 )
582 return EXIT_CONFIG
583 if args.verb == "sample":
584 return _cmd_sample(args)
585 if args.out is None:
586 print("bench_witness: run needs --out", file=sys.stderr)
587 return EXIT_CONFIG
588 return Witness(args.out, args.interval_ms / 1000.0, args.proc, args.lock_dir).run(
589 args.max_seconds
590 )
591
592
593if __name__ == "__main__":
594 sys.exit(main(sys.argv[1:]))
-proof
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
int abs(int j)
Compute absolute value of integer.