Files
takana/scripts/diagnostico/cazar-cuelgue-headless.sh
T
Sergio 17f8f4fb80 takana: la ruta del repo es /mnt/vvv/takana
El directorio se movio de verdad, asi que las referencias absolutas dentro del
repo ya eran incorrectas. Tambien los ejemplos que usaban ~/hammer.

NO se tocan docs/evidencia/: son REGISTRO de lo que se corrio ese dia, y
reescribir una ruta ahi adentro falsifica la evidencia. Que nombren una ruta que
ya no existe es correcto: existia cuando se midio.
2026-09-09 20:08:02 +00:00

167 lines
12 KiB
Bash
Executable File

#!/bin/bash
# cazar-cuelgue-headless.sh — cazador de un cuelgue INTERMITENTE de firefox en headless.
#
# ══ EL CASO, Y POR QUÉ ESTE SCRIPT SOBREVIVE AUNQUE NO LO RESOLVIÓ ═════════════════════════════
# El 2026-09-08, dos corridas del banco de PGO se colgaron: 120.034 y 120.030 ms, o sea EXACTAMENTE
# el `timeout 120` del arnés. No eran ruido de carga —una corrida normal son ~20 s— sino bloqueos
# hasta que las maté. Dos de 56 (~3,5 %).
#
# NO SE REPRODUJO. 202 corridas en cuatro condiciones, CERO cuelgues:
#
# un binario, un sandbox compartido ............... 30 · 0
# 4 binarios alternando, un sandbox ............... 60 · 0 (descarta churn de caché de página)
# 4 binarios, UN SANDBOX NUEVO POR CORRIDA ........ 56 · 0 (descarta el montaje de namespaces)
# ídem, con LAS PÁGINAS EXACTAS del banco ......... 56 · 0 (descarta la página)
#
# Si la tasa fuera 3,5 %, ver cero en 202 tendría probabilidad ~0,06 %. O sea que la tasa real bajo
# estas condiciones NO es ésa, y lo que faltaba estaba fuera de ellas.
#
# ⚠ UN ERROR DE MÉTODO QUE COSTÓ 146 CORRIDAS, ANOTADO PARA NO REPETIRLO: las tres primeras cazas
# usaron `flex.html` y `tablas.html` porque las tenía a mano, cuando las colgadas habían sido en
# `fuera-del-corpus.html` y `del-corpus.html`. Estuve probando una condición que NO era la
# observada, creyendo que sí. Antes de concluir «no se reproduce», comprobar que el instrumento
# reproduce lo que dice reproducir.
#
# LO QUE SÍ SE APRENDIÓ, que acota al culpable:
# · Es TODO O NADA. En 202 corridas ninguna pasó siquiera de 45 s: o terminan en ~20 s o se
# bloquean hasta el timeout. No es lentitud ocasional, es un bloqueo.
# · Sobrevive la hipótesis que no se puede probar retroactivamente: contención transitoria de otra
# cosa en la máquina (es compartida con otros agentes) durante esas dos ventanas.
#
# ⚠ EVIDENCIA A FAVOR DE ESA HIPÓTESIS, VISTA DESPUÉS (2026-09-08): al ir a montar OTRO banco, la
# carga estaba en 3,34 (contra 0,13 de las cazas) porque otro agente había arrancado un `cargo`,
# y `kswapd0` aparecía consumiendo CPU con CERO memoria libre — o sea recuperación de páginas
# bajo presión. Cada corrida mapea un `libxul` de 227 MB; con la caché apretada, eso es
# exactamente la condición que las cuatro cazas NO tenían y que los dos bancos con cuelgues SÍ.
# No es prueba —no se puede reconstruir el estado de aquellas dos ventanas— pero es la primera
# evidencia que apunta a algo concreto: **cazar bajo presión de memoria, no con la máquina
# ociosa**. Se puede forzar corriendo el cazador mientras algo grande compila.
#
# ╔══════════════════════════════════════════════════════════════════════════════════════════════╗
# ║ RESUELTO 2026-09-08 — la causa NO era la presión de memoria, y el cuelgue era la punta ║
# ║ visible de un defecto que estaba en el 100 % de las corridas sin que nadie lo viera. ║
# ╚══════════════════════════════════════════════════════════════════════════════════════════════╝
#
# LO QUE SE REPRODUJO Y LO QUE SE REFUTÓ (ver cazar-cuelgue-bajo-presion.sh para el arnés):
#
# nivel de presión disponible PSI ok COLGADAS
# control (sin hog) 4402 MiB 0,02 20 0
# 2000 1944 MiB 0,00 18 2 ← los dos cuelgues, con PSI CERO
# 1200 1184 MiB 0,54 20 0
# 700 673 MiB 2,31 20 0
#
# Reproducido por primera vez en 282 corridas (2 de 80 = 2,5 %, compatible con el 3,5 % original),
# PERO SIN DOSIS-RESPUESTA: los cuelgues cayeron en el nivel con MENOS presión medida y los dos
# niveles realmente apretados dieron cero. La presión no es la causa. Y dos hipótesis más, muertas
# con su medición al lado:
# · OOM killer se lleva al hijo → REFUTADA: `oom_kill` de /proc/vmstat no se movió (delta=0) en
# ninguna de las dos colgadas, y los `Web Content` estaban VIVOS.
# · el fork server de Gecko → REFUTADA con A/B del pref `dom.ipc.forkserver.enable`: con el pref
# en false el proceso `forkserver` desaparece del censo (35 muestras → 0, o sea que el pref SÍ
# hizo efecto) y siguen los mismos 2 segfaults.
#
# LA CAUSA, con la cadena entera medida. El log de una corrida COLGADA contra el de una BUENA se
# separan perfectamente (6 buenas: 0 · 2 colgadas: 7 fallos de lanzamiento de pestaña + 6
# `messageManager is null`). Tirando de ahí apareció lo importante: **en las 6 corridas BUENAS
# también revientan 2 procesos hijos con SIGSEGV**, siempre después de escribir el PNG, o sea sin
# que nadie lo note. Muestreando /proc dentro del sandbox, esos dos se llaman `Sandbox Forked`: son
# los ayudantes que el sandbox PROPIO de Firefox forkea para montar su user-namespace. Y no puede:
# ya estamos dentro del user-namespace de bwrap, así que falla con `writing /proc/self/uid_map:
# EPERM` — y el ayudante muere en la salida de error.
#
# Las tres cantidades se mueven JUNTAS, que es la forma de una cadena causal y no de una coincidencia:
#
# configuración segv screenshot uid_map:EPERM 'Sandbox Forked'
# sandbox de Firefox COMPLETO 7 NO 9 725
# security.sandbox.content.level=0 2 sí 2 68
# los 3 prefs .level a 0 1 sí 1 33
# las 5 MOZ_DISABLE_*_SANDBOX 0 sí 0 0
#
# ⚠ Y el sandbox completo CUELGA DETERMINISTA: rc=124 a los 180 s, sin PNG. Comparte familia con el
# cuelgue intermitente (espera infinita, sin screenshot, ayudantes reventados) pero **no está
# probado que sean el mismo**: al determinista le faltan los mensajes `Failed to launch`. Lo que
# sí está probado es que el arnés viejo dejaba 2 ayudantes reventando en CADA corrida y que
# apagando los cinco sandboxes no queda ninguno.
#
# LA VERIFICACIÓN A/B (50 pares intercalados bajo presión, verificar-arreglo-sandbox.sh):
#
# arnés viejo (sólo CONTENT apagado) ...... 98 segfaults · 1 COLGADA · 49/50 screenshots
# arnés nuevo (los cinco apagados) ........ 0 segfaults · 0 colgadas · 50/50 screenshots
#
# ⚠ QUÉ PRUEBA Y QUÉ NO, porque la tentación de leer de más acá es grande:
# · El MECANISMO está eliminado y eso sí es concluyente: 98 → 0, exactamente 2 por corrida, en el
# 100 % de las corridas. Un efecto determinista se refuta con pocas muestras.
# · El CUELGUE no. 1 contra 0 en 50 pares no es significativo por sí solo: con la tasa medida
# (3 colgadas en 130 corridas del arnés viejo ≈ 2,3 %), ver cero en 50 corridas tiene ~31 % de
# probabilidad AUNQUE NADA HUBIERA CAMBIADO. Para un negativo convincente harían falta ~200.
# Lo que sí se puede decir: las TRES colgadas observadas tienen la misma huella exacta —7 fallos
# de lanzamiento de pestaña, 1 de rdd, 6 `messageManager is null`, cero screenshot— y las tres
# cayeron en configuraciones donde los ayudantes revientan. Ninguna apareció sin el mecanismo.
# Evidencia en docs/evidencia/cuelgue-headless/.
#
# EL ARREGLO, para cualquier firefox headless de la distro dentro de bwrap — el sandbox de Gecko no
# aporta nada acá, porque bwrap YA es la jaula, y lo único que hace es fallar y dejar cadáveres:
#
# MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 \
# MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
#
# ⚠ LECCIÓN DE MÉTODO, la segunda del día con el mismo disfraz: el A/B del fork server dio "0 segv"
# en las dos ramas y parecía un resultado. No lo era — el arnés estaba roto (`bwrap: Can't create
# file at /user.js: Read-only file system`, el punto de montaje tiene que EXISTIR en un root de
# sólo lectura) y NINGUNA rama sacaba screenshot. Lo delató la rama de control, que también dio
# cero cuando tenía que dar dos. **Un instrumento muerto y un experimento exitoso se ven idénticos
# desde afuera; sólo los distingue un control que TIENE que dar distinto.**
#
# ══ PARA QUÉ SIRVE ENTONCES ════════════════════════════════════════════════════════════════════
# Para que la PRÓXIMA vez que aparezca, se capture en el acto en vez de empezar de cero: `wchan`,
# `syscall` y `state` de cada proceso (distinguen espera de disco de espera de lock), memoria y
# carga del host en ese instante, y el log del navegador de esa corrida —que en las buenas se tira—.
#
# Uso: N=56 UMBRAL=45 scripts/diagnostico/cazar-cuelgue-headless.sh
# (necesita el rootfs hidratado en store/.rootfs/sway-v2 y las páginas en el scratchpad;
# ajustar las rutas de arriba si se mueven)
set -u
RAIZ=/mnt/vvv/takana; R=$RAIZ/store/.rootfs/sway-v2
S=/tmp/claude-1001/-mnt-vvv-hammer/9d639cb7-92ff-4a26-bd7f-ec9988b972cf/scratchpad
N=${N:-56}; UMBRAL=${UMBRAL:-45}
FF=($(ls -d $RAIZ/store/8116bdec*-firefox $RAIZ/store/1d730334*-firefox \
$RAIZ/store/3d199174*-firefox $RAIZ/store/bb33fd56*-firefox))
PAGS=(fuera-del-corpus.html del-corpus.html) # LAS DEL BANCO: es donde ocurrieron las 2 colgadas
colgadas=0
for i in $(seq 1 $N); do
art=${FF[$(( i % 4 ))]}; pag=${PAGS[$(( i % 2 ))]}
# ⚠ `setsid` + matar el GRUPO, no el bwrap: `kill $BW` mata el bwrap EXTERIOR, que no es el init
# del namespace de PID, y el firefox de dentro SOBREVIVE. Si el arnés corre bajo `flock`, ese
# superviviente hereda el fd del lock y deja a la granja sin compilar hasta que alguien lo note
# (pasó dos veces el 2026-09-08, hora y media la primera). Medido: matando sólo el bwrap queda
# 1 firefox vivo; matando el grupo, 0.
setsid bwrap --ro-bind "$R" / --ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1 \
--ro-bind "$art/usr/lib/firefox" /usr/lib/firefox \
--dev /dev --proc /proc --tmpfs /run --tmpfs /tmp --unshare-pid \
--ro-bind "$S/bench" /bench --bind "$S/caza4" /salida --unshare-user --uid 0 --gid 0 \
--setenv PATH /usr/bin:/bin \
/bin/sh -c 'export HOME=/tmp/h MOZ_HEADLESS=1 MOZ_DISABLE_CONTENT_SANDBOX=1 LIBGL_ALWAYS_SOFTWARE=1 LANG=C.UTF-8; mkdir -p $HOME
LD_LIBRARY_PATH=/usr/lib/firefox /usr/lib/firefox/firefox --headless --screenshot /salida/x.png "file:///bench/'"$pag"'"' \
>/dev/null 2>&1 &
BW=$!
t=0
while kill -0 $BW 2>/dev/null && [ $t -lt $UMBRAL ]; do sleep 1; t=$((t+1)); done
if kill -0 $BW 2>/dev/null; then
colgadas=$((colgadas+1))
D=$S/caza4/colgada-$i; mkdir -p "$D"
{ echo "corrida $i · $(basename $art | cut -c1-12) · $pag · colgada a los ${t}s"
echo "--- memoria del host ---"; head -4 /proc/meminfo
echo "--- carga ---"; cat /proc/loadavg; } > "$D/resumen.txt"
ps -eo pid,ppid,stat,rss,wchan:24,comm 2>/dev/null | grep -iE 'bwrap|firefox|Web Con|Isolated|PID' > "$D/ps.txt"
for p in $(pgrep -P $BW 2>/dev/null; pgrep -f 'usr/lib/firefox/firefox' 2>/dev/null | head -8); do
{ echo "--- pid $p comm=$(cat /proc/$p/comm 2>/dev/null) state=$(awk '/^State:/{print $2,$3}' /proc/$p/status 2>/dev/null)"
echo " wchan=$(cat /proc/$p/wchan 2>/dev/null)"
echo " syscall=$(cut -c1-70 /proc/$p/syscall 2>/dev/null)"
echo " rss=$(awk '/^VmRSS:/{print $2,$3}' /proc/$p/status 2>/dev/null)"; } >> "$D/procesos.txt" 2>/dev/null
done
kill -TERM -$BW 2>/dev/null; sleep 3; kill -KILL -$BW 2>/dev/null # el GRUPO
printf 'X'
else printf '.'; fi
done
echo ""; echo "== colgadas: $colgadas de $N"