| #!/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() |