ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
bench_contention_verdict.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"""Decide what a bench-contention run actually proved.
5
6The two accounts, and which one is evidence
7-------------------------------------------
8A run produces two records of the same minutes:
9
10* the **journal** -- ``/var/lib/ra8-bench/journal.ndjson``, written by the lock
11 about itself. It says who acquired and released, and when.
12* the **witness** -- ``lib/bench_witness.py`` sampling ``/proc`` on the bench
13 host. It says which process had the J-Link, started by which machine, between
14 which two instants, read from the kernel.
15
16Only the second is evidence. An actor that silently skipped its work writes
17exactly the same pair of journal lines as one that waited its turn and then did
18the work, so no arrangement of journal lines can tell those apart. Every claim
19below is therefore either decided by the witness, or is a claim ABOUT the
20journal decided by checking it against the witness.
21
22Attribution is derived twice, independently
23-------------------------------------------
24Each actor in a run programs a DIFFERENT app, and the J-Link commander script
25the witness captured names the hex being programmed. That maps a hardware
26interval to an actor through the payload. Separately, the ssh peer address maps
27it to a machine through the kernel. The two are computed separately and then
28required to agree: if the app-derived grouping and the peer-derived grouping
29disagree, something is wrong with the experiment and the run is reported
30inconclusive rather than green.
31
32Intervals are widened, never narrowed
33-------------------------------------
34A process start instant is exact (``/proc/<pid>/stat`` field 22). Its end is
35bracketed between the last sample that saw it and the first that did not. Every
36interval is taken as ``[exact_start, gone_by]`` -- the PESSIMISTIC bound, which
37can only make overlap more likely to be reported. Non-overlap concluded from
38widened intervals is non-overlap.
39
40Usage::
41
42 bench_contention_verdict.py --dir RUNDIR --phases exclusion,death
43 bench_contention_verdict.py --selftest
44
45Exit 0 when every claim in the selected phases held, 1 when one did not, 3 when
46no verdict could be established (a missing or empty artefact).
47"""
48
49from __future__ import annotations
50
51import argparse
52import json
53import re
54import sys
55from datetime import datetime
56from pathlib import Path
57
58EXIT_OK = 0
59EXIT_FAIL = 1
60EXIT_UNKNOWN = 3
61
62# How long after a SIGKILL a queued waiter may take to get in before the
63# release path is judged not to be prompt. The ssh-death selftest measures the
64# flock dropping in well under a second; this allows for the waiter's own
65# poll interval, its ssh round trip and a loaded Pi.
66DEATH_HANDOVER_LIMIT_S = 30.0
67
68# Slack when checking that a hardware interval sits inside its journal hold
69# window. Both timestamps come from the bench host's clock, so this covers
70# journal timestamps being whole seconds while witness ones are milliseconds.
71CONTAINMENT_SLACK_S = 2.0
72
73# Below this, a leftover hardware session that outlived its holder is just the
74# tail of a process shutting down and is not worth narrating.
75LINGER_NOTICEABLE_S = 1.0
76
77# A collision needs two distinct machines. Named so the comparisons below read
78# as "more than one machine" rather than as an unexplained 1.
79ONE_MACHINE = 1
80
81# An assignment line is "<actor> <app>"; anything shorter is not one.
82ASSIGNMENT_FIELDS = 2
83
84
85class Verdict:
86 """Collected claims and their outcomes, rendered as one report."""
87
88 def __init__(self) -> None:
89 """Start with no claims recorded and nothing failed."""
90 self.rows: list[tuple[str, str, str]] = []
91 self.failed = 0
92 self.unknown = 0
93
94 def record(self, phase: str, claim: str, ok: bool | None, detail: str) -> None:
95 """Log one claim. ``ok is None`` means no verdict could be established."""
96 if ok is None:
97 state = "UNKNOWN"
98 self.unknown += 1
99 elif ok:
100 state = "PASS"
101 else:
102 state = "FAIL"
103 self.failed += 1
104 self.rows.append((f"{phase}/{claim}", state, detail))
105
106 def note(self, phase: str, label: str, detail: str) -> None:
107 """Record an OBSERVATION -- something measured, not something asserted.
108
109 Wait distributions and grant orders are evidence a reader needs and
110 nothing can fail on: there is no threshold at which a grant order is
111 "wrong". Recording them as claims that happen to always pass would
112 inflate the pass count with rows that can never fail, which is exactly
113 how a suite drifts into looking stronger than it is.
114 """
115 self.rows.append((f"{phase}/{label}", "OBSERVED", detail))
116
117 def report(self) -> int:
118 """Print every claim and return the process exit code."""
119 width = max((len(r[0]) for r in self.rows), default=10)
120 print("\n=== bench contention verdict ===")
121 for name, state, detail in self.rows:
122 print(f" {state:<7} {name:<{width}} {detail}")
123 if self.failed:
124 print(f"\nVERDICT: FAIL -- {self.failed} claim(s) did not hold.")
125 return EXIT_FAIL
126 if self.unknown:
127 print(f"\nVERDICT: UNKNOWN -- {self.unknown} claim(s) could not be established.")
128 print("UNKNOWN is not a pass. Fix the artefact and re-run.")
129 return EXIT_UNKNOWN
130 print(f"\nVERDICT: PASS -- {len(self.rows)} claim(s) held.")
131 return EXIT_OK
132
133
134def read_ndjson(path: Path) -> list[dict]:
135 """Every parsable object in an NDJSON file; [] when it is absent."""
136 if not path.is_file():
137 return []
138 out = []
139 for raw in path.read_text(errors="replace").splitlines():
140 line = raw.strip()
141 if not line:
142 continue
143 try:
144 out.append(json.loads(line))
145 except json.JSONDecodeError:
146 continue
147 return out
148
149
150def iso_epoch(text: str) -> float:
151 """Seconds since the epoch for an ISO-8601 instant, or 0.0 when unparsable."""
152 try:
153 return datetime.fromisoformat(text).timestamp()
154 except (ValueError, TypeError):
155 return 0.0
156
157
158class Hold:
159 """One acquire/release pair out of the journal."""
160
161 def __init__(self, lock_id: str, holder: str, start: float, note: str) -> None:
162 """Open a hold at *start*; ``end`` stays infinite until a release lands."""
163 self.lock_id = lock_id
164 self.holder = holder
165 self.start = start
166 self.end = float("inf")
167 self.note = note
168 self.forced = False
169
170 def __repr__(self) -> str:
171 """Render as the holder and its window, for a failure message."""
172 return f"<Hold {self.holder} {self.start:.0f}..{self.end:.0f}>"
173
174
175def holds_from_journal(events: list[dict]) -> list[Hold]:
176 """Reconstruct hold windows, in journal order, from acquire/release lines."""
177 open_holds: dict[str, Hold] = {}
178 holds: list[Hold] = []
179 for ev in events:
180 kind = ev.get("event", "")
181 lock_id = ev.get("lock_id", "")
182 when = iso_epoch(ev.get("at", ""))
183 if kind == "acquire":
184 hold = Hold(lock_id, ev.get("holder_name", ""), when, ev.get("note", ""))
185 open_holds[lock_id] = hold
186 holds.append(hold)
187 elif kind == "release" and lock_id in open_holds:
188 open_holds.pop(lock_id).end = when
189 elif kind == "force-take" and lock_id in open_holds:
190 open_holds[lock_id].forced = True
191 return holds
192
193
194class Interval:
195 """One hardware-tool occupancy, widened to its pessimistic bounds."""
196
197 def __init__(self, ev_start: dict, ev_end: dict | None) -> None:
198 """Build the widened interval from a proc_start and its proc_end."""
199 self.pid = ev_start.get("pid")
200 self.tool = ev_start.get("tool", "")
201 self.peer = ev_start.get("peer", "")
202 self.script = ev_start.get("script", "")
203 self.argv = ev_start.get("argv", "")
204 self.start = float(ev_start.get("start_epoch") or ev_start.get("first_seen") or 0.0)
205 self.end = float((ev_end or {}).get("gone_by") or ev_start.get("first_seen") or 0.0)
206 self.actor = ""
207
208 def overlaps(self, other: Interval) -> bool:
209 """True when the two widened intervals share any instant."""
210 return self.start < other.end and other.start < self.end
211
212 def __repr__(self) -> str:
213 """Render as tool, pid and machine, for a failure message."""
214 return f"<{self.tool} pid={self.pid} peer={self.peer} actor={self.actor}>"
215
216
217def intervals_from_witness(events: list[dict]) -> list[Interval]:
218 """Pair proc_start with proc_end into widened occupancy intervals."""
219 starts: dict[int, dict] = {}
220 ends: dict[int, dict] = {}
221 for ev in events:
222 if ev.get("ev") == "proc_start":
223 starts[ev["pid"]] = ev
224 elif ev.get("ev") == "proc_end":
225 ends[ev["pid"]] = ev
226 out = [Interval(s, ends.get(pid)) for pid, s in starts.items()]
227 out.sort(key=lambda i: i.start)
228 return out
229
230
231def attribute_by_payload(intervals: list[Interval], assignment: dict[str, str]) -> None:
232 """Name each interval's actor from the app its J-Link script programs.
233
234 ``assignment`` maps actor name -> app name. The commander script contains
235 ``loadfile /tmp/hil_<app>_mram.<pid>.hex``, so the app names the actor
236 without consulting the lock at all. Read-only sessions carry no loadfile and
237 stay unattributed by this route; the peer axis covers those.
238 """
239 by_app = {app: actor for actor, app in assignment.items()}
240 for iv in intervals:
241 for app, actor in by_app.items():
242 if f"hil_{app}_mram" in iv.script:
243 iv.actor = actor
244 break
245
246
247def check_attribution_axes(intervals: list[Interval]) -> tuple[bool, str]:
248 """Require the payload-derived and kernel-derived attributions to agree.
249
250 Every interval attributed to one actor must carry one peer address, and no
251 two actors may share a peer. A disagreement means the experiment did not run
252 the way it was described, and nothing downstream should be believed.
253 """
254 peers_by_actor: dict[str, set[str]] = {}
255 for iv in intervals:
256 if iv.actor:
257 peers_by_actor.setdefault(iv.actor, set()).add(iv.peer)
258 split = {a: p for a, p in peers_by_actor.items() if len(p) > 1}
259 if split:
260 return (False, f"an actor's work came from more than one machine: {split}")
261 seen: dict[str, str] = {}
262 for actor, peers in peers_by_actor.items():
263 peer = next(iter(peers))
264 if peer in seen:
265 return (False, f"actors {seen[peer]} and {actor} share peer {peer}")
266 seen[peer] = actor
267 return (True, f"{len(peers_by_actor)} actor(s), one machine each: {seen}")
268
269
270def overlapping_pairs(intervals: list[Interval]) -> list[tuple[Interval, Interval]]:
271 """Every pair of intervals from DIFFERENT machines that share an instant."""
272 out = []
273 for i, first in enumerate(intervals):
274 for second in intervals[i + 1 :]:
275 if not first.peer or not second.peer or first.peer == second.peer:
276 continue
277 if first.overlaps(second):
278 out.append((first, second))
279 return out
280
281
282def census_collisions(events: list[dict]) -> list[dict]:
283 """Census samples that directly observed two machines on the board at once."""
284 return [
285 ev for ev in events if ev.get("ev") == "census" and len(ev.get("peers", [])) > ONE_MACHINE
286 ]
287
288
289def console_collisions(events: list[dict]) -> list[tuple[float, list[str]]]:
290 """Instants at which two machines held the same board console at once.
291
292 A second axis, and an independently damaging one: the J-Link OB's VCOM
293 delivers each byte to exactly one reader, so two tails of one console each
294 get roughly half the stream. #497 lists that as a concrete failure -- a HIL
295 run silently pattern-matching against half its output -- and it is a
296 collision the J-Link's own multiplexing does nothing to prevent.
297 """
298 per_instant: dict[tuple[float, str], set[str]] = {}
299 for ev in events:
300 if ev.get("ev") != "tty":
301 continue
302 key = (float(ev.get("t", 0.0)), str(ev.get("dev", "")))
303 per_instant.setdefault(key, set()).add(str(ev.get("peer", "")))
304 return [
305 (when, sorted(peers))
306 for (when, _dev), peers in sorted(per_instant.items())
307 if len({p for p in peers if p}) > ONE_MACHINE
308 ]
309
310
311def hold_overlaps(holds: list[Hold]) -> list[tuple[Hold, Hold]]:
312 """Every pair of journal hold windows that overlap in time."""
313 out = []
314 for i, first in enumerate(holds):
315 out.extend(
316 (first, second)
317 for second in holds[i + 1 :]
318 if first.start < second.end and second.start < first.end
319 )
320 return out
321
322
323class PhaseData:
324 """The three artefacts of one phase, loaded together."""
325
326 def __init__(self, root: Path, phase: str) -> None:
327 """Load one phase's journal slice and witness trace from *root*."""
328 self.phase = phase
329 self.journal = read_ndjson(root / f"journal-{phase}.ndjson")
330 self.witness = read_ndjson(root / f"witness-{phase}.ndjson")
331 self.holds = holds_from_journal(self.journal)
332 self.intervals = intervals_from_witness(self.witness)
333
334 @property
335 def usable(self) -> bool:
336 """False when an artefact is missing, which is UNKNOWN and not a pass."""
337 return bool(self.witness)
338
339
340def _load_assignment(root: Path, phase: str) -> dict[str, str]:
341 """Actor name -> app name, as the driver assigned it for *phase*.
342
343 Per phase, because the phases assign differently -- `death` leaves its
344 victim out -- and a single shared file let the last phase to run rewrite
345 the record the earlier ones are judged against, which read as an actor
346 having done no work when it had flashed the board perfectly well.
347 """
348 path = root / f"assignment-{phase}.txt"
349 out: dict[str, str] = {}
350 if not path.is_file():
351 return out
352 for line in path.read_text().splitlines():
353 parts = line.split()
354 if len(parts) >= ASSIGNMENT_FIELDS:
355 out[parts[0]] = parts[1]
356 return out
357
358
359def claim_exclusion(v: Verdict, root: Path, roster: list[str]) -> None:
360 """Mutual exclusion, journal truthfulness, and no physical interleaving."""
361 data = PhaseData(root, "exclusion")
362 phase = "exclusion"
363 if not data.usable:
364 v.record(phase, "artefacts", None, "no witness output for this phase")
365 return
366 assignment = _load_assignment(root, "exclusion")
367 attribute_by_payload(data.intervals, assignment)
368
369 ok, detail = check_attribution_axes(data.intervals)
370 v.record(phase, "attribution-agrees", ok, detail)
371
372 collisions = census_collisions(data.witness)
373 pairs = overlapping_pairs(data.intervals)
374 v.record(
375 phase,
376 "no-physical-overlap",
377 not collisions and not pairs,
378 f"{len(data.intervals)} tool session(s); {len(collisions)} census collision(s); "
379 f"{len(pairs)} overlapping pair(s)" + (f" -- {pairs[0]}" if pairs else ""),
380 )
381
382 console = console_collisions(data.witness)
383 v.record(
384 phase,
385 "no-console-collision",
386 not console,
387 "no instant had two machines reading the board console"
388 if not console
389 else f"{len(console)} instant(s) with two console readers, first {console[0]}",
390 )
391
392 worked = {iv.actor for iv in data.intervals if iv.actor}
393 missing = [a for a in roster if a not in worked]
394 v.record(
395 phase,
396 "every-actor-really-worked",
397 not missing,
398 f"programmed the board: {sorted(worked)}"
399 + (f"; NO hardware evidence for {missing}" if missing else ""),
400 )
401
402 overlaps = hold_overlaps(data.holds)
403 v.record(
404 phase,
405 "holds-serialised",
406 not overlaps,
407 f"{len(data.holds)} hold(s) in the journal, none overlapping"
408 if not overlaps
409 else f"overlapping holds: {overlaps[0]}",
410 )
411 _claim_containment(v, phase, data)
412
413
414def _claim_containment(v: Verdict, phase: str, data: PhaseData) -> None:
415 """Every hardware interval sat inside the hold the journal attributes to it.
416
417 This is where the journal is checked against reality in both directions: no
418 hardware activity outside a hold (the lock did not miss anything), and each
419 actor's activity inside ITS OWN hold (the journal named the right actor).
420 """
421 named = [iv for iv in data.intervals if iv.actor]
422 if not named:
423 v.record(phase, "journal-matches-hardware", None, "no attributable hardware activity")
424 return
425 outside, misattributed = [], []
426 for iv in named:
427 window = next(
428 (
429 h
430 for h in data.holds
431 if h.start - CONTAINMENT_SLACK_S <= iv.start
432 and iv.end <= h.end + CONTAINMENT_SLACK_S
433 ),
434 None,
435 )
436 if window is None:
437 outside.append(iv)
438 elif iv.actor not in window.holder:
439 misattributed.append((iv, window.holder))
440 v.record(
441 phase,
442 "journal-matches-hardware",
443 not outside and not misattributed,
444 f"{len(named)} programming session(s), each inside the hold the journal "
445 f"attributes to the same actor"
446 if not (outside or misattributed)
447 else f"unlocked activity: {outside}; misattributed: {misattributed}",
448 )
449
450
451def claim_negative_control(v: Verdict, root: Path) -> None:
452 """The witness must be able to SEE a collision, or nothing else means much."""
453 data = PhaseData(root, "negctl")
454 phase = "negative-control"
455 if not data.usable:
456 v.record(phase, "artefacts", None, "no witness output for this phase")
457 return
458 collisions = census_collisions(data.witness)
459 pairs = overlapping_pairs(data.intervals)
460 peers = sorted({iv.peer for iv in data.intervals if iv.peer})
461 v.record(
462 phase,
463 "witness-detects-collision",
464 bool(collisions or pairs),
465 f"unguarded run from {len(peers)} machine(s) {peers}: "
466 f"{len(collisions)} census collision(s), {len(pairs)} overlapping pair(s)"
467 + (
468 ""
469 if (collisions or pairs)
470 else " -- the witness saw NO collision, so a clean guarded run proves nothing"
471 ),
472 )
473 console = console_collisions(data.witness)
474 v.record(
475 phase,
476 "witness-detects-console-collision",
477 bool(console),
478 f"{len(console)} instant(s) with two machines on one console"
479 + (
480 f", up to {max(len(p) for _, p in console)} at once" if console else " -- axis unproven"
481 ),
482 )
483 v.record(
484 phase,
485 "no-lock-was-held",
486 not data.holds,
487 f"{len(data.holds)} hold(s) in the journal during the unguarded phase",
488 )
489
490
491def _kill_instant(root: Path) -> float:
492 """The instant the victim's ssh client was SIGKILLed, from the kill log."""
493 path = root / "kill-marker.txt"
494 if not path.is_file():
495 return 0.0
496 match = re.search(r"KILLED at ([0-9.]+)", path.read_text())
497 return float(match.group(1)) if match else 0.0
498
499
500def _claim_death_release(v: Verdict, data: PhaseData, killed: float) -> None:
501 """The flock really dropped when the holder's ssh client was SIGKILLed."""
502 released = [h for h in data.holds if h.end != float("inf") and h.end >= killed - 1]
503 if not released:
504 v.record(
505 "death", "lock-released-on-death", ok=False, detail="no release followed the SIGKILL"
506 )
507 return
508 # The journal stamps whole seconds (`date -Iseconds`) while the kill instant
509 # is sub-second, so a release inside the same second can read as very
510 # slightly negative. Clamp rather than print a negative delay that invites a
511 # reader to distrust the whole table.
512 gap = max(0.0, min(h.end for h in released) - killed)
513 v.record(
514 "death",
515 "lock-released-on-death",
516 gap <= DEATH_HANDOVER_LIMIT_S,
517 f"the journal recorded a release within {gap:.0f}-1s of the SIGKILL "
518 "(journal resolution is one second), with no trap having run",
519 )
520
521
522def _claim_death_handover(v: Verdict, data: PhaseData, killed: float, first: Hold) -> None:
523 """A waiter got in promptly ONCE THE BOARD WAS ACTUALLY IDLE.
524
525 A waiter must not be judged against the SIGKILL instant. The victim's J-Link
526 session lives on the bench host, over its own ssh, and does not die with the
527 hold; the bench host deliberately withholds the board until that leftover
528 finishes. Time spent in that interlock is the interlock WORKING.
529 """
530 in_flight = [iv for iv in data.intervals if iv.start <= killed <= iv.end]
531 idle_at = max([killed, *[iv.end for iv in in_flight]])
532 lingered = idle_at - killed
533 v.note(
534 "death",
535 "board-quiesced-before-handover",
536 f"the dead holder's hardware ran {lingered:.1f}s past its own release; "
537 f"the bench host withheld the board until it stopped"
538 if lingered > LINGER_NOTICEABLE_S
539 else "no hardware outlived the dead holder",
540 )
541 delay = max(0.0, first.start - idle_at)
542 v.record(
543 "death",
544 "waiter-gets-in",
545 delay <= DEATH_HANDOVER_LIMIT_S,
546 f"{first.holder} acquired {delay:.1f}s after the board went idle "
547 f"({first.start - killed:.1f}s after the SIGKILL, of which {lingered:.1f}s was the "
548 f"leftover session); limit {DEATH_HANDOVER_LIMIT_S:.0f}s",
549 )
550
551
552def _claim_death_work(v: Verdict, data: PhaseData, root: Path, first: Hold) -> None:
553 """The waiter really used the board, and never at the same time as the dead one."""
554 attribute_by_payload(data.intervals, _load_assignment(root, "death"))
555 worked = [
556 iv
557 for iv in data.intervals
558 if iv.start >= first.start - CONTAINMENT_SLACK_S
559 and iv.end <= first.end + CONTAINMENT_SLACK_S
560 ]
561 v.record(
562 "death",
563 "waiter-really-used-the-board",
564 bool(worked),
565 f"{len(worked)} tool session(s) inside {first.holder}'s hold"
566 if worked
567 else f"{first.holder} took the lock but no hardware activity is recorded inside it",
568 )
569 pairs = overlapping_pairs(data.intervals)
570 v.record(
571 "death",
572 "no-overlap-across-the-handover",
573 not pairs,
574 "the dead holder's work and the waiter's never coincided"
575 if not pairs
576 else f"OVERLAP across the handover: {pairs[0]}",
577 )
578
579
580def claim_death(v: Verdict, root: Path) -> None:
581 """A waiter really gets the board after the holder is killed."""
582 data = PhaseData(root, "death")
583 if not data.usable:
584 v.record("death", "artefacts", None, "no witness output for this phase")
585 return
586 killed = _kill_instant(root)
587 if killed <= 0:
588 v.record("death", "kill-recorded", None, "no KILLED marker in kill-marker.txt")
589 return
590 _claim_death_release(v, data, killed)
591 after = [h for h in data.holds if h.start >= killed]
592 if not after:
593 v.record(
594 "death",
595 "waiter-gets-in",
596 ok=False,
597 detail=f"nothing acquired the bench after the SIGKILL at {killed:.2f} -- "
598 "the queue did not move",
599 )
600 return
601 first = min(after, key=lambda h: h.start)
602 _claim_death_handover(v, data, killed, first)
603 _claim_death_work(v, data, root, first)
604
605
606def _fairness_rows(root: Path, roster: list[str]) -> dict[str, list[float]]:
607 """Per-actor wait times, from each actor's own request/acquire timeline.
608
609 The request instant is the actor's own claim, which is the one thing here
610 that cannot be taken from the kernel -- a machine's intent to ask is not
611 visible on the bench. It is a safe claim to accept: overstating it can only
612 make an actor look like it waited LESS, so it cannot manufacture fairness.
613 """
614 waits: dict[str, list[float]] = {a: [] for a in roster}
615 journal = read_ndjson(root / "journal-fairness.ndjson")
616 acquires = [
617 (iso_epoch(e.get("at", "")), e.get("holder_name", ""))
618 for e in journal
619 if e.get("event") == "acquire"
620 ]
621 for actor in roster:
622 log = root / f"fairness-{actor}.log"
623 if not log.is_file():
624 continue
625 mine = sorted(t for t, who in acquires if actor in who)
626 requests = sorted(
627 float(m.group(1))
628 for m in re.finditer(
629 rf"ROUND \d+ {re.escape(actor)} request ([0-9.]+)", log.read_text()
630 )
631 )
632 # Requests and grants are both in time order and strictly alternate for
633 # one actor -- it cannot ask again before its previous turn is over --
634 # so pair them by consuming the grant list as the requests are walked.
635 # An index-into-a-filtered-list did this before and matched only every
636 # other round, which under-reported the number of waits measured.
637 pending = list(mine)
638 for req in requests:
639 grant = next((t for t in pending if t >= req - 1), None)
640 if grant is None:
641 continue
642 pending.remove(grant)
643 waits[actor].append(grant - req)
644 return waits
645
646
647def claim_fairness(v: Verdict, root: Path, roster: list[str], rounds: int) -> None:
648 """Report the wait distribution honestly, and fail only on real starvation."""
649 phase = "fairness"
650 journal = read_ndjson(root / "journal-fairness.ndjson")
651 if not journal:
652 v.record(phase, "artefacts", None, "no journal slice for this phase")
653 return
654 holds = holds_from_journal(journal)
655 counts = {a: sum(1 for h in holds if a in h.holder) for a in roster}
656 starved = [a for a, n in counts.items() if n == 0]
657 v.record(
658 phase,
659 "nobody-starved",
660 not starved,
661 f"acquires per actor: {counts} (asked for {rounds} each)"
662 + (f"; STARVED: {starved}" if starved else ""),
663 )
664 v.record(
665 phase,
666 "all-requests-served",
667 all(n >= rounds for n in counts.values()),
668 f"every actor got all {rounds} of its turns"
669 if all(n >= rounds for n in counts.values())
670 else f"short of {rounds} turns: { {a: n for a, n in counts.items() if n < rounds} }",
671 )
672 waits = _fairness_rows(root, roster)
673 summary = {
674 a: f"n={len(w)} max={max(w):.1f}s avg={sum(w) / len(w):.1f}s" for a, w in waits.items() if w
675 }
676 v.note(phase, "wait-distribution", f"{summary}")
677 order = [h.holder.split(":")[-1] for h in sorted(holds, key=lambda h: h.start)]
678 v.note(phase, "grant-order", " -> ".join(order))
679 v.record(
680 phase,
681 "no-overlapping-holds",
682 not hold_overlaps(holds),
683 f"{len(holds)} hold(s), none overlapping",
684 )
685
686
687def _fake_interval(pid: int, peer: str, start: float, end: float) -> Interval:
688 """A synthetic tool occupancy, for the selftest."""
689 return Interval(
690 {"pid": pid, "tool": "JLinkExe", "peer": peer, "start_epoch": start, "script": ""},
691 {"gone_by": end},
692 )
693
694
695def _selftest_overlap() -> list[str]:
696 """Overlap must be found when it is there, and not when it is not."""
697 failures = []
698 clean = [
699 _fake_interval(1, "10.0.0.1", 100.0, 110.0),
700 _fake_interval(2, "10.0.0.2", 110.5, 120.0),
701 ]
702 if overlapping_pairs(clean):
703 failures.append("overlapping_pairs reported an overlap between disjoint intervals")
704 dirty = [
705 _fake_interval(1, "10.0.0.1", 100.0, 110.0),
706 _fake_interval(2, "10.0.0.2", 109.9, 120.0),
707 ]
708 if not overlapping_pairs(dirty):
709 failures.append("overlapping_pairs MISSED a 0.1s overlap between two machines")
710 same = [
711 _fake_interval(1, "10.0.0.1", 100.0, 110.0),
712 _fake_interval(2, "10.0.0.1", 105.0, 120.0),
713 ]
714 if overlapping_pairs(same):
715 failures.append("overlapping_pairs flagged one machine overlapping itself")
716 if not census_collisions([{"ev": "census", "peers": ["a", "b"]}]):
717 failures.append("census_collisions missed a two-peer census")
718 if census_collisions([{"ev": "census", "peers": ["a"]}]):
719 failures.append("census_collisions flagged a single-peer census")
720 return failures
721
722
723def _selftest_console() -> list[str]:
724 """Two machines on ONE console at ONE instant, and nothing weaker."""
725
726 def tty(when: float, dev: str, peer: str) -> dict:
727 return {"ev": "tty", "t": when, "dev": dev, "peer": peer}
728
729 failures = []
730 if console_collisions([tty(1.0, "/dev/ttyACM1", "a"), tty(2.0, "/dev/ttyACM1", "b")]):
731 failures.append("console_collisions flagged two readers that were not simultaneous")
732 if not console_collisions([tty(1.0, "/dev/ttyACM1", "a"), tty(1.0, "/dev/ttyACM1", "b")]):
733 failures.append("console_collisions MISSED two machines on one console at one instant")
734 if console_collisions([tty(1.0, "/dev/ttyACM1", "a"), tty(1.0, "/dev/ttyACM1", "a")]):
735 failures.append("console_collisions flagged one machine holding a console twice")
736 if console_collisions([tty(1.0, "/dev/ttyACM1", "a"), tty(1.0, "/dev/ttyACM2", "b")]):
737 failures.append("console_collisions flagged two DIFFERENT consoles as a collision")
738 return failures
739
740
741def _selftest_journal() -> list[str]:
742 """Journal reconstruction, and the timestamp parser it rests on."""
743 failures = []
744 events = [
745 {"at": "2026-01-01T00:00:00+00:00", "event": "acquire", "lock_id": "a", "holder_name": "x"},
746 {"at": "2026-01-01T00:00:10+00:00", "event": "release", "lock_id": "a", "holder_name": "x"},
747 {"at": "2026-01-01T00:00:05+00:00", "event": "acquire", "lock_id": "b", "holder_name": "y"},
748 {"at": "2026-01-01T00:00:20+00:00", "event": "release", "lock_id": "b", "holder_name": "y"},
749 ]
750 if not hold_overlaps(holds_from_journal(events)):
751 failures.append("hold_overlaps missed two journal holds that overlap")
752 if iso_epoch("2026-01-01T00:00:00+00:00") <= 0:
753 failures.append("iso_epoch could not parse a journal timestamp")
754 if iso_epoch("nonsense") != 0.0:
755 failures.append("iso_epoch accepted a non-timestamp")
756 return failures
757
758
759def selftest() -> int:
760 """Prove the analyser calls a collision a collision, and a clean run clean.
761
762 A verdict tool that returned PASS on any input would be the most expensive
763 kind of green in this repository. Both directions are asserted on synthetic
764 witness data, so the check does not depend on a bench being present.
765 """
766 failures = _selftest_overlap() + _selftest_console() + _selftest_journal()
767 for text in failures:
768 print(f"bench_contention_verdict: SELFTEST FAIL -- {text}", file=sys.stderr)
769 if failures:
770 return EXIT_FAIL
771 print("bench_contention_verdict: selftest OK -- overlap detected and non-overlap not")
772 return EXIT_OK
773
774
775def main(argv: list[str]) -> int:
776 """Run every requested phase's claims and print one verdict."""
777 parser = argparse.ArgumentParser(description="bench contention verdict")
778 parser.add_argument("--dir", type=Path, help="run artefact directory")
779 parser.add_argument("--phases", default="exclusion,negative-control,death,fairness")
780 parser.add_argument("--rounds", type=int, default=3)
781 parser.add_argument("--selftest", action="store_true")
782 args = parser.parse_args(argv)
783
784 if args.selftest:
785 return selftest()
786 if args.dir is None or not args.dir.is_dir():
787 print(
788 "bench_contention_verdict: --dir must name an existing run directory", file=sys.stderr
789 )
790 return EXIT_UNKNOWN
791
792 roster_file = args.dir / "roster.txt"
793 if not roster_file.is_file():
794 print("bench_contention_verdict: no roster.txt in the run directory", file=sys.stderr)
795 return EXIT_UNKNOWN
796 roster = [line.strip() for line in roster_file.read_text().splitlines() if line.strip()]
797
798 v = Verdict()
799 phases = [p.strip() for p in args.phases.split(",") if p.strip()]
800 if "exclusion" in phases:
801 claim_exclusion(v, args.dir, roster)
802 if "negative-control" in phases:
803 claim_negative_control(v, args.dir)
804 if "death" in phases:
805 claim_death(v, args.dir)
806 if "fairness" in phases:
807 claim_fairness(v, args.dir, roster, args.rounds)
808 if not v.rows:
809 print("bench_contention_verdict: no claims were evaluated", file=sys.stderr)
810 return EXIT_UNKNOWN
811 return v.report()
812
813
814if __name__ == "__main__":
815 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157