fix(discord-bridge): /cleanup urmareste descendentii, dar STRICT in cgroup-ul serviciului
Doua defecte, al doilea gasit la verificarea primului.
1. Procesul `claude` al unui fir isi porneste serverele MCP (npm/sh/node pentru
playwright), care nu se numesc `claude` si scapau cautarii. Masurat pe firul viu:
claude 300 MB + MCP 137 MB = ~440 MB per fir, nu 300. find_orphans merge acum pe
arbore, iar kill_orphans opreste copiii inaintea parintilor (altfel se reparenteaza
si scapa).
2. PERICULOS: definitia initiala a orfanului ("orice `claude` absent din state.json")
prindea sesiunile Claude interactive de pe container. Rularea seaca propunea 25 de
procese / ~3864 MB — sesiunile de lucru din tmux, inclusiv cea din care ar fi fost
data comanda. `/cleanup force:True` din Discord si-ar fi omorat propria sesiune.
Apartenenta la cgroup-ul `claude-discord.service` devine conditie necesara pentru
toate familiile, fail-closed la cgroup necitibil. Dupa fix: zero procese raportate.
Test de regresie pe instantaneul real (doua sesiuni in tmux-spawn-*.scope, una in
claude-discord.service): se raporteaza doar a treia. Verificat prin mutant ca testul
musca — cu filtrul scos pica 3 teste.
3. INTERFACES.md cerea pid_start_time in ticks, dar session_store scrie secunde (si
state.json viu contine secunde). Contractul era imprecis, nu codul: un consumator
care compara ticks nu s-ar potrivi niciodata, iar cum acea comparatie protejeaza
turul in desfasurare, esecul ar fi fost tacut si ar fi facut eligibil exact ce
trebuia protejat. Documentat, cu avertismentul explicit.
Bug colateral prins de agent: raportul cu rezultate de omorare ajungea la 2289 caractere,
peste limita Discord — mesajul ar fi fost respins exact la `/cleanup force:True`. Buget
de caractere adaugat.
Suita: 322 passed cu discord.py, 319 passed + 3 skipped fara. Rulare seaca pe container: curat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
@@ -45,7 +45,11 @@ Fisiere partajate ca *citire*: acest INTERFACES.md. Nimeni nu-l editeaza.
|
||||
}
|
||||
```
|
||||
|
||||
`pid_start_time` = `/proc/<pid>/stat` field 22, pentru detectarea PID reuse.
|
||||
`pid_start_time` = `/proc/<pid>/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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<uuid>.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/<pid>/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/<pid>/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-<uuid>.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)
|
||||
|
||||
|
||||
|
||||
@@ -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/<pid>/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) == []
|
||||
|
||||
Reference in New Issue
Block a user