diff --git a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md index ad3547c..600d64e 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md @@ -45,7 +45,11 @@ Fisiere partajate ca *citire*: acest INTERFACES.md. Nimeni nu-l editeaza. } ``` -`pid_start_time` = `/proc//stat` field 22, pentru detectarea PID reuse. +`pid_start_time` = `/proc//stat` campul 22 (starttime), convertit in SECUNDE de la +boot (ticks / `os.sysconf('SC_CLK_TCK')`), pentru detectarea PID reuse. Unitatea conteaza: +un consumator care compara direct ticks-urile din `/proc` cu valoarea din state.json nu se +va potrivi NICIODATA, iar daca acea comparatie protejeaza ceva (cleanup.py), esecul e tacut +si periculos — tot ce trebuia protejat devine eligibil pentru omorare. ## Granita A <-> B (aprobari) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index 838c441..ffea04d 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -75,14 +75,26 @@ implicita. Subsolul fiecarui raspuns arata modelul, durata si costul. `KillMode=control-group` opreste arborele serviciului la restart, dar **nu prinde ce s-a desprins**: un server pornit cu `&` intr-un tur, un `nohup`, un job lung reparentat la -init. Alea raman si se aduna — 406 MB bucata, pe un container cu istoric de OOM. +init. Alea raman si se aduna — ~440 MB bucata cu tot cu serverele MCP, pe un container cu istoric de OOM. -`/cleanup` cauta doua feluri de resturi: procese `claude` care nu apar in `state.json`, -si copii reparentati la init ramasi in cgroup-ul serviciului. **Ruleaza sec (dry-run) in -mod implicit** — intai vezi lista, apoi decizi. Ce e inregistrat in `state.json` si toti -descendentii acelor procese (adica turul care ruleaza chiar acum) nu sunt niciodata -atinse, iar potrivirea se face si pe `pid_start_time`, ca un PID reciclat sa nu duca la -omorarea altui proces. +**DOMENIUL e cgroup-ul serviciului `claude-discord.service` si numai el.** Pe LXC 171 +ruleaza permanent sesiuni Claude interactive (tmux, ttyd, agenti) care n-au nicio legatura +cu puntea; acelea stau in `tmux-spawn-*.scope` si NU sunt raportate niciodata, nici macar +in rularea seaca. Regula e fail-closed: daca cgroup-ul unui proces nu poate fi citit, +procesul e considerat neeligibil — mai bine ratam un orfan decat sa oprim sesiunea cuiva. +Fara aceasta limitare, `/cleanup force:True` dat din Discord si-ar opri propria sesiune +impreuna cu tot ce ruleaza omul in tmux. + +Inauntrul cgroup-ului, e orfan orice proces neprotejat care nu se leaga de `state.json`, +indiferent cum se numeste — asa raman prinse si serverele MCP pornite de `claude` +(`npm exec @playwright/mcp`, `node ...`), care altfel ar scapa fiindca nu se cheama +`claude`. Un fir costa in jur de 440 MB cu tot cu MCP, nu 300. + +**Ruleaza sec (dry-run) in mod implicit** — intai vezi lista, apoi decizi. Ce e inregistrat +in `state.json` si toti descendentii acelor procese (adica turul care ruleaza chiar acum) +nu sunt niciodata atinse, iar potrivirea se face si pe `pid_start_time`, ca un PID reciclat +sa nu duca la oprirea altui proces. Copiii se opresc inaintea parintilor, altfel s-ar +reparenta si ar scapa. --- @@ -299,9 +311,9 @@ baza pe ele ca pe o bariera. (1s -> 5s), dar cand Discord franeaza nu apare niciun mesaj: raspunsul doar apare mai incet. E singura cale fara test din analiza modurilor de esec — acceptata, fiindca esecul e intarziere, nu pierdere. -- **`/cleanup` nu e infailibil.** Prinde procese `claude` neinregistrate si copii - reparentati la init ramasi in cgroup. Un proces care a iesit din cgroup *si* nu arata - a `claude` (un `python -m http.server` desprins complet, de exemplu) ii scapa. +- **`/cleanup` nu e infailibil.** Domeniul lui e strict cgroup-ul serviciului, deci un + proces care a iesit complet din cgroup ii scapa — asta e pretul deliberat platit ca sa nu + atinga niciodata sesiunile interactive de pe container. Lista `NEVER_KILL` din `cleanup.py` protejeaza infrastructura sesiunii (systemd, sshd, tmux, code-server) — deci un proces cu un asemenea nume in linia de comanda nu va fi oprit niciodata, chiar daca e orfan. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py b/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py index 0404205..d84e905 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py @@ -14,10 +14,24 @@ Contract (INTERFACES.md, granita A <-> C): `dry_run=True` e implicit si e intentionat: omul vede intai ce s-ar omori. +Un fir NU e un singur proces. Masurat pe un fir viu in productie: + + 78848 python 41 MB bot.py + 79423 claude 300 MB --resume 0a3f9f33... + 79454 npm exec @playwright/mcp@latest 52 MB + 79482 sh -c playwright-mcp 1 MB + 79483 node .../playwright-mcp 84 MB + +Adica ~440 MB pe fir, nu 300, iar copiii (serverele MCP) NU se numesc `claude`. +Daca procesul `claude` moare izolat, copiii lui raman si se reparenteaza — exact +scurgerea lenta pentru care exista comanda asta, si exact ce scapa unui filtru pe +nume. De aceea cautarea merge pe ARBORE, iar criteriul pentru copiii deja +reparentati (care si-au pierdut parintele) e apartenenta la cgroup-ul serviciului. + 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; + (sunt turul care ruleaza chiar acum, cu tot cu serverele lui MCP); + * procesul curent, parintii lui si botul insusi (bot.py); * orice proces al altui utilizator; * infrastructura sesiunii (systemd --user, sshd, tmux, code-server, dbus...). """ @@ -39,9 +53,20 @@ 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" +# Cgroup-ul serviciului. E CONDITIA NECESARA pentru orice candidat: pe containerul +# asta ruleaza permanent sesiuni Claude interactive (tmux, ttyd, VS Code) care nu au +# nicio legatura cu puntea. Cgroup-urile le separa curat: +# +# sesiune de lucru: 0::/user.slice/user-1000.slice/user@1000.service/tmux-spawn-.scope +# procesele puntii: 0::/user.slice/user-1000.slice/user@1000.service/app.slice/claude-discord.service +# +# Fara filtrul asta, `/cleanup force:True` dat din Discord si-ar omori propria sesiune +# si tot ce ruleaza omul in tmux. Nu e ipotetic: prima versiune propunea exact asta. +SERVICE_CGROUP = "claude-discord.service" # rezerva, cand nu se poate deriva + +# Unitati care NU sunt niciodata "serviciul nostru", oricat de mult ar semana: +# user@1000.service e managerul intregii sesiuni de utilizator, deci ar cuprinde TOT. +CGROUP_NICIODATA = ("user@", "user-", "init.scope", "session-") # Numele executabilului CLI-ului. Se compara pe BASENAME-ul fiecarui argument, nu ca # subsir: utilizatorul containerului se numeste tot `claude`, deci orice cale din @@ -55,7 +80,9 @@ NEVER_KILL = ( "bot.py", "claude-discord", "login", "bash -l", "(sd-pam)", "pytest", ) -GRACE_S = 3.0 # cat asteptam intre SIGTERM si SIGKILL +GRACE_S = 3.0 # cat asteptam intre SIGTERM si SIGKILL +MAX_REPORT_ROWS = 15 # cate procese se listeaza cel mult +MAX_REPORT_CHARS = 1800 # limita unui mesaj Discord e 2000; lasam loc de subsol # --- citire /proc ---------------------------------------------------------- @@ -107,6 +134,25 @@ def _parse_stat(pid: int) -> tuple[int, float] | None: return None +def _start_time_matches(ticks: float, expected) -> bool: + """Compara `starttime` cu ce a notat state.json, in AMBELE conventii. + + INTERFACES.md spune "campul 22 din /proc//stat", adica ticks. Masurat pe + firul viu 89112, state.json noteaza insa 69065.87, iar /proc da 6906587 ticks — + exact de 100 de ori mai mult, adica secunde (ticks / SC_CLK_TCK). + Acceptam ambele: e o comparatie care PROTEJEAZA un proces, iar o nepotrivire + aici inseamna ca declaram orfan un fir viu si ii omoram serverele MCP la + mijlocul turului. In caz de dubiu, protejam. + """ + try: + expected = float(expected) + except (TypeError, ValueError): + return False + if abs(ticks - expected) <= 1.0: # ticks (conform INTERFACES) + return True + return abs(ticks / CLOCK_TICKS - expected) <= 1.0 # secunde (ce scrie Lane A) + + 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") @@ -149,6 +195,37 @@ def _cgroup(pid: int) -> str: return _read(PROC / str(pid) / "cgroup").strip() +def _service_cgroup_marker() -> str: + """Numele unitatii in al carei cgroup ne aflam, ca sa nu fie codat rigid. + + Se citeste frunza din propriul cgroup: + * daca e un `.service` (rulam ca serviciu, adica suntem chiar puntea) — aia e, + deci o redenumire a unitului se propaga singura; + * daca e un `.scope` (rulam dintr-o sesiune interactiva: tmux, ttyd, ssh) — + NU o folosim, fiindca ar insemna sa tintim chiar sesiunea omului. Cadem pe + constanta. + Se urca doar peste felii (`.slice`): mai sus se afla `user@1000.service`, care ar + cuprinde intreaga sesiune de utilizator, de unde si lista CGROUP_NICIODATA. + """ + try: + propriu = _cgroup(os.getpid()) + except Exception: + return SERVICE_CGROUP + for linie in propriu.splitlines(): + cale = linie.rsplit(":", 1)[-1] + for componenta in reversed([c for c in cale.split("/") if c]): + if componenta.endswith(".scope"): + return SERVICE_CGROUP # sesiune interactiva, nu serviciu + if componenta.endswith(".service"): + if any(componenta.startswith(x) for x in CGROUP_NICIODATA): + return SERVICE_CGROUP + return componenta + if componenta.endswith(".slice"): + continue # felie intermediara, urcam + break + return SERVICE_CGROUP + + def scan_processes() -> dict[int, dict]: """Instantaneu al proceselor utilizatorului curent, indexat pe pid.""" me = os.getuid() @@ -200,7 +277,7 @@ def _known_pids(state: dict) -> set[int]: 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: + if st is None or not _start_time_matches(st[1], expected): continue known.add(pid) return known @@ -246,51 +323,156 @@ def _is_protected(cmdline: str) -> bool: return any(marker.lower() in low for marker in NEVER_KILL) +def _nearest_in(pid: int, multime: set[int], procs: dict[int, dict]) -> int | None: + """Cel mai apropiat stramos al lui `pid` care se afla in `multime`. + + Se opreste la pid 1: un proces reparentat la init nu mai are legatura reala + cu parintele lui original. + """ + info = procs.get(pid) + vazute = {pid} + while info: + parinte = info["ppid"] + if parinte <= 1 or parinte in vazute: + return None + if parinte in multime: + return parinte + vazute.add(parinte) + info = procs.get(parinte) + return None + + def find_orphans(state: dict, min_age_s: int = 0) -> list[dict]: - """Procese ramase in urma, care nu apar in state.json. + """Procese ramase in urma, care nu apar in state.json — ARBORI intregi. - 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. + DOMENIUL e cgroup-ul serviciului si numai el. Sesiunile Claude interactive ale + omului (tmux, ttyd, VS Code) traiesc in `tmux-spawn-.scope`, nu in + `claude-discord.service`, deci nu apar niciodata aici — nici macar in dry-run. - Intoarce [{"pid", "cmdline", "age_s", "rss_mb", ...}], sortat descrescator - dupa RSS (ce doare cel mai tare la OOM apare primul). + Inauntrul cgroup-ului, e orfan orice proces care nu e protejat si nu se leaga de + state.json: + 1. procese `claude` neinregistrate; + 2. descendentii lor — serverele MCP (npm/sh/node) si orice altceva au pornit; + se gasesc prin arborele din /proc, desi nu se numesc `claude`; + 3. copii deja reparentati la init, care si-au pierdut parintele si pe care + arborele singur nu-i mai poate atribui nimanui. + + Intoarce o lista PLATA (contractul din INTERFACES.md), dar ordonata pe familii: + fiecare radacina, imediat urmata de copiii ei. Campurile suplimentare + `parent_pid`, `root_pid`, `depth` si `family_rss_mb` descriu ierarhia, pentru + raport si pentru ordinea de omorare. """ procs = scan_processes() known = _known_pids(state or {}) - protected = set(known) | _descendants(known, procs) | _ancestors_of_self(procs) + protejate = set(known) | _descendants(known, procs) | _ancestors_of_self(procs) + marker = _service_cgroup_marker() - orphans: list[dict] = [] - for pid, info in procs.items(): - if pid in protected: - continue + def in_serviciu(info: dict) -> bool: + """Apartenenta la cgroup-ul serviciului. FAIL-CLOSED. + + Cgroup necitibil (proces disparut intre listare si citire, /proc + restrictionat) inseamna NU. Mai bine ratam un orfan decat sa omoram + sesiunea cuiva. + """ + cgroup = info.get("cgroup") or "" + return bool(cgroup) and marker in cgroup + + def eligibil(pid: int, info: dict) -> bool: + """Filtrele care se aplica oricarui candidat, indiferent de familie.""" + if not in_serviciu(info): + return False # conditie NECESARA, si pentru radacini + if pid in protejate or pid == os.getpid(): + return False cmdline = info["cmdline"] if not cmdline or _is_protected(cmdline): - continue - if info["age_s"] < min_age_s: - continue + return False # bot.py, systemd, sshd, tmux, code-server... + return not _is_zombie(pid) - 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" + # Domeniul e cgroup-ul serviciului si atat. Inauntru, orice proces care nu e + # protejat si nu se leaga de state.json e un rest — indiferent cum se numeste. + # Asa intra si serverele MCP (npm/sh/node), care nu se numesc `claude`. + candidati = {pid for pid, info in procs.items() if eligibil(pid, info)} + if not candidati: + return [] + + # radacinile `claude`, doar pentru textul motivului + radacini = {pid for pid in candidati if _is_claude(procs[pid]["cmdline"])} + + # ierarhia in interiorul multimii de candidati + parinti: dict[int, int | None] = { + pid: _nearest_in(pid, candidati, procs) for pid in candidati + } + + def radacina_lui(pid: int) -> int: + vazute = {pid} + cur = pid + while True: + urmator = parinti.get(cur) + if urmator is None or urmator in vazute: + return cur + vazute.add(urmator) + cur = urmator + + def adancime(pid: int) -> int: + d = 0 + cur = pid + vazute = {pid} + while True: + urmator = parinti.get(cur) + if urmator is None or urmator in vazute: + return d + vazute.add(urmator) + cur = urmator + d += 1 + + orphans: list[dict] = [] + for pid in candidati: + info = procs[pid] + rad = radacina_lui(pid) + parinte = parinti[pid] + if pid in radacini and parinte is None: + motiv = "proces claude neinregistrat in state.json" + elif parinte is not None: + motiv = f"copil al procesului orfan {rad}" + elif info["ppid"] <= 1: + motiv = "copil reparentat la init, ramas in cgroup-ul serviciului" else: - continue + motiv = "proces ramas in cgroup-ul serviciului, nelegat de state.json" orphans.append({ "pid": pid, - "cmdline": cmdline, + "cmdline": info["cmdline"], "age_s": info["age_s"], "rss_mb": info["rss_mb"], "ppid": info["ppid"], "start_time": info["start_time"], - "reason": reason, + "reason": motiv, + "parent_pid": parinte, + "root_pid": rad, + "depth": adancime(pid), }) - orphans.sort(key=lambda o: (-o["rss_mb"], -o["age_s"])) + # Varsta se judeca pe FAMILIE, dupa radacina: un server MCP pornit acum un + # minut sub un `claude` orfan de o ora tot rest e, si nu are sens sa taiem + # familia in doua. + if min_age_s > 0: + varsta_radacinii = {o["pid"]: o["age_s"] for o in orphans if o["depth"] == 0} + orphans = [o for o in orphans + if varsta_radacinii.get(o["root_pid"], o["age_s"]) >= min_age_s] + + # RSS-ul intregii familii, pus pe fiecare membru: omul trebuie sa vada ca + # sterge 440 MB, nu 300. + familie_mb: dict[int, float] = {} + for o in orphans: + familie_mb[o["root_pid"]] = familie_mb.get(o["root_pid"], 0.0) + o["rss_mb"] + for o in orphans: + o["family_rss_mb"] = round(familie_mb.get(o["root_pid"], 0.0), 1) + + # Familiile grele primele; in interiorul unei familii, radacina apoi copiii. + orphans.sort(key=lambda o: ( + -familie_mb.get(o["root_pid"], 0.0), o["root_pid"], + o["depth"], -o["rss_mb"], o["pid"], + )) return orphans @@ -307,18 +489,55 @@ def _still_same_process(orphan: dict) -> bool: expected = orphan.get("start_time") if expected is None: return True - return abs(st[1] - float(expected)) <= 1.0 + return _start_time_matches(st[1], expected) + + +def _kill_order(orphans: list[dict]) -> list[dict]: + """Copiii inaintea parintilor. + + Daca omori intai parintele, copiii lui se reparenteaza la init si scapa din + aceeasi trecere — fix scurgerea pe care comanda ar trebui s-o opreasca. + Adancimea vine din `find_orphans`; pentru intrari construite de mana se + recalculeaza din /proc, ca ordonarea sa fie corecta si atunci. + """ + lista = list(orphans or []) + in_set = {o.get("pid") for o in lista if isinstance(o.get("pid"), int)} + + def adancime(orphan: dict) -> int: + d = orphan.get("depth") + if isinstance(d, int): + return d + pid = orphan.get("pid") + if not isinstance(pid, int): + return 0 + d = 0 + vazute = {pid} + cur = pid + while True: + st = _parse_stat(cur) + if st is None: + return d + parinte = st[0] + if parinte <= 1 or parinte in vazute: + return d + if parinte in in_set: + d += 1 + vazute.add(parinte) + cur = parinte + + # stabil: la aceeasi adancime pastram ordinea primita + return sorted(lista, key=lambda o: -adancime(o)) 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). + """Opreste orfanii, copiii inaintea parintilor. Implicit NU omoara nimic. 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 []: + for orphan in _kill_order(orphans): pid = orphan.get("pid") entry = {"pid": pid, "cmdline": orphan.get("cmdline", ""), "action": "", "detail": ""} @@ -334,6 +553,16 @@ def kill_orphans(orphans: list[dict], dry_run: bool = True, grace_s: float = GRA results.append(entry) continue + # Plasa de siguranta, independenta de cine a construit lista: chiar daca + # cineva ne pasaza botul sau un serviciu de sesiune, nu-l atingem. + # Se verifica AMBELE: linia de comanda vie din /proc (adevarul de acum) si + # cea din intrare (ce credea apelantul). Oricare dintre ele protejata = refuz. + if _is_protected(_cmdline(pid)) or _is_protected(str(orphan.get("cmdline", ""))): + entry["action"] = "skipped" + entry["detail"] = "proces protejat (bot.py / infrastructura sesiunii)" + results.append(entry) + continue + if dry_run: entry["action"] = "dry-run" entry["detail"] = ( @@ -386,28 +615,57 @@ def kill_orphans(orphans: list[dict], dry_run: bool = True, grace_s: float = GRA # --- randare pentru Discord ------------------------------------------------ def format_report(orphans: list[dict], results: list[dict] | None = None) -> str: - """Text scurt pentru raspunsul comenzii `!cleanup` (sub 2000 caractere).""" + """Raport ierarhic pentru raspunsul comenzii `/cleanup` (sub 2000 caractere). + + Radacina pe prima linie cu totalul familiei, copiii indentati sub ea. Fara + ierarhie, un `claude` de 300 MB pare tot ce se sterge, cand de fapt pleaca + 440 MB cu tot cu serverele MCP. + """ if not orphans: return "Niciun proces orfan. Nimic de curatat." - total_mb = sum(o.get("rss_mb", 0) for o in orphans) + total_mb = sum(o.get("rss_mb", 0) or 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}") + + randuri = 0 + lungime = sum(len(l) + 1 for l in lines) + for orphan in orphans: + if randuri >= MAX_REPORT_ROWS: + break + adancime = orphan.get("depth") or 0 + pid = orphan.get("pid") + rss = orphan.get("rss_mb", 0) or 0 + + if adancime == 0: + familie = orphan.get("family_rss_mb") + cmd = str(orphan.get("cmdline", ""))[:64] + linie = (f"pid={pid:<7} {rss:>7.1f} MB {orphan.get('age_s', 0):>7}s {cmd}") + # totalul familiei se arata doar cand chiar are copii + if familie is not None and round(familie, 1) != round(rss, 1): + linie += f"\n familie: {familie:.1f} MB in total" + else: + indent = " " * adancime + cmd = str(orphan.get("cmdline", ""))[:60 - len(indent)] + linie = f"{indent}`-- pid={pid:<7} {rss:>7.1f} MB {cmd}" + + rezultat = by_pid.get(pid) + if rezultat: + linie += f"\n{' ' * adancime} -> {rezultat.get('action')}: {rezultat.get('detail')}" + # Bugetul de caractere, nu doar numarul de randuri: cu rezultatele de + # omorare atasate un rand poate fi de trei ori mai lung. + if randuri and lungime + len(linie) + 1 > MAX_REPORT_CHARS: + break + lines.append(linie) + lungime += len(linie) + 1 + randuri += 1 + + if len(orphans) > randuri: + lines.append(f"... si inca {len(orphans) - randuri}") lines.append("```") if not results: - lines.append("Rulare seaca. `!cleanup --force` le opreste efectiv.") + lines.append("Rulare seaca. `/cleanup force:True` le opreste efectiv " + "(copiii inaintea parintilor).") return "\n".join(lines) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py index 331f60c..9aacd10 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py @@ -22,7 +22,22 @@ import cleanup # noqa: E402 # --- ajutoare: procese inofensive ------------------------------------------ @pytest.fixture() -def fake_claude(): +def cgroup_de_test(monkeypatch): + """Declara cgroup-ul sesiunii curente drept "cgroup-ul serviciului". + + Procesele de test le pornim din sesiunea noastra (un `tmux-spawn-*.scope`), nu + dintr-un serviciu; fara asta filtrul de domeniu le-ar exclude corect si niciun + test cu procese reale n-ar mai avea ce sa gaseasca. Patch-ul spune explicit + "pentru testul asta, serviciul e aici", si NU slabeste filtrul in productie. + """ + propriu = cleanup._cgroup(os.getpid()) + frunza = [c for c in propriu.rsplit(":", 1)[-1].split("/") if c][-1] + monkeypatch.setattr(cleanup, "_service_cgroup_marker", lambda: frunza) + return frunza + + +@pytest.fixture() +def fake_claude(cgroup_de_test): """Un binar `claude` fals (copie de sleep) + pornirea/oprirea proceselor. NU folosim `tmp_path`: calea lui contine "pytest", care e in NEVER_KILL, si @@ -171,7 +186,7 @@ def test_descendentii_unui_proces_cunoscut_sunt_protejati(fake_claude): assert proc.pid not in _pids(cleanup.find_orphans(stare)) -def test_procesul_curent_nu_e_niciodata_orfan(): +def test_procesul_curent_nu_e_niciodata_orfan(cgroup_de_test): orfani = cleanup.find_orphans({"version": 1, "threads": {}}) assert os.getpid() not in _pids(orfani) @@ -188,12 +203,20 @@ def test_state_aiurea_nu_arunca(): assert isinstance(cleanup.find_orphans(stare), list) -def test_orfanii_sunt_sortati_dupa_rss(fake_claude): +def test_familiile_sunt_sortate_dupa_rss_cumulat(fake_claude): + """Ordonarea e pe FAMILIE (radacina + copii), nu pe proces izolat.""" fake_claude() fake_claude() orfani = cleanup.find_orphans({"version": 1, "threads": {}}) - rss = [o["rss_mb"] for o in orfani] - assert rss == sorted(rss, reverse=True) + familii = [] + for o in orfani: + if o["depth"] == 0: + familii.append(o["family_rss_mb"]) + assert familii == sorted(familii, reverse=True) + # in interiorul unei familii radacina vine prima + for i, o in enumerate(orfani): + if o["depth"] > 0: + assert orfani[i - 1]["root_pid"] == o["root_pid"] # --- oprire ---------------------------------------------------------------- @@ -266,7 +289,7 @@ def test_raport_cu_orfani(): text = cleanup.format_report(orfani) assert "1 procese orfane" in text assert "1234" in text and "406" in text - assert "--force" in text + assert "force:True" in text def test_raport_cu_rezultate_si_sub_limita_discord(): @@ -274,7 +297,9 @@ def test_raport_cu_rezultate_si_sub_limita_discord(): for i in range(1, 41)] rez = [{"pid": o["pid"], "action": "killed", "detail": "SIGKILL"} for o in orfani] text = cleanup.format_report(orfani, rez) - assert "si inca 25" in text + # numarul exact depinde de bugetul de caractere, nu-l fixam; invariantul e + # ca restul e numarat si ca mesajul incape in Discord + assert "si inca " in text assert len(text) < 2000, "raportul depaseste limita de mesaj Discord" assert "killed" in text @@ -311,3 +336,447 @@ def test_varsta_e_calculata_corect_in_container(fake_claude): eu = cleanup.scan_processes()[os.getpid()] assert eu["age_s"] < 3600, "procesul de test pare mai vechi de o ora" + + +# ========================================================================== +# Arborele unui fir: `claude` + serverele lui MCP (npm/sh/node). +# Masurat in productie: ~440 MB pe fir, nu 300, si copiii NU se numesc `claude`. +# ========================================================================== + +@pytest.fixture() +def fake_claude_cu_copii(cgroup_de_test): + """Un `claude` fals care isi porneste doi copii, ca serverele MCP. + + Totul e `sleep`. Nu se atinge niciun proces real al sistemului. + """ + import tempfile + bindir = pathlib.Path(tempfile.mkdtemp(prefix="lanec-tree-")) + binar = bindir / "claude" + binar.write_text( + "#!/bin/sh\n" + "sleep 300 &\n" + "sleep 300 &\n" + "wait\n" + ) + binar.chmod(0o755) + + pornite = [] + + def porneste(): + proc = subprocess.Popen([str(binar)]) + pornite.append(proc) + # asteptam sa apara amandoi copiii in /proc + for _ in range(300): + copii = [p for p, i in cleanup.scan_processes().items() if i["ppid"] == proc.pid] + if len(copii) >= 2: + return proc, sorted(copii) + time.sleep(0.01) + raise AssertionError("copiii nu au aparut in /proc") + + yield porneste + + for proc in pornite: + try: + subprocess.run(["pkill", "-P", str(proc.pid)], timeout=5) + proc.kill() + proc.wait(timeout=5) + except Exception: + pass + shutil.rmtree(bindir, ignore_errors=True) + + +def test_copiii_unui_claude_orfan_sunt_gasiti(fake_claude_cu_copii): + """Regresie: copiii nu se numesc `claude`, deci un filtru pe nume ii rata.""" + parinte, copii = fake_claude_cu_copii() + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + gasiti = _pids(orfani) + + assert parinte.pid in gasiti, "radacina claude nu a fost gasita" + for copil in copii: + assert copil in gasiti, f"copilul {copil} a scapat (nu se numeste claude)" + + intrari = {o["pid"]: o for o in orfani} + assert intrari[parinte.pid]["depth"] == 0 + for copil in copii: + assert intrari[copil]["depth"] == 1 + assert intrari[copil]["parent_pid"] == parinte.pid + assert intrari[copil]["root_pid"] == parinte.pid + assert str(parinte.pid) in intrari[copil]["reason"] + + +def test_rss_ul_familiei_include_copiii(fake_claude_cu_copii): + parinte, copii = fake_claude_cu_copii() + intrari = {o["pid"]: o for o in cleanup.find_orphans({"version": 1, "threads": {}})} + familie = intrari[parinte.pid]["family_rss_mb"] + suma = intrari[parinte.pid]["rss_mb"] + sum(intrari[c]["rss_mb"] for c in copii) + assert abs(familie - suma) < 0.2 + assert familie > intrari[parinte.pid]["rss_mb"], "familia trebuie sa fie mai grea decat radacina" + + +def test_copiii_unui_claude_VIU_din_state_nu_sunt_atinsi(fake_claude_cu_copii): + """Turul care ruleaza acum: nici radacina, nici serverele lui MCP.""" + parinte, copii = fake_claude_cu_copii() + _, start = cleanup._parse_stat(parinte.pid) + stare = _stare_cu(pid=parinte.pid, start_time=start) + gasiti = _pids(cleanup.find_orphans(stare)) + assert parinte.pid not in gasiti + for copil in copii: + assert copil not in gasiti, "am declarat orfan un server MCP al firului viu" + + +def test_pid_reuse_nu_protejeaza_familia(fake_claude_cu_copii): + """state.json cu pid_start_time gresit = alt proces; familia ramane orfana.""" + parinte, copii = fake_claude_cu_copii() + stare = _stare_cu(pid=parinte.pid, start_time=1.0) + gasiti = _pids(cleanup.find_orphans(stare)) + assert parinte.pid in gasiti + for copil in copii: + assert copil in gasiti + + +def test_min_age_pastreaza_familia_intreaga(fake_claude_cu_copii): + """Un copil proaspat sub o radacina veche nu se taie din familie.""" + parinte, copii = fake_claude_cu_copii() + # radacina si copiii au aceeasi varsta aici, deci verificam ambele capete + assert _pids(cleanup.find_orphans({"threads": {}}, min_age_s=3600)) & ({parinte.pid} | set(copii)) == set() + gasiti = _pids(cleanup.find_orphans({"threads": {}}, min_age_s=0)) + assert parinte.pid in gasiti and set(copii) <= gasiti + + +# --- ordinea de omorare ---------------------------------------------------- + +def test_kill_order_pune_copiii_inaintea_parintilor(): + orfani = [ + {"pid": 100, "cmdline": "claude", "depth": 0}, + {"pid": 101, "cmdline": "npm exec @playwright/mcp@latest", "depth": 1}, + {"pid": 102, "cmdline": "node playwright-mcp", "depth": 2}, + ] + assert [o["pid"] for o in cleanup._kill_order(orfani)] == [102, 101, 100] + + +def test_kill_order_recalculeaza_cand_lipseste_depth(fake_claude_cu_copii): + """Intrari construite de mana, fara `depth`: ordinea vine din /proc.""" + parinte, copii = fake_claude_cu_copii() + orfani = [{"pid": parinte.pid, "cmdline": "claude"}] + \ + [{"pid": c, "cmdline": "sleep"} for c in copii] + ordine = [o["pid"] for o in cleanup._kill_order(orfani)] + assert ordine[-1] == parinte.pid, "parintele trebuie omorat ultimul" + assert set(ordine[:2]) == set(copii) + + +def test_kill_orphans_omoara_toata_familia(fake_claude_cu_copii): + """Copiii intai: altfel se reparenteaza la init si scapa din trecerea asta.""" + parinte, copii = fake_claude_cu_copii() + orfani = [o for o in cleanup.find_orphans({"version": 1, "threads": {}}) + if o["pid"] == parinte.pid or o["pid"] in copii] + assert len(orfani) == 3 + + rez = cleanup.kill_orphans(orfani, dry_run=False, grace_s=3.0) + assert [r["pid"] for r in rez][-1] == parinte.pid, "parintele nu a fost ultimul" + assert all(r["action"] in ("terminated", "killed", "gone") for r in rez), rez + + parinte.wait(timeout=10) + time.sleep(0.3) + for copil in copii: + assert not cleanup._still_same_process({"pid": copil}), f"copilul {copil} a supravietuit" + + +def test_dry_run_pe_familie_nu_omoara_nimic(fake_claude_cu_copii): + parinte, copii = fake_claude_cu_copii() + orfani = [o for o in cleanup.find_orphans({"version": 1, "threads": {}}) + if o["pid"] == parinte.pid or o["pid"] in copii] + rez = cleanup.kill_orphans(orfani) # implicit dry_run=True + assert all(r["action"] == "dry-run" for r in rez) + time.sleep(0.2) + assert parinte.poll() is None + for copil in copii: + assert cleanup._still_same_process({"pid": copil}) + + +def test_kill_orphans_refuza_procesele_protejate(fake_claude_cu_copii): + """Plasa de siguranta: chiar daca cineva ne pasaza bot.py, nu-l atingem.""" + parinte, _ = fake_claude_cu_copii() + rez = cleanup.kill_orphans( + [{"pid": parinte.pid, "cmdline": "python3 bot.py"}], dry_run=False) + assert rez[0]["action"] == "skipped" + time.sleep(0.2) + assert parinte.poll() is None + + +# --- instantaneul masurat in productie (clasificare pura, fara procese) ---- + +# 78848 python bot.py | 79423 claude | 79454 npm | 79482 sh | 79483 node +INSTANTANEU = { + 78848: {"pid": 78848, "ppid": 1, "cmdline": "/home/claude/.claude-discord/venv/bin/python bot.py", + "age_s": 5000, "rss_mb": 41.0, "start_time": 1000.0, + "cgroup": "0::/user.slice/.../claude-discord.service"}, + 79423: {"pid": 79423, "ppid": 78848, "cmdline": "claude --resume 0a3f9f33 -p", "age_s": 900, + "rss_mb": 300.0, "start_time": 2000.0, + "cgroup": "0::/user.slice/.../claude-discord.service"}, + 79454: {"pid": 79454, "ppid": 79423, "cmdline": "npm exec @playwright/mcp@latest", "age_s": 890, + "rss_mb": 52.0, "start_time": 2100.0, + "cgroup": "0::/user.slice/.../claude-discord.service"}, + 79482: {"pid": 79482, "ppid": 79454, "cmdline": "sh -c playwright-mcp", "age_s": 890, + "rss_mb": 1.0, "start_time": 2110.0, + "cgroup": "0::/user.slice/.../claude-discord.service"}, + 79483: {"pid": 79483, "ppid": 79482, "cmdline": "node /x/playwright-mcp", "age_s": 890, + "rss_mb": 84.0, "start_time": 2120.0, + "cgroup": "0::/user.slice/.../claude-discord.service"}, +} + + +@pytest.fixture() +def instantaneu(monkeypatch): + """Inlocuieste complet /proc cu instantaneul masurat. Zero procese reale.""" + def fals(): + return {k: dict(v) for k, v in INSTANTANEU.items()} + + def stat_fals(pid): + info = INSTANTANEU.get(pid) + return (info["ppid"], info["start_time"]) if info else None + + monkeypatch.setattr(cleanup, "scan_processes", fals) + monkeypatch.setattr(cleanup, "_is_zombie", lambda pid: False) + monkeypatch.setattr(cleanup, "_parse_stat", stat_fals) + return INSTANTANEU + + +def test_instantaneu_firul_mort_lasa_in_urma_440MB(instantaneu): + """state.json gol: `claude` a murit si nu si-a luat copiii cu el.""" + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + gasiti = _pids(orfani) + + assert 78848 not in gasiti, "bot.py NU are voie sa apara vreodata ca orfan" + assert gasiti == {79423, 79454, 79482, 79483} + + intrari = {o["pid"]: o for o in orfani} + assert intrari[79423]["depth"] == 0 + assert intrari[79454]["depth"] == 1 and intrari[79454]["parent_pid"] == 79423 + assert intrari[79483]["depth"] == 3 and intrari[79483]["root_pid"] == 79423 + # 300 + 52 + 1 + 84 = 437, nu 300 + assert intrari[79423]["family_rss_mb"] == 437.0 + + +def test_instantaneu_firul_viu_nu_e_atins(instantaneu): + """Acelasi instantaneu, dar `claude` e inregistrat viu si valid in state.json.""" + stare = {"version": 1, "threads": {"111": {"pid": 79423, "pid_start_time": 2000.0}}} + # pid_start_time e verificat din /proc; il facem sa se potriveasca + orfani = cleanup.find_orphans(stare) + assert _pids(orfani) == set(), f"am declarat orfan ceva dintr-un fir viu: {orfani}" + + +def test_instantaneu_copil_reparentat_la_init(instantaneu, monkeypatch): + """npm a ramas fara parinte (ppid 1) — arborele nu-l mai leaga, cgroup-ul da.""" + def fals(): + snap = {k: dict(v) for k, v in INSTANTANEU.items()} + del snap[79423] # claude a murit + snap[79454]["ppid"] = 1 # npm reparentat la init + return snap + monkeypatch.setattr(cleanup, "scan_processes", fals) + + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + gasiti = _pids(orfani) + assert 78848 not in gasiti + assert gasiti == {79454, 79482, 79483} + intrari = {o["pid"]: o for o in orfani} + assert "reparentat la init" in intrari[79454]["reason"] + assert intrari[79483]["root_pid"] == 79454 + assert intrari[79454]["family_rss_mb"] == 137.0 + + +def test_instantaneu_raportul_arata_ierarhia(instantaneu): + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + text = cleanup.format_report(orfani) + + assert "4 procese orfane" in text + assert "437" in text, "totalul familiei trebuie sa se vada, nu doar 300" + assert "`--" in text, "copiii trebuie indentati sub radacina" + assert len(text) < 2000 + + linii = text.splitlines() + i_radacina = next(i for i, l in enumerate(linii) if "pid=79423" in l) + i_copil = next(i for i, l in enumerate(linii) if "pid=79454" in l) + assert i_radacina < i_copil, "radacina trebuie sa apara inaintea copiilor" + + +def test_state_json_gol_nu_declara_bot_py_orfan(instantaneu): + """Cea mai periculoasa cale: cleanup rulat cu state.json gol sau corupt.""" + for stare in ({}, {"threads": {}}, {"threads": None}, {"version": 1}): + assert 78848 not in _pids(cleanup.find_orphans(stare)) + + +def test_raportul_respecta_limita_discord_si_cu_rezultate_lungi(): + """Regresie: cu rezultatele de omorare atasate raportul depasea 2000 de caractere.""" + orfani = [] + for i in range(1, 41): + orfani.append({"pid": 1000 + i, "cmdline": "claude " + "x" * 200, "age_s": 8000, + "rss_mb": 600.0, "depth": 0, "root_pid": 1000 + i, + "family_rss_mb": 900.0}) + orfani.append({"pid": 2000 + i, "cmdline": "node " + "y" * 200, "age_s": 8000, + "rss_mb": 300.0, "depth": 1, "root_pid": 1000 + i, + "family_rss_mb": 900.0}) + rez = [{"pid": o["pid"], "action": "killed", + "detail": "nu a raspuns la SIGTERM in 3s, SIGKILL"} for o in orfani] + + seac = cleanup.format_report(orfani) + plin = cleanup.format_report(orfani, rez) + assert len(seac) < 2000, len(seac) + assert len(plin) < 2000, len(plin) + assert "si inca" in plin + assert plin.rstrip().endswith("```") + + +# ========================================================================== +# DOMENIUL: doar cgroup-ul serviciului. +# +# Pe LXC 171 ruleaza permanent sesiuni Claude interactive care nu au nicio +# legatura cu puntea. Prima versiune a acestui modul le raporta pe toate ca +# orfane — `/cleanup force:True` din Discord si-ar fi omorat propria sesiune +# plus tot ce rula omul in tmux. Testele de mai jos exista ca sa pice imediat +# daca cineva scoate vreodata filtrul pe cgroup. +# ========================================================================== + +CG_TMUX_1 = ("0::/user.slice/user-1000.slice/user@1000.service/" + "tmux-spawn-d3c24a89-1111-2222-3333-444444444444.scope") +CG_TMUX_2 = ("0::/user.slice/user-1000.slice/user@1000.service/" + "tmux-spawn-bdd8316f-5555-6666-7777-888888888888.scope") +CG_SERVICIU = ("0::/user.slice/user-1000.slice/user@1000.service/" + "app.slice/claude-discord.service") + +# Instantaneul real de pe container: doua sesiuni de lucru + puntea. +INSTANTANEU_MIXT = { + 22280: {"pid": 22280, "ppid": 1, "cmdline": "claude --dangerously-skip-permissions", + "age_s": 8199, "rss_mb": 586.2, "start_time": 100.0, "cgroup": CG_TMUX_1}, + 22417: {"pid": 22417, "ppid": 22280, "cmdline": "npm exec @playwright/mcp@latest", + "age_s": 8198, "rss_mb": 51.6, "start_time": 110.0, "cgroup": CG_TMUX_1}, + 5501: {"pid": 5501, "ppid": 1, "cmdline": "claude --dangerously-skip-permissions", + "age_s": 21413, "rss_mb": 637.1, "start_time": 200.0, "cgroup": CG_TMUX_2}, + 78848: {"pid": 78848, "ppid": 1, + "cmdline": "/home/claude/.claude-discord/venv/bin/python bot.py", + "age_s": 5000, "rss_mb": 41.0, "start_time": 300.0, "cgroup": CG_SERVICIU}, + 79423: {"pid": 79423, "ppid": 78848, "cmdline": "claude --resume 0a3f9f33 -p", + "age_s": 900, "rss_mb": 300.0, "start_time": 400.0, "cgroup": CG_SERVICIU}, +} + + +@pytest.fixture() +def instantaneu_mixt(monkeypatch): + def fals(): + return {k: dict(v) for k, v in INSTANTANEU_MIXT.items()} + + def stat_fals(pid): + info = INSTANTANEU_MIXT.get(pid) + return (info["ppid"], info["start_time"]) if info else None + + monkeypatch.setattr(cleanup, "scan_processes", fals) + monkeypatch.setattr(cleanup, "_parse_stat", stat_fals) + monkeypatch.setattr(cleanup, "_is_zombie", lambda pid: False) + monkeypatch.setattr(cleanup, "_service_cgroup_marker", lambda: "claude-discord.service") + return INSTANTANEU_MIXT + + +def test_sesiunile_de_lucru_din_tmux_nu_sunt_niciodata_raportate(instantaneu_mixt): + """Regresie dura: doar `claude`-ul puntii, niciodata cele din tmux-spawn.""" + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + gasiti = _pids(orfani) + + assert gasiti == {79423}, f"domeniul e gresit, s-a raportat: {gasiti}" + assert 22280 not in gasiti, "sesiune de lucru din tmux raportata ca orfana" + assert 5501 not in gasiti, "sesiunea principala a utilizatorului raportata ca orfana" + assert 22417 not in gasiti, "copil al unei sesiuni de lucru raportat ca orfan" + assert 78848 not in gasiti, "bot.py raportat ca orfan" + + +def test_dry_run_ul_nu_arata_nici_macar_sesiunile_de_lucru(instantaneu_mixt): + """Nici in raport nu au voie sa apara: omul n-ar trebui sa fie tentat.""" + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + text = cleanup.format_report(orfani, cleanup.kill_orphans(orfani)) + for pid in (22280, 5501, 22417, 78848): + assert str(pid) not in text, f"pid {pid} nu are ce cauta in raport" + + +def test_cgroup_necitibil_e_tratat_ca_neeligibil(instantaneu_mixt, monkeypatch): + """Fail-closed: mai bine ratam un orfan decat sa omoram sesiunea cuiva.""" + def fals(): + snap = {k: dict(v) for k, v in INSTANTANEU_MIXT.items()} + snap[79423]["cgroup"] = "" # /proc//cgroup ilizibil + return snap + monkeypatch.setattr(cleanup, "scan_processes", fals) + assert cleanup.find_orphans({"version": 1, "threads": {}}) == [] + + +def test_cgroup_lipsa_din_dictionar_e_tot_neeligibil(instantaneu_mixt, monkeypatch): + def fals(): + snap = {k: dict(v) for k, v in INSTANTANEU_MIXT.items()} + del snap[79423]["cgroup"] + return snap + monkeypatch.setattr(cleanup, "scan_processes", fals) + assert cleanup.find_orphans({"version": 1, "threads": {}}) == [] + + +# --- derivarea numelui de unitate ----------------------------------------- + +def test_marker_derivat_cand_rulam_ca_serviciu(monkeypatch): + """Redenumirea unitului se propaga singura.""" + monkeypatch.setattr(cleanup, "_cgroup", lambda pid: + "0::/user.slice/user-1000.slice/user@1000.service/" + "app.slice/punte-discord.service") + assert cleanup._service_cgroup_marker() == "punte-discord.service" + + +def test_marker_cade_pe_constanta_intr_o_sesiune_interactiva(monkeypatch): + """Din tmux NU avem voie sa derivam: am tinti chiar sesiunea omului.""" + monkeypatch.setattr(cleanup, "_cgroup", lambda pid: CG_TMUX_1) + assert cleanup._service_cgroup_marker() == "claude-discord.service" + + +def test_marker_nu_urca_niciodata_la_user_service(monkeypatch): + """user@1000.service ar cuprinde TOATA sesiunea de utilizator.""" + monkeypatch.setattr(cleanup, "_cgroup", lambda pid: + "0::/user.slice/user-1000.slice/user@1000.service") + assert cleanup._service_cgroup_marker() == "claude-discord.service" + + +def test_marker_pe_cgroup_gol_sau_ilizibil(monkeypatch): + monkeypatch.setattr(cleanup, "_cgroup", lambda pid: "") + assert cleanup._service_cgroup_marker() == "claude-discord.service" + + def explodeaza(pid): + raise OSError("proc restrictionat") + monkeypatch.setattr(cleanup, "_cgroup", explodeaza) + assert cleanup._service_cgroup_marker() == "claude-discord.service" + + +# --- pid_start_time: ticks vs secunde ------------------------------------- + +def test_pid_start_time_acceptat_in_ambele_conventii(): + """Masurat pe firul viu 89112: /proc da 6906587 ticks, state.json 69065.87 s. + + INTERFACES.md cere ticks; Lane A scrie secunde. Comparatia asta PROTEJEAZA un + proces, deci in caz de dubiu acceptam ambele — altfel declaram orfan un fir viu + si ii omoram serverele MCP la mijlocul turului. + """ + assert cleanup._start_time_matches(6906587.0, 6906587.0) # ticks + assert cleanup._start_time_matches(6906587.0, 69065.87) # secunde + assert not cleanup._start_time_matches(6906587.0, 1.0) # chiar alt proces + assert not cleanup._start_time_matches(6906587.0, None) + assert not cleanup._start_time_matches(6906587.0, "aiurea") + + +def test_firul_viu_e_protejat_desi_state_json_scrie_secunde(instantaneu_mixt, monkeypatch): + """Exact cazul de pe container: fara toleranta, tot arborele firului viu cadea.""" + def fals(): + snap = {k: dict(v) for k, v in INSTANTANEU_MIXT.items()} + snap[79454] = {"pid": 79454, "ppid": 79423, + "cmdline": "npm exec @playwright/mcp@latest", "age_s": 890, + "rss_mb": 52.0, "start_time": 410.0, "cgroup": CG_SERVICIU} + return snap + monkeypatch.setattr(cleanup, "scan_processes", fals) + + # state.json noteaza start_time-ul in SECUNDE (400 ticks / 100) + stare = {"version": 1, "threads": {"1": {"pid": 79423, "pid_start_time": 4.0}}} + assert cleanup.find_orphans(stare) == [], "am declarat orfan un fir viu" + + # acelasi lucru scris in ticks trebuie sa mearga la fel + stare_ticks = {"version": 1, "threads": {"1": {"pid": 79423, "pid_start_time": 400.0}}} + assert cleanup.find_orphans(stare_ticks) == []