#!/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 + optional `/dev/shm/` orphan-segment sweep. Two cleanup phases (run in order when both are enabled): 1. **process reap** — 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. 2. **shm sweep** (`--shm` / `--shm-only`) — unlinks `/dev/shm/` entries owned by the current uid that no live process has open (mmap'd or fd-held). Needed because `tractor` disables `mp.resource_tracker` (see `tractor.ipc._mp_bs`), so a hard-crashing actor leaves leaked segments that nothing else GCs. Process-reap 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: # process reap only (default) scripts/tractor-reap # process reap + shm sweep scripts/tractor-reap --shm # only the shm sweep, skip process reap scripts/tractor-reap --shm-only # from inside a still-live supervisor scripts/tractor-reap --parent 12345 # dry-run: list what would be reaped, don't act scripts/tractor-reap -n scripts/tractor-reap --shm -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/paths but do not signal/unlink', ) parser.add_argument( '--shm', action='store_true', help=( 'after process reap, also unlink orphaned ' '/dev/shm segments owned by the current user ' 'that no live process is mapping or holding open' ), ) parser.add_argument( '--shm-only', action='store_true', help='skip process reap; only do the shm sweep', ) 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, find_orphaned_shm, reap, reap_shm, ) rc: int = 0 # --- phase 1: process reap (skipped under --shm-only) --- if not args.shm_only: 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') elif args.dry_run: print( f'[tractor-reap] dry-run — {mode}:\n {pids}' ) else: _, survivors = reap(pids, grace=args.grace) if survivors: rc = 1 # --- phase 2: shm sweep (opt-in) --- if args.shm or args.shm_only: leaked: list[str] = find_orphaned_shm() if not leaked: print( '[tractor-reap] no orphaned /dev/shm ' 'segments to sweep' ) elif args.dry_run: print( f'[tractor-reap] dry-run — {len(leaked)} ' f'orphaned shm segment(s):\n {leaked}' ) else: _, errors = reap_shm(leaked) if errors: rc = 1 # exit 0 if everything cleaned cleanly, else 1 — useful # for CI health-check chaining. return rc if __name__ == '__main__': raise SystemExit(main())