Files
Claude Agent c8a5d418f3 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
2026-08-30 12:47:16 +00:00

783 lines
32 KiB
Python

"""Teste pentru cleanup.py (T13).
Testele ating /proc-ul real, dar NUMAI cu procese pe care le pornesc ele insele:
copii ai lui `sleep` redenumiti `claude`. Nu se atinge niciodata un proces al
sistemului si nu se apeleaza `kill_orphans(dry_run=False)` decat pe acesti copii.
"""
import os
import pathlib
import shutil
import subprocess
import sys
import time
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import cleanup # noqa: E402
# --- ajutoare: procese inofensive ------------------------------------------
@pytest.fixture()
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
procesul de test ar iesi protejat din greseala.
"""
import tempfile
bindir = pathlib.Path(tempfile.mkdtemp(prefix="lanec-bin-"))
binar = bindir / "claude"
shutil.copy(shutil.which("sleep") or "/bin/sleep", binar)
binar.chmod(0o755)
pornite = []
def porneste(secunde="60"):
proc = subprocess.Popen([str(binar), secunde])
pornite.append(proc)
# asteptam sa apara in /proc cu cmdline complet
for _ in range(50):
if cleanup._cmdline(proc.pid):
break
time.sleep(0.01)
return proc
yield porneste
for proc in pornite:
try:
proc.kill()
proc.wait(timeout=5)
except Exception:
pass
shutil.rmtree(bindir, ignore_errors=True)
def _stare_cu(pid=None, start_time=None):
thread = {"sid": "x", "cwd": "/workspace", "model": "sonnet"}
if pid is not None:
thread["pid"] = pid
if start_time is not None:
thread["pid_start_time"] = start_time
return {"version": 1, "threads": {"111": thread}, "cost": {"day": "2026-08-30", "usd": 0.0}}
def _pids(orphans):
return {o["pid"] for o in orphans}
# --- citirea /proc ---------------------------------------------------------
def test_parse_stat_pe_procesul_curent():
ppid, starttime = cleanup._parse_stat(os.getpid())
assert ppid == os.getppid()
assert starttime > 0
def test_parse_stat_pid_inexistent():
assert cleanup._parse_stat(4_000_000) is None
def test_parse_stat_suporta_comm_cu_spatii(tmp_path, monkeypatch):
"""comm-ul e intre paranteze si poate contine spatii/paranteze."""
fals = tmp_path / "777"
fals.mkdir()
# campurile 3..24: state, ppid, apoi umplutura pana la campul 22 = starttime
campuri = ["S", "42"] + ["0"] * 17 + ["987654"] + ["0", "0"]
(fals / "stat").write_text("777 (nume ciudat (x)) " + " ".join(campuri) + "\n")
monkeypatch.setattr(cleanup, "PROC", tmp_path)
assert cleanup._parse_stat(777) == (42, 987654.0)
def test_rss_si_cmdline_pentru_procesul_curent():
assert cleanup._rss_mb(os.getpid()) > 0
assert "python" in cleanup._cmdline(os.getpid()).lower()
def test_scan_processes_contine_procesul_curent():
procs = cleanup.scan_processes()
assert os.getpid() in procs
info = procs[os.getpid()]
assert set(info) >= {"pid", "ppid", "cmdline", "age_s", "rss_mb", "start_time", "cgroup"}
def test_scan_processes_doar_uid_ul_curent():
procs = cleanup.scan_processes()
# pid 1 apartine altui utilizator in acest container
assert all(p > 0 for p in procs)
assert cleanup._uid(os.getpid()) == os.getuid()
# --- clasificare -----------------------------------------------------------
def test_is_claude_recunoaste_variantele():
assert cleanup._is_claude("claude -p --resume abc")
assert cleanup._is_claude("/home/claude/.nvm/versions/node/v20.19.6/bin/claude -p")
assert cleanup._is_claude("node /home/x/node_modules/@anthropic-ai/claude-code/bin/claude")
def test_is_claude_nu_confunda_home_ul_utilizatorului():
# utilizatorul se numeste `claude`, deci caile lui contin cuvantul
assert not cleanup._is_claude("/usr/bin/python3 /home/claude/script.py")
assert not cleanup._is_claude("sleep 300")
def test_procese_protejate_nu_sunt_orfane():
assert cleanup._is_protected("/usr/lib/systemd/systemd --user")
assert cleanup._is_protected("sshd: claude@pts/0")
assert cleanup._is_protected("python3 bot.py")
def test_gaseste_proces_claude_neinregistrat(fake_claude):
proc = fake_claude()
orfani = cleanup.find_orphans({"version": 1, "threads": {}})
assert proc.pid in _pids(orfani)
entry = next(o for o in orfani if o["pid"] == proc.pid)
assert set(entry) >= {"pid", "cmdline", "age_s", "rss_mb"}
assert entry["age_s"] >= 0
assert "neinregistrat" in entry["reason"]
def test_procesul_din_state_json_nu_e_orfan(fake_claude):
proc = fake_claude()
stare = _stare_cu(pid=proc.pid)
assert proc.pid not in _pids(cleanup.find_orphans(stare))
def test_pid_start_time_care_nu_se_potriveste_nu_protejeaza(fake_claude):
"""PID reuse: state.json crede ca stie pid-ul, dar e alt proces acum."""
proc = fake_claude()
stare = _stare_cu(pid=proc.pid, start_time=1.0) # start_time vechi, gresit
assert proc.pid in _pids(cleanup.find_orphans(stare))
def test_pid_start_time_corect_protejeaza(fake_claude):
proc = fake_claude()
_, start = cleanup._parse_stat(proc.pid)
stare = _stare_cu(pid=proc.pid, start_time=start)
assert proc.pid not in _pids(cleanup.find_orphans(stare))
def test_descendentii_unui_proces_cunoscut_sunt_protejati(fake_claude):
"""Copiii turului care ruleaza acum nu au voie sa fie declarati orfani."""
proc = fake_claude()
# declaram procesul curent (pytest) ca fiind procesul firului; copilul lui
# `proc` e descendentul lui, deci protejat
stare = _stare_cu(pid=os.getpid())
assert proc.pid not in _pids(cleanup.find_orphans(stare))
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)
def test_min_age_filtreaza_procesele_proaspete(fake_claude):
proc = fake_claude()
orfani = cleanup.find_orphans({"version": 1, "threads": {}}, min_age_s=3600)
assert proc.pid not in _pids(orfani)
def test_state_aiurea_nu_arunca():
for stare in ({}, {"threads": None}, {"threads": {"a": None}},
{"threads": {"a": {"pid": "nu-i numar"}}}, {"threads": {"a": {"pid": -5}}}):
assert isinstance(cleanup.find_orphans(stare), list)
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": {}})
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 ----------------------------------------------------------------
def test_dry_run_e_implicit_si_nu_omoara_nimic(fake_claude):
proc = fake_claude()
orfani = [o for o in cleanup.find_orphans({"version": 1, "threads": {}}) if o["pid"] == proc.pid]
rez = cleanup.kill_orphans(orfani) # fara dry_run explicit
assert rez[0]["action"] == "dry-run"
time.sleep(0.2)
assert proc.poll() is None, "procesul a fost omorat desi era rulare seaca"
def test_kill_orphans_opreste_efectiv_cu_force(fake_claude):
proc = fake_claude()
orfani = [o for o in cleanup.find_orphans({"version": 1, "threads": {}}) if o["pid"] == proc.pid]
assert orfani, "procesul de test nu a fost gasit ca orfan"
rez = cleanup.kill_orphans(orfani, dry_run=False, grace_s=3.0)
assert rez[0]["action"] in ("terminated", "killed")
assert proc.wait(timeout=5) is not None
def test_proces_deja_disparut_e_raportat_gone(fake_claude):
proc = fake_claude()
_, start = cleanup._parse_stat(proc.pid)
orfan = {"pid": proc.pid, "cmdline": "claude", "age_s": 1, "rss_mb": 1, "start_time": start}
proc.kill()
proc.wait(timeout=5)
rez = cleanup.kill_orphans([orfan], dry_run=False)
assert rez[0]["action"] == "gone"
def test_pid_reuse_impiedica_omorarea_gresita(fake_claude):
"""start_time nepotrivit => refuzam sa omoram, chiar cu dry_run=False."""
proc = fake_claude()
orfan = {"pid": proc.pid, "cmdline": "claude", "age_s": 1, "rss_mb": 1,
"start_time": 1.0} # alt proces, evident
rez = cleanup.kill_orphans([orfan], dry_run=False)
assert rez[0]["action"] == "gone"
time.sleep(0.2)
assert proc.poll() is None, "am omorat un proces cu start_time nepotrivit"
def test_kill_orphans_refuza_procesul_curent():
_, start = cleanup._parse_stat(os.getpid())
orfan = {"pid": os.getpid(), "cmdline": "pytest", "age_s": 1, "rss_mb": 1,
"start_time": start}
rez = cleanup.kill_orphans([orfan], dry_run=False)
assert rez[0]["action"] == "skipped"
def test_kill_orphans_pe_lista_goala():
assert cleanup.kill_orphans([]) == []
assert cleanup.kill_orphans(None) == []
def test_kill_orphans_intrare_aiurea_nu_arunca():
rez = cleanup.kill_orphans([{"cmdline": "fara pid"}, {"pid": None}], dry_run=False)
assert all(r["action"] == "gone" for r in rez)
# --- raport ----------------------------------------------------------------
def test_raport_gol():
assert "Niciun proces orfan" in cleanup.format_report([])
def test_raport_cu_orfani():
orfani = [{"pid": 1234, "cmdline": "claude -p --resume abc", "age_s": 900, "rss_mb": 406.0}]
text = cleanup.format_report(orfani)
assert "1 procese orfane" in text
assert "1234" in text and "406" in text
assert "force:True" in text
def test_raport_cu_rezultate_si_sub_limita_discord():
orfani = [{"pid": i, "cmdline": "claude -p " + "x" * 200, "age_s": i, "rss_mb": 406.0}
for i in range(1, 41)]
rez = [{"pid": o["pid"], "action": "killed", "detail": "SIGKILL"} for o in orfani]
text = cleanup.format_report(orfani, rez)
# 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
def test_zombie_nu_e_orfan_si_nu_se_omoara(fake_claude):
"""Un copil terminat dar nereaped are inca /proc/<pid>, dar e mort."""
proc = fake_claude()
proc.terminate()
for _ in range(200):
if cleanup._is_zombie(proc.pid):
break
time.sleep(0.01)
assert cleanup._is_zombie(proc.pid), "nu am reusit sa produc un zombi"
assert proc.pid not in _pids(cleanup.find_orphans({"version": 1, "threads": {}}))
_, start = cleanup._parse_stat(proc.pid)
rez = cleanup.kill_orphans(
[{"pid": proc.pid, "cmdline": "claude", "age_s": 1, "rss_mb": 0, "start_time": start}],
dry_run=False,
)
assert rez[0]["action"] == "gone"
def test_varsta_e_calculata_corect_in_container(fake_claude):
"""Regresie: /proc/uptime e virtualizat de lxcfs, `starttime` nu.
Cu scaderea naiva, un proces pornit acum iesea cu varsta negativa (deci 0)
si filtrul `min_age_s` devenea inutil. Referinta corecta e `btime`.
"""
proc = fake_claude()
info = cleanup.scan_processes()[proc.pid]
assert 0 <= info["age_s"] < 60, f"varsta absurda: {info['age_s']}s"
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) == []