#!/usr/bin/env python3 """Reap orphaned/runaway processes that saturate the Nautilus box. Two known offenders (2026-06-07): 1. ``task-master-mcp`` (Node) — spawned by the Claude Code VSCode extension, orphaned to ``ppid=1`` when its session ends, then **balloons to ~3GB each**. They accrue as sessions cycle and exhaust RAM within minutes, risking the OOM-killer hitting Postgres/Paperclip. See memory ``project_taskmaster_mcp_memory_leak``. 2. ``camoufox-bin`` (Firefox) — the X13 court-fetch browser. A fetch that hangs or is killed mid-flight can leave a stray browser orphaned to ``ppid=1``. Serial-only fetching means any ``ppid=1`` camoufox-bin is stale and safe to kill. Safety: only processes **orphaned to ``ppid=1``** are reaped — a process still owned by a live parent (an attached MCP server, or a browser a fetch is actively using) is never touched. Pure ``/proc`` parsing, no psutil dependency. Usage: python scripts/reap_orphan_procs.py # one pass, print what was reaped python scripts/reap_orphan_procs.py --dry-run # report only python scripts/reap_orphan_procs.py --loop 180 # daemon: reap every 180s """ from __future__ import annotations import argparse import os import signal import sys import time # Process-name substrings to reap when orphaned (ppid==1). TARGETS = ("task-master-mcp", "camoufox-bin") def _read(path: str) -> str: try: with open(path, "rb") as f: return f.read().decode("utf-8", "replace") except OSError: return "" def _proc_info(pid: str) -> tuple[int, str, int] | None: """Return (ppid, cmdline, rss_kb) for a pid, or None if it vanished.""" status = _read(f"/proc/{pid}/status") if not status: return None ppid, rss = 0, 0 for line in status.splitlines(): if line.startswith("PPid:"): try: ppid = int(line.split()[1]) except (IndexError, ValueError): pass elif line.startswith("VmRSS:"): try: rss = int(line.split()[1]) except (IndexError, ValueError): pass cmd = _read(f"/proc/{pid}/cmdline").replace("\x00", " ").strip() return ppid, cmd, rss def find_orphans() -> list[tuple[str, str, int]]: """Return [(pid, cmd, rss_kb)] of ppid==1 processes matching TARGETS.""" out = [] for pid in os.listdir("/proc"): if not pid.isdigit(): continue info = _proc_info(pid) if not info: continue ppid, cmd, rss = info if ppid == 1 and any(t in cmd for t in TARGETS): out.append((pid, cmd, rss)) return out def reap(dry_run: bool = False) -> int: orphans = find_orphans() freed_mb = 0 for pid, cmd, rss in orphans: name = next((t for t in TARGETS if t in cmd), cmd[:30]) freed_mb += rss // 1024 if dry_run: print(f"[dry-run] would reap pid={pid} ({name}) rss={rss//1024}MB", flush=True) continue try: os.kill(int(pid), signal.SIGKILL) print(f"reaped pid={pid} ({name}) rss={rss//1024}MB", flush=True) except ProcessLookupError: pass except PermissionError: print(f" permission denied for pid={pid} ({name})", flush=True) if orphans: print(f"{'would free' if dry_run else 'freed'} ~{freed_mb}MB " f"from {len(orphans)} orphan(s)", flush=True) return len(orphans) def main() -> int: ap = argparse.ArgumentParser(description="Reap orphaned task-master-mcp / camoufox-bin") ap.add_argument("--dry-run", action="store_true", help="report only, kill nothing") ap.add_argument("--loop", type=int, default=0, metavar="SECONDS", help="run forever, reaping every N seconds") args = ap.parse_args() if args.loop: print(f"reaper loop: every {args.loop}s targets={TARGETS}", flush=True) while True: try: reap(args.dry_run) except Exception as e: # never let the daemon die print(f"reap error: {e}", flush=True) time.sleep(args.loop) else: reap(args.dry_run) return 0 if __name__ == "__main__": sys.exit(main())