diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index 3221fcf0..5193ef6f 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -901,6 +901,28 @@ estaba bien: **el que no llegaba a llamarla era el bucle**. Una decisión correc igual que una que no existe. Arreglado (sin sesión = nada activo) y fijado con dos tests que corren el PROCESO y el reloj de verdad, el segundo comprobado volviendo a poner el `continue`. +⚠ **La pereza tiene un residuo, y el guardián lo daba por bueno (2026-09-10).** El daemon se va +«tras 300 s **sin nada**», y un torrent sin enjambre nunca termina ⇒ nunca está sin nada: se queda, +que es lo correcto para un cliente de torrent. Pero el guardián cerraba el sandbox y se iba, y +**quedaba vivo el daemon Y el `bwrap` interno**, sosteniendo abiertos overlays ya borrados — +encontrado con `ps` sobre la máquina, no por un test. La corrección tiene dos mitades y ninguna +sobra: + +1. el guión de adentro **despide al daemon** con `puriy-costura-torrent stop` — el propio verbo del + producto, así que de paso se ejerce; +2. el guardián **cuenta los daemons antes y después** (`ps -C`, nunca `pkill -f`, que empareja la + línea de comandos y se lleva la shell del que corre el test) y **falla nombrando el PID** si quedó + alguno; y barre lo propio en el `finally`, para que un test rojo tampoco deje basura. + +Y el orden importa: la huella del socket se anota **adentro y antes** del `stop`, porque al irse el +daemon lo borra — mirarlo desde el hub después mediría el reloj, no el hecho. Comprobado en los dos +sentidos, quitando el `stop` en una copia fuera del repo: sin él el guardián decía ✓ igual y dejaba +un daemon suelto; con la aserción nueva sale +`✗ quedó un daemon vivo tras cerrar el sandbox: PID(s) [20157]`. La primera hipótesis —que sin `stop` +el guardián se COLGARÍA, porque `bwrap --unshare-pid` espera a su namespace— **era falsa y la +medición la descartó**: el `bwrap` externo vuelve, y el que se queda esperando es el interno. El tope +de 180 s quedó igual, pero por lo que es: seguro contra un cuelgue, no el que detecta la fuga. + ⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del daemon, al pie, para que nadie lo lea como «probado de punta a punta». diff --git a/scripts/test-atuq-torrent.py b/scripts/test-atuq-torrent.py index 17a6d3ef..8f188fdb 100755 --- a/scripts/test-atuq-torrent.py +++ b/scripts/test-atuq-torrent.py @@ -32,11 +32,13 @@ import hashlib import http.server import os import shutil +import signal import socket import subprocess import sys import tempfile import threading +import time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs") @@ -44,6 +46,9 @@ ALPINE = os.path.join(ROOT, ".dev-fs/alpine") HAMMER = os.path.join(ROOT, "target/release/takana") STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store")) CLIENTE = "/usr/bin/puriy-costura-torrent" +# Tope del sandbox entero: 60 s los pone `timeout` sobre el navegador, y el resto es +# arranque + despedida del daemon. Si se pasa, no hay veredicto: hay algo colgado. +TOPE_SANDBOX = 180 def fatal(msg): @@ -119,6 +124,19 @@ class Servidor(http.server.SimpleHTTPRequestHandler): self.wfile.write(datos) +def daemons_vivos(): + """PIDs de daemons de torrent vivos AHORA, vistos desde el hub. + + El daemon corre dentro del sandbox, pero `bwrap` no oculta los procesos al host: + `ps -C` los ve con su PID de acá. Es por nombre de proceso a propósito — `pkill -f` + empareja la línea de comandos entera y se lleva puesta la shell que corre el test + (medido, sale 144 y lo que sigue no corre). + """ + r = subprocess.run(["ps", "-o", "pid=", "-C", "puriy-costura-torrent"], + capture_output=True, text=True, check=False) + return {int(l) for l in r.stdout.split()} + + def puerto_libre(): s = socket.socket() s.bind(("127.0.0.1", 0)) @@ -136,13 +154,22 @@ def correr(atuq, costura, daemon, salida, url, negativo): {borrar} timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ > /salida/consola.log 2>&1 + # El socket se anota ACÁ ADENTRO, antes de despedir al daemon: al irse lo borra, + # así que mirarlo después desde el hub no mediría nada. + [ -S /salida/run/puriy-costura-torrent.sock ] && echo si > /salida/socket-visto + # Y hay que despedirlo a mano. Medido (2026-09-10): el daemon se va «tras 300s sin + # nada», pero un torrent sin enjambre NUNCA termina ⇒ nunca está «sin nada»; y + # `bwrap --unshare-pid` no vuelve hasta que su namespace de PIDs queda vacío. + # Sin este `stop` el guardián se cuelga PARA SIEMPRE, y de paso deja el sandbox + # entero vivo (un `bwrap` huérfano pinneando el overlay borrado). + command -v puriy-costura-torrent >/dev/null 2>&1 && puriy-costura-torrent stop >/dev/null 2>&1 exit 0 """ capas = ["--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, "--overlay-src", costura] if daemon: capas += ["--overlay-src", daemon] - subprocess.run([ + proceso = subprocess.Popen([ "bwrap", *capas, "--tmp-overlay", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", "--tmpfs", "/tmp", "--unshare-pid", @@ -156,7 +183,16 @@ def correr(atuq, costura, daemon, salida, url, negativo): "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1", "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1", "sh", "-c", guion, - ], check=False) + ]) + try: + proceso.wait(timeout=TOPE_SANDBOX) + except subprocess.TimeoutExpired: + # No es un fallo del navegador: es que algo quedó vivo dentro del namespace. + proceso.kill() + proceso.wait(timeout=30) + fatal(f"el sandbox no volvió en {TOPE_SANDBOX}s — quedó algo vivo adentro" + " (el sospechoso es el daemon: `bwrap` espera a que el namespace de PIDs" + " se vacíe)") log = open(os.path.join(salida, "consola.log"), errors="replace").read() return [l[len("TORRENT "):] for l in log.splitlines() if l.startswith("TORRENT ")], log @@ -173,6 +209,10 @@ def main(): print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}" f"\n {os.path.basename(daemon)}") + # Censo previo: puede haber un daemon de otra corrida o de otro agente, y ése no + # es nuestro. Lo que se juzga al final es la DIFERENCIA. + ajenos = daemons_vivos() + puerto = puerto_libre() Servidor.puerto = puerto servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor) @@ -240,15 +280,39 @@ def main(): # Y la prueba de la PEREZA: el daemon no estaba corriendo, y su socket # apareció porque el cliente lo levantó. - sock = os.path.join(salida, "run", "puriy-costura-torrent.sock") - if not os.path.exists(sock): - fatal("no hay socket del daemon: no llegó a levantarse") - print(f"\nsocket del daemon: {os.path.relpath(sock, salida)}") + # La huella la dejó el propio sandbox antes de despedir al daemon: el socket + # se lo lleva al salir, así que buscarlo ahora sería medir el reloj, no el hecho. + if not os.path.exists(os.path.join(salida, "socket-visto")): + fatal("no hubo socket del daemon: no llegó a levantarse") + print("\nsocket del daemon: run/puriy-costura-torrent.sock (visto adentro)") print("\n✓ el navegador le entrega el torrent a la suite y el daemon lo toma") print("✓ y NADIE lo arrancó: no hay servicio en la imagen — lo levantó su cliente") + + # Y que se haya IDO. Esto no es prolijidad: el sandbox se cerró, sus overlays + # están borrados, y un daemon vivo los sostiene abiertos indefinidamente — + # su torrent de prueba no tiene enjambre, así que nunca termina y nunca está + # «sin nada» que hacer. Medido: sin el `stop` de adentro el guardián decía ✓ + # igual y dejaba el daemon Y su `bwrap` corriendo. Un residuo que nadie mide + # se ve igual que no haberlo dejado. + for _ in range(15): + quedaron = daemons_vivos() - ajenos + if not quedaron: + break + time.sleep(1) + if quedaron: + fatal(f"quedó un daemon vivo tras cerrar el sandbox: PID(s) {sorted(quedaron)}" + " — se despide con `puriy-costura-torrent stop` DENTRO del sandbox") + print("✓ y se fue cuando se lo pidieron: el sandbox no deja daemon suelto") return 0 finally: servidor.shutdown() + # Barrer SÓLO lo que apareció durante esta corrida, y por PID: un test rojo no + # tiene derecho a dejar procesos, ni a matar los de otro agente. + for pid in sorted(daemons_vivos() - ajenos): + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass shutil.rmtree(tmp, ignore_errors=True)