#!/usr/bin/env python3 # tractor: structured concurrent "actors". # Copyright 2018-eternity Tyler Goodlet. # # SPDX-License-Identifier: AGPL-3.0-or-later ''' `tractor-reap` — SC-polite zombie-subactor reaper. Finds `tractor` subactor processes left alive after a `pytest` (or any tractor-app) run that failed to fully cancel its actor tree, then sends SIGINT with a bounded grace window before escalating to SIGKILL. Detection modes (auto-selected): --parent : descendant-mode — kill procs whose PPid == . Use when a parent is still alive and you want to scope the sweep precisely (e.g. CI wrapper calling in from outside pytest). (default) : orphan-mode — kill procs with PPid==1 (init-reparented) whose cwd matches the repo root AND whose cmdline contains `python`. The cwd filter is what prevents sweeping unrelated init-children. Usage: # after a pytest run crashed/was Ctrl+C'd scripts/tractor-reap # from inside a still-live supervisor scripts/tractor-reap --parent 12345 # dry-run: list what would be reaped, don't signal scripts/tractor-reap -n ''' import argparse import pathlib import subprocess import sys def _repo_root() -> pathlib.Path: ''' Use `git rev-parse --show-toplevel` when available; fall back to the repo this script lives in. ''' try: out: str = subprocess.check_output( ['git', 'rev-parse', '--show-toplevel'], stderr=subprocess.DEVNULL, text=True, ).strip() return pathlib.Path(out) except (subprocess.CalledProcessError, FileNotFoundError): return pathlib.Path(__file__).resolve().parent.parent def main() -> int: parser = argparse.ArgumentParser( prog='tractor-reap', description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( '--parent', '-p', type=int, default=None, help='descendant-mode: reap procs with PPid==', ) parser.add_argument( '--grace', '-g', type=float, default=3.0, help='SIGINT grace window in seconds (default 3.0)', ) parser.add_argument( '--dry-run', '-n', action='store_true', help='list matched pids but do not signal', ) args = parser.parse_args() # import lazily so `--help` doesn't require the tractor # package to be importable (e.g. when running from a # shell not inside a venv). repo = _repo_root() sys.path.insert(0, str(repo)) from tractor._testing._reap import ( find_descendants, find_orphans, reap, ) if args.parent is not None: pids: list[int] = find_descendants(args.parent) mode: str = f'descendants of PPid={args.parent}' else: pids = find_orphans(repo) mode = f'orphans (PPid=1, cwd={repo})' if not pids: print(f'[tractor-reap] no {mode} to reap') return 0 if args.dry_run: print(f'[tractor-reap] dry-run — {mode}:\n {pids}') return 0 signalled, survivors = reap(pids, grace=args.grace) # exit 0 if everyone exited cleanly, else 1 to signal # escalation happened — makes the command useful in # CI health-checks and `||`-chaining. return 0 if not survivors else 1 if __name__ == '__main__': raise SystemExit(main())