#!/usr/bin/env python3 """cleanup.py — comanda `!cleanup`: procese lasate in urma de tururi anterioare (T13). De ce exista (Codex #8): `KillMode=control-group` opreste arborele serviciului la restart, dar NU prinde procesele care s-au desprins — daemoni pornite cu `&` sau `nohup`, servere de dezvoltare lansate intr-un tur si uitate acolo, joburi lungi reparentate la init. Fiecare proces `claude` are ~406 MB RSS masurat, iar containerul are istoric de OOM: acumularea lor nu e cosmetica. Contract (INTERFACES.md, granita A <-> C): find_orphans(state) -> [{"pid", "cmdline", "age_s", "rss_mb"}] kill_orphans(orphans, dry_run=True) -> [{...}] `dry_run=True` e implicit si e intentionat: omul vede intai ce s-ar omori. Ce NU e considerat orfan: * procesele `claude` inregistrate in state.json si toti descendentii lor (sunt turul care ruleaza chiar acum); * procesul curent, parintii lui si botul insusi; * orice proces al altui utilizator; * infrastructura sesiunii (systemd --user, sshd, tmux, code-server, dbus...). """ from __future__ import annotations import os import pathlib import signal import sys import time try: # pragma: no cover - config.py apartine Lane A, poate lipsi la momentul asta import config as _config # type: ignore except Exception: # pragma: no cover _config = None PROC = pathlib.Path("/proc") CLOCK_TICKS = float(os.sysconf("SC_CLK_TCK")) PAGE_SIZE = float(os.sysconf("SC_PAGE_SIZE")) # Cgroup-ul serviciului: procesele reparentate la init care raman inauntru sunt # aproape sigur resturi ale unui tur. Numele e fix, vezi ops/claude-discord.service. SERVICE_CGROUP = "claude-discord.service" # Numele executabilului CLI-ului. Se compara pe BASENAME-ul fiecarui argument, nu ca # subsir: utilizatorul containerului se numeste tot `claude`, deci orice cale din # home-ul lui contine cuvantul si un match pe subsir ar da fals pozitiv pe tot. CLAUDE_BASENAMES = ("claude", "claude.exe") CLAUDE_PATH_MARKERS = ("claude-code/bin/claude", "node_modules/.bin/claude") # Procese care nu se ating niciodata, chiar daca sunt in cgroup si reparentate. NEVER_KILL = ( "systemd", "sshd", "dbus-daemon", "tmux", "code-server", "vscode-server", "bot.py", "claude-discord", "login", "bash -l", "(sd-pam)", "pytest", ) GRACE_S = 3.0 # cat asteptam intre SIGTERM si SIGKILL # --- citire /proc ---------------------------------------------------------- def _read(path: pathlib.Path) -> str: try: return path.read_text(errors="replace") except Exception: return "" def _boot_epoch() -> float: """Momentul (epoch) fata de care e masurat `starttime` din /proc//stat. ATENTIE, capcana de container: pe LXC 171 /proc/uptime e virtualizat de lxcfs (arata uptime-ul CONTAINERULUI), in timp ce `starttime` ramane raportat la boot-ul GAZDEI. Scaderea celor doua da varste negative (verificat: -561s pentru un proces pornit acum 10 minute; si `ps -o etimes` arata acolo 4123168064). `btime` din /proc/stat e coerent cu `starttime`, deci ea e referinta corecta. """ for line in _read(PROC / "stat").splitlines(): if line.startswith("btime"): try: return float(line.split()[1]) except Exception: break # ultima varianta: boot dedus din uptime (poate fi gresit in container) try: return time.time() - float(_read(PROC / "uptime").split()[0]) except Exception: return 0.0 def _parse_stat(pid: int) -> tuple[int, float] | None: """(ppid, starttime_ticks) din /proc//stat. comm-ul e intre paranteze si poate contine spatii, deci se taie dupa ultimul ')'. """ raw = _read(PROC / str(pid) / "stat") if not raw: return None try: rest = raw[raw.rindex(")") + 2:].split() # dupa comm, campul 1 e state; ppid e campul 4 global = rest[1] ppid = int(rest[1]) starttime = float(rest[19]) # campul 22 global return ppid, starttime except Exception: return None def _is_zombie(pid: int) -> bool: """Un proces terminat dar nereaped are inca /proc//stat; e mort, nu viu.""" raw = _read(PROC / str(pid) / "stat") try: return raw[raw.rindex(")") + 2:].split()[0] in ("Z", "X", "x") except Exception: return False def _rss_mb(pid: int) -> float: try: pages = float(_read(PROC / str(pid) / "statm").split()[1]) return round(pages * PAGE_SIZE / (1024 * 1024), 1) except Exception: return 0.0 def _cmdline(pid: int) -> str: raw = _read(PROC / str(pid) / "cmdline") if raw: parts = [p for p in raw.split("\0") if p] if parts: return " ".join(parts) # proces de kernel sau cmdline gol: cadem pe comm comm = _read(PROC / str(pid) / "comm").strip() return f"[{comm}]" if comm else "" def _uid(pid: int) -> int | None: for line in _read(PROC / str(pid) / "status").splitlines(): if line.startswith("Uid:"): try: return int(line.split()[1]) except Exception: return None return None def _cgroup(pid: int) -> str: return _read(PROC / str(pid) / "cgroup").strip() def scan_processes() -> dict[int, dict]: """Instantaneu al proceselor utilizatorului curent, indexat pe pid.""" me = os.getuid() boot = _boot_epoch() now = time.time() out: dict[int, dict] = {} try: entries = [e for e in os.listdir(PROC) if e.isdigit()] except Exception: return out for entry in entries: pid = int(entry) st = _parse_stat(pid) if st is None: continue # procesul a disparut intre listare si citire uid = _uid(pid) if uid is None or uid != me: continue ppid, starttime = st age = now - (boot + starttime / CLOCK_TICKS) if boot else 0.0 out[pid] = { "pid": pid, "ppid": ppid, "cmdline": _cmdline(pid), "age_s": int(max(age, 0)), "rss_mb": _rss_mb(pid), "start_time": starttime, "cgroup": _cgroup(pid), } return out # --- clasificare ----------------------------------------------------------- def _known_pids(state: dict) -> set[int]: """Pid-urile pe care state.json le declara vii, cu verificare de PID reuse.""" known: set[int] = set() threads = (state or {}).get("threads") or {} if not isinstance(threads, dict): return known for entry in threads.values(): if not isinstance(entry, dict): continue pid = entry.get("pid") if not isinstance(pid, int) or pid <= 0: continue expected = entry.get("pid_start_time") if expected is not None: st = _parse_stat(pid) # Pid reciclat: alt proces poarta acum acelasi numar. Nu-l protejam, # dar nici nu-l omoram automat — intra pe filtrele obisnuite. if st is None or abs(st[1] - float(expected)) > 1.0: continue known.add(pid) return known def _descendants(roots: set[int], procs: dict[int, dict]) -> set[int]: """Toti descendentii pid-urilor date (turul care ruleaza acum e intocmai asta).""" children: dict[int, list[int]] = {} for pid, info in procs.items(): children.setdefault(info["ppid"], []).append(pid) seen: set[int] = set() stack = list(roots) while stack: pid = stack.pop() for child in children.get(pid, ()): if child not in seen: seen.add(child) stack.append(child) return seen def _ancestors_of_self(procs: dict[int, dict]) -> set[int]: out: set[int] = set() pid = os.getpid() while pid and pid not in out: out.add(pid) info = procs.get(pid) if not info: break pid = info["ppid"] return out def _is_claude(cmdline: str) -> bool: for token in cmdline.split(): if token.rsplit("/", 1)[-1] in CLAUDE_BASENAMES: return True return any(m in cmdline for m in CLAUDE_PATH_MARKERS) def _is_protected(cmdline: str) -> bool: low = cmdline.lower() return any(marker.lower() in low for marker in NEVER_KILL) def find_orphans(state: dict, min_age_s: int = 0) -> list[dict]: """Procese ramase in urma, care nu apar in state.json. Doua familii: 1. procese `claude` care nu sunt inregistrate in state.json; 2. copii reparentati la init (ppid == 1) ramasi in cgroup-ul serviciului — serverele si joburile pornite intr-un tur anterior. Intoarce [{"pid", "cmdline", "age_s", "rss_mb", ...}], sortat descrescator dupa RSS (ce doare cel mai tare la OOM apare primul). """ procs = scan_processes() known = _known_pids(state or {}) protected = set(known) | _descendants(known, procs) | _ancestors_of_self(procs) orphans: list[dict] = [] for pid, info in procs.items(): if pid in protected: continue cmdline = info["cmdline"] if not cmdline or _is_protected(cmdline): continue if info["age_s"] < min_age_s: continue if _is_zombie(pid): continue # zombi: nu consuma memorie si nu se poate omori if _is_claude(cmdline): reason = "proces claude neinregistrat in state.json" elif info["ppid"] == 1 and SERVICE_CGROUP in info["cgroup"]: reason = "copil reparentat la init, ramas in cgroup-ul serviciului" else: continue orphans.append({ "pid": pid, "cmdline": cmdline, "age_s": info["age_s"], "rss_mb": info["rss_mb"], "ppid": info["ppid"], "start_time": info["start_time"], "reason": reason, }) orphans.sort(key=lambda o: (-o["rss_mb"], -o["age_s"])) return orphans # --- oprire ---------------------------------------------------------------- def _still_same_process(orphan: dict) -> bool: """Aparare impotriva PID reuse intre `find_orphans` si `kill_orphans`.""" pid = orphan.get("pid") if not isinstance(pid, int) or pid <= 0: return False st = _parse_stat(pid) if st is None or _is_zombie(pid): return False expected = orphan.get("start_time") if expected is None: return True return abs(st[1] - float(expected)) <= 1.0 def kill_orphans(orphans: list[dict], dry_run: bool = True, grace_s: float = GRACE_S) -> list[dict]: """Opreste orfanii. Implicit NU omoara nimic (dry_run=True). SIGTERM, apoi SIGKILL dupa `grace_s` daca procesul inca traieste. Intoarce cate un rezultat per intrare: {"pid", "cmdline", "action", "detail"}. action: "dry-run" | "terminated" | "killed" | "gone" | "skipped" | "error" """ results: list[dict] = [] for orphan in orphans or []: pid = orphan.get("pid") entry = {"pid": pid, "cmdline": orphan.get("cmdline", ""), "action": "", "detail": ""} if not isinstance(pid, int) or not _still_same_process(orphan): entry["action"] = "gone" entry["detail"] = "procesul nu mai exista sau pid-ul a fost reciclat" results.append(entry) continue if pid == os.getpid(): entry["action"] = "skipped" entry["detail"] = "e chiar procesul curent" results.append(entry) continue if dry_run: entry["action"] = "dry-run" entry["detail"] = ( f"s-ar trimite SIGTERM (varsta {orphan.get('age_s')}s, " f"{orphan.get('rss_mb')}MB)" ) results.append(entry) continue try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: entry["action"] = "gone" entry["detail"] = "disparut inainte de SIGTERM" results.append(entry) continue except Exception as exc: entry["action"] = "error" entry["detail"] = f"SIGTERM a esuat: {exc}" results.append(entry) continue deadline = time.time() + grace_s while time.time() < deadline: if not _still_same_process(orphan): break time.sleep(0.1) if not _still_same_process(orphan): entry["action"] = "terminated" entry["detail"] = "oprit cu SIGTERM" results.append(entry) continue try: os.kill(pid, signal.SIGKILL) entry["action"] = "killed" entry["detail"] = f"nu a raspuns la SIGTERM in {grace_s:.0f}s, SIGKILL" except ProcessLookupError: entry["action"] = "terminated" entry["detail"] = "oprit cu SIGTERM" except Exception as exc: entry["action"] = "error" entry["detail"] = f"SIGKILL a esuat: {exc}" results.append(entry) return results # --- randare pentru Discord ------------------------------------------------ def format_report(orphans: list[dict], results: list[dict] | None = None) -> str: """Text scurt pentru raspunsul comenzii `!cleanup` (sub 2000 caractere).""" if not orphans: return "Niciun proces orfan. Nimic de curatat." total_mb = sum(o.get("rss_mb", 0) for o in orphans) lines = [f"**{len(orphans)} procese orfane** (~{total_mb:.0f} MB RSS in total)", "```"] by_pid = {r.get("pid"): r for r in (results or [])} for orphan in orphans[:15]: cmd = str(orphan.get("cmdline", ""))[:70] line = ( f"pid={orphan.get('pid'):<7} {orphan.get('rss_mb'):>7} MB " f"{orphan.get('age_s'):>7}s {cmd}" ) res = by_pid.get(orphan.get("pid")) if res: line += f"\n -> {res.get('action')}: {res.get('detail')}" lines.append(line) if len(orphans) > 15: lines.append(f"... si inca {len(orphans) - 15}") lines.append("```") if not results: lines.append("Rulare seaca. `!cleanup --force` le opreste efectiv.") return "\n".join(lines) def _load_state() -> dict: """Citeste state.json pentru rularea din linia de comanda. Tolerant la lipsa.""" if _config is not None and getattr(_config, "STATE_FILE", None): path = pathlib.Path(_config.STATE_FILE) else: path = pathlib.Path(os.path.expanduser("~/.claude-discord/state.json")) try: import json with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) return data if isinstance(data, dict) else {} except Exception: return {} if __name__ == "__main__": # Uz manual: python3 cleanup.py -> doar raporteaza # python3 cleanup.py --force -> opreste efectiv force = "--force" in sys.argv[1:] found = find_orphans(_load_state()) res = kill_orphans(found, dry_run=not force) print(format_report(found, res))