summaryrefslogtreecommitdiff
path: root/test/hil/board_lock.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/hil/board_lock.py')
-rwxr-xr-xtest/hil/board_lock.py254
1 files changed, 0 insertions, 254 deletions
diff --git a/test/hil/board_lock.py b/test/hil/board_lock.py
deleted file mode 100755
index c35e13705..000000000
--- a/test/hil/board_lock.py
+++ /dev/null
@@ -1,254 +0,0 @@
-#!/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()