atuq §6.9: el guardián del torrent dejaba un daemon suelto y decía ✓

Encontrado con `ps` sobre la máquina, no por un test: al cerrar el sandbox quedaban
vivos el daemon y su `bwrap` interno, sosteniendo overlays ya borrados. Es correcto que
el daemon se quede —su torrent de prueba no tiene enjambre, así que nunca termina y
nunca está «sin nada»—, lo que faltaba era despedirlo y MEDIR que se fue.

- el guión de adentro le pide `puriy-costura-torrent stop` (el verbo del producto);
- la huella del socket se anota adentro y ANTES del stop: el daemon lo borra al irse;
- el guardián censa daemons antes/después con `ps -C` (nunca `pkill -f`) y falla
  nombrando el PID; el `finally` barre lo propio para que un test rojo no deje basura;
- tope de 180 s al sandbox, como seguro contra un cuelgue.

Comprobado en los dos sentidos con una copia rota fuera del repo: sin el `stop` el
guardián daba ✓ igual, y con la aserción nueva sale ✗ nombrando el PID. La hipótesis
de que se COLGARÍA era falsa: el `bwrap` externo vuelve; el que espera es el interno.

Verde: positivo, control negativo y rotura a propósito.
This commit is contained in:
Sergio
2026-09-10 21:29:28 +00:00
parent adeda7e499
commit b98c46b530
2 changed files with 92 additions and 6 deletions
+22
View File
@@ -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 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`. 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 **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 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». daemon, al pie, para que nadie lo lea como «probado de punta a punta».
+70 -6
View File
@@ -32,11 +32,13 @@ import hashlib
import http.server import http.server
import os import os
import shutil import shutil
import signal
import socket import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading import threading
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs") 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") HAMMER = os.path.join(ROOT, "target/release/takana")
STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store")) STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
CLIENTE = "/usr/bin/puriy-costura-torrent" 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): def fatal(msg):
@@ -119,6 +124,19 @@ class Servidor(http.server.SimpleHTTPRequestHandler):
self.wfile.write(datos) 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(): def puerto_libre():
s = socket.socket() s = socket.socket()
s.bind(("127.0.0.1", 0)) s.bind(("127.0.0.1", 0))
@@ -136,13 +154,22 @@ def correr(atuq, costura, daemon, salida, url, negativo):
{borrar} {borrar}
timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ timeout 60 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
> /salida/consola.log 2>&1 > /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 exit 0
""" """
capas = ["--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, capas = ["--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq,
"--overlay-src", costura] "--overlay-src", costura]
if daemon: if daemon:
capas += ["--overlay-src", daemon] capas += ["--overlay-src", daemon]
subprocess.run([ proceso = subprocess.Popen([
"bwrap", *capas, "--tmp-overlay", "/", "bwrap", *capas, "--tmp-overlay", "/",
"--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
"--tmpfs", "/tmp", "--unshare-pid", "--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_SOCKET_PROCESS_SANDBOX", "1",
"--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1", "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
"sh", "-c", guion, "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() 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 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)}" print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}"
f"\n {os.path.basename(daemon)}") 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() puerto = puerto_libre()
Servidor.puerto = puerto Servidor.puerto = puerto
servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor) 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 # Y la prueba de la PEREZA: el daemon no estaba corriendo, y su socket
# apareció porque el cliente lo levantó. # apareció porque el cliente lo levantó.
sock = os.path.join(salida, "run", "puriy-costura-torrent.sock") # La huella la dejó el propio sandbox antes de despedir al daemon: el socket
if not os.path.exists(sock): # se lo lleva al salir, así que buscarlo ahora sería medir el reloj, no el hecho.
fatal("no hay socket del daemon: no llegó a levantarse") if not os.path.exists(os.path.join(salida, "socket-visto")):
print(f"\nsocket del daemon: {os.path.relpath(sock, salida)}") 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("\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") 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 return 0
finally: finally:
servidor.shutdown() 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) shutil.rmtree(tmp, ignore_errors=True)