mirror of
https://github.com/hathach/tinyusb.git
synced 2026-08-18 02:53:35 +00:00
Confirmed by a 10-finder / 28-verifier adversarial review pass: board_lock.py — the flock is now the sole authority: drop cmd_hold's pid-liveness pre-gate (a live hil_test.py pool worker's stale record no longer blocks a genuinely free board); cmd_release probes the flock and only signals a verified holder, refuses to kill hil_test.py holders (CI mid-test), handles PermissionError; the holder daemon truncates its lock records on SIGTERM and keeps the success pipe clear of fds 0-2 (closed-stdio hold used to leave an orphan holder while reporting failure); --config default resolves beside the script. hil_test.py — truncate the lock record on per-board release (pool workers outlive their flocks); warn instead of silently failing open when the lock dir is unusable; error out on -b names absent from the config (was a silent zero-test exit 0, readable as a green HIL run); drop an emptied board row in accumulate_report (variant boards left a blank ghost row). workflows — remove the stray positional arg that made the validate size stage exit 2 on every run; wrap JSON.parse(args) in all six scripts; factor pr-babysit's drifted reply recipe into postReplyRecipe and dedup refutation replies across cycles; validate args.pr and maxCycles; driver-review rejects an empty dimensions list; hil-validate drops a dead guard clause and retries diagnostics with -v -r 1. agents/docs — port-dev scopes git clang-format to its own files (concurrent workers reformatted each other in shared checkouts); hil-operator/hil skill wording matches actual fail-fast output; the implementation plan is now a DO-NOT-EXECUTE historical record (banner + checked boxes) so plan-executing agents cannot revert shipped files. Verified: lock storm 1-winner-in-10, stale-record hold, closed-stdio hold, dead-pid cleanup, CI-holder refusal, ghost-row 4-scenario merge, unknown-board exit 1, py_compile + check.sh on all six workflows, pre-commit clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Upj4hta5TNoAbidqeC1zZ6
255 lines
8.7 KiB
Python
Executable File
255 lines
8.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Per-board advisory locks for the HIL rig.
|
|
|
|
Arbitrates board access between dev sessions and CI's hil_test.py without
|
|
stopping the actions-runner. Locks are kernel flocks: the kernel releases
|
|
them automatically when the holder process dies, and holders clear their
|
|
lock-file record on release so records stay truthful (/tmp also clears on
|
|
reboot).
|
|
|
|
Usage:
|
|
board_lock.py hold BOARD [BOARD...] --reason TEXT
|
|
board_lock.py hold --all [--config CONFIG.json] --reason TEXT
|
|
board_lock.py release BOARD [BOARD...] | release --all
|
|
board_lock.py status
|
|
|
|
A holder process holds ALL boards given in one `hold` call; releasing any of
|
|
them kills that holder and releases all of its boards.
|
|
"""
|
|
import argparse
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import select
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
LOCK_DIR = '/tmp/tinyusb-hil-locks'
|
|
|
|
|
|
def lock_path(board: str) -> str:
|
|
return os.path.join(LOCK_DIR, f'{board}.lock')
|
|
|
|
|
|
def boards_from_config(config: str) -> list:
|
|
try:
|
|
with open(config) as f:
|
|
return [b['name'] for b in json.load(f)['boards']]
|
|
except (OSError, ValueError, KeyError) as e:
|
|
print(f'ERROR: cannot read board roster {config}: {e}', file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def read_info(board: str):
|
|
try:
|
|
with open(lock_path(board)) as f:
|
|
return json.load(f)
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def is_locked(board: str) -> bool:
|
|
"""True if the recorded holder process is still alive.
|
|
|
|
Deliberately never touches the flock: even a momentary probe lock would
|
|
make a concurrent acquirer's LOCK_NB attempt fail spuriously. The flock
|
|
taken by acquirers themselves stays the only authority."""
|
|
info = read_info(board)
|
|
pid = info.get('pid') if isinstance(info, dict) else None
|
|
if not isinstance(pid, int) or pid <= 0:
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True # alive but owned by another user (e.g. the CI runner)
|
|
return True
|
|
|
|
|
|
def cmd_hold(boards, reason):
|
|
os.makedirs(LOCK_DIR, exist_ok=True)
|
|
# No pre-check: the holder's own LOCK_NB flock is the only authority — a
|
|
# recorded pid may be stale or recycled (e.g. a live hil_test.py worker
|
|
# that already released this board's flock but not its record).
|
|
# The holder signals success through this pipe. A generic is_locked()
|
|
# poll would be fooled by a RIVAL invocation's flock — only the holder
|
|
# itself knows whether it won every board.
|
|
r_fd, w_fd = os.pipe()
|
|
pid = os.fork()
|
|
if pid > 0:
|
|
os.close(w_fd)
|
|
os.waitpid(pid, 0) # reap intermediate child
|
|
ready, _, _ = select.select([r_fd], [], [], 10)
|
|
ok = bool(ready) and os.read(r_fd, 1) == b'1'
|
|
os.close(r_fd)
|
|
if ok:
|
|
print(f'held: {", ".join(boards)}')
|
|
return 0
|
|
for b in boards:
|
|
info = read_info(b)
|
|
if info:
|
|
print(f'ERROR: {b} locked: {info}', file=sys.stderr)
|
|
print('ERROR: holder failed to acquire locks', file=sys.stderr)
|
|
return 1
|
|
# intermediate child: detach, then spawn the actual holder
|
|
os.setsid()
|
|
if os.fork() > 0:
|
|
os._exit(0)
|
|
# holder (grandchild): acquire all flocks, signal the parent, sleep until killed
|
|
os.close(r_fd)
|
|
# Keep the success pipe clear of fds 0-2: invoked with stdio closed,
|
|
# os.pipe() can land there and the dup2 loop below would clobber it.
|
|
if w_fd <= 2:
|
|
w_fd = fcntl.fcntl(w_fd, fcntl.F_DUPFD, 3)
|
|
# Detach stdio: a `hold` whose output is captured must see EOF when the
|
|
# front-end exits — the immortal holder must not keep that pipe open.
|
|
devnull = os.open(os.devnull, os.O_RDWR)
|
|
for std_fd in (0, 1, 2):
|
|
os.dup2(devnull, std_fd)
|
|
if devnull > 2:
|
|
os.close(devnull)
|
|
try:
|
|
handles = []
|
|
for b in boards:
|
|
# O_RDWR without O_TRUNC: never truncate before the flock is
|
|
# held — a losing racer must not wipe the winner's holder info.
|
|
fd = os.open(lock_path(b), os.O_RDWR | os.O_CREAT, 0o666)
|
|
fh = os.fdopen(fd, 'r+')
|
|
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fh.truncate(0)
|
|
fh.seek(0)
|
|
json.dump({'pid': os.getpid(), 'reason': reason,
|
|
'since': time.strftime('%Y-%m-%dT%H:%M:%S%z')}, fh)
|
|
fh.flush()
|
|
handles.append(fh)
|
|
except OSError:
|
|
try:
|
|
os.write(w_fd, b'0')
|
|
except OSError:
|
|
pass
|
|
os._exit(1) # lost a race; parent reports the failure
|
|
os.write(w_fd, b'1')
|
|
os.close(w_fd)
|
|
|
|
def _bow_out(*_):
|
|
# clear the records before dying so read_info/status stay truthful
|
|
# (the kernel drops the flocks themselves on exit either way)
|
|
for h in handles:
|
|
try:
|
|
h.truncate(0)
|
|
except OSError:
|
|
pass
|
|
os._exit(0)
|
|
|
|
signal.signal(signal.SIGTERM, _bow_out)
|
|
while True:
|
|
signal.pause()
|
|
|
|
|
|
def cmd_release(boards):
|
|
rc = 0
|
|
victims = set()
|
|
for b in boards:
|
|
try:
|
|
fd = os.open(lock_path(b), os.O_RDWR)
|
|
except OSError:
|
|
continue # no lock file (or another user's): nothing we can release
|
|
fh = os.fdopen(fd, 'r+')
|
|
try:
|
|
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
# flock genuinely held — never SIGTERM on a mere pid record: the
|
|
# pid may be recycled, or a live worker that already moved on.
|
|
fh.close()
|
|
info = read_info(b) or {}
|
|
pid = info.get('pid')
|
|
if info.get('reason') == 'hil_test.py':
|
|
print(f'ERROR: {b} is mid-test by hil_test.py (pid {pid}) — not killing a '
|
|
'CI run; wait for it to finish', file=sys.stderr)
|
|
rc = 1
|
|
elif isinstance(pid, int) and pid > 0:
|
|
victims.add(pid)
|
|
else:
|
|
print(f'ERROR: {b} is held but its record is unreadable', file=sys.stderr)
|
|
rc = 1
|
|
continue
|
|
# flock was free: only a stale record remained — clear it
|
|
try:
|
|
fh.truncate(0)
|
|
except OSError:
|
|
pass
|
|
fh.close()
|
|
for holder in sorted(victims):
|
|
try:
|
|
os.kill(holder, signal.SIGTERM)
|
|
print(f'released holder pid {holder}')
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
print(f'ERROR: holder pid {holder} belongs to another user — cannot signal it',
|
|
file=sys.stderr)
|
|
rc = 1
|
|
time.sleep(0.3)
|
|
still = [b for b in boards if is_locked(b)]
|
|
if still:
|
|
print(f'ERROR: still locked: {", ".join(still)}', file=sys.stderr)
|
|
return 1
|
|
return rc
|
|
|
|
|
|
def cmd_status():
|
|
if not os.path.isdir(LOCK_DIR):
|
|
print('no locks')
|
|
return 0
|
|
any_locked = False
|
|
for fn in sorted(os.listdir(LOCK_DIR)):
|
|
if not fn.endswith('.lock'):
|
|
continue
|
|
b = fn[:-5]
|
|
if is_locked(b):
|
|
any_locked = True
|
|
print(f'{b}: {read_info(b)}')
|
|
if not any_locked:
|
|
print('no locks')
|
|
return 0
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = ap.add_subparsers(dest='cmd', required=True)
|
|
p_hold = sub.add_parser('hold')
|
|
p_hold.add_argument('boards', nargs='*')
|
|
p_hold.add_argument('--all', action='store_true')
|
|
p_hold.add_argument('--config',
|
|
default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
'tinyusb.json'),
|
|
help='board roster JSON (default: tinyusb.json beside this script)')
|
|
p_hold.add_argument('--reason', required=True)
|
|
p_rel = sub.add_parser('release')
|
|
p_rel.add_argument('boards', nargs='*')
|
|
p_rel.add_argument('--all', action='store_true')
|
|
sub.add_parser('status')
|
|
a = ap.parse_args()
|
|
if a.cmd == 'hold':
|
|
boards = boards_from_config(a.config) if a.all else a.boards
|
|
if not boards:
|
|
ap.error('no boards given (name boards or use --all)')
|
|
sys.exit(cmd_hold(boards, a.reason))
|
|
if a.cmd == 'release':
|
|
if a.all:
|
|
boards = ([fn[:-5] for fn in os.listdir(LOCK_DIR) if fn.endswith('.lock')]
|
|
if os.path.isdir(LOCK_DIR) else [])
|
|
else:
|
|
boards = a.boards
|
|
if not boards:
|
|
ap.error('no boards given (name boards or use --all)')
|
|
sys.exit(cmd_release(boards))
|
|
sys.exit(cmd_status())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|