diff --git a/scripts/diagnostico/cazar-cuelgue-bajo-presion.sh b/scripts/diagnostico/cazar-cuelgue-bajo-presion.sh new file mode 100755 index 00000000..1bd5831d --- /dev/null +++ b/scripts/diagnostico/cazar-cuelgue-bajo-presion.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# cazar-cuelgue-bajo-presion.sh — segunda caza del cuelgue de firefox headless, ahora BAJO PRESIÓN +# DE MEMORIA, que es la única condición que las cuatro cazas anteriores (202 corridas, 0 cuelgues) +# no tenían y que los dos bancos con cuelgues sí. +# +# ══ LA HIPÓTESIS, Y POR QUÉ ES FALSABLE ════════════════════════════════════════════════════════ +# El cuelgue es TODO O NADA: en 202 corridas ninguna pasó de 45 s, o terminan en ~20 s o se bloquean +# hasta el timeout. Eso no parece lentitud por paginación —que daría una cola de tiempos largos— +# sino un BLOQUEO. La forma más simple de que firefox se bloquee sólo bajo presión es que +# **el proceso padre espere a un hijo que el OOM killer se llevó**: `--screenshot` espera a que el +# proceso de contenido dibuje, y si el kernel lo mata, la respuesta no llega nunca. +# +# Eso es falsable con un contador que el kernel ya lleva: `oom_kill` en /proc/vmstat. Se muestrea +# antes y después de CADA corrida. Si los cuelgues coinciden con incrementos, es prueba mecánica, +# no correlación; si hay cuelgues SIN incremento, la hipótesis se cae y queda acotada por otro lado. +# +# ══ QUÉ SE MIDE, Y POR QUÉ ESTAS COSAS ═════════════════════════════════════════════════════════ +# · MemAvailable y PSI (/proc/pressure/memory) al lanzar ⇒ la presión queda como NÚMERO. Sin esto +# «corrí bajo presión» es una afirmación sobre mi intención, no sobre el experimento. +# · delta de oom_kill ⇒ el testigo mecánico. +# · código de salida ⇒ distingue COLGADA (bloqueada hasta el umbral) de MUERTA (el kernel se la +# llevó a los 3 s). Son fenómenos distintos y confundirlos arruinaría el recuento. +# · nivel 0 SIN hog ⇒ el control. Sin él, un cuelgue no se puede atribuir a la presión. +# +# El generador de presión (presion-memoria.py) se marca oom_score_adj=1000: si el kernel tiene que +# matar a alguien es a él, nunca a un build de la granja. Y toda la caza corre con el flock de la +# granja tomado, para que no arranque una compilación de 4 h mientras se estrangula la RAM. +# +# Uso: N=20 scripts/diagnostico/cazar-cuelgue-bajo-presion.sh +set -u +RAIZ=/mnt/vvv/hammer; R=$RAIZ/store/.rootfs/sway-v2 +AQUI=$(cd "$(dirname "$0")" && pwd) +N=${N:-20}; UMBRAL=${UMBRAL:-60} +SAL=$(mktemp -d); trap 'rm -rf "$SAL"' EXIT +FF=($(ls -d $RAIZ/store/8116bdec*-firefox $RAIZ/store/3d199174*-firefox)) +PAGS=(fuera-del-corpus.html del-corpus.html) # LAS DEL BANCO: donde ocurrieron las 2 colgadas + +disp(){ awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo; } +psi(){ awk -F'avg10=' '/^some/{print substr($2,1,5)}' /proc/pressure/memory; } +ooms(){ awk '/^oom_kill /{print $2}' /proc/vmstat; } + +HOG="" +para_hog(){ [ -n "$HOG" ] && kill "$HOG" 2>/dev/null; HOG=""; sleep 3; } +trap 'para_hog; rm -rf "$SAL"' EXIT + +for NIVEL in 0 2000 1200 700; do + if [ "$NIVEL" = 0 ]; then + echo "── nivel CONTROL: sin presión" + else + echo "── nivel: apuntando a $NIVEL MiB disponibles" + python3 "$AQUI/presion-memoria.py" "$NIVEL" 1800 >"$SAL/hog.log" 2>&1 & HOG=$! + for _ in $(seq 1 40); do sleep 2; [ "$(disp)" -le $((NIVEL+300)) ] && break; done + fi + echo " arranca con disponible=$(disp) MiB psi=$(psi)" + colg=0; muer=0; ok=0 + for i in $(seq 1 $N); do + d0=$(disp); p0=$(psi); o0=$(ooms) + bwrap --ro-bind "$R" / --ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1 \ + --ro-bind "${FF[$(( i % 2 ))]}/usr/lib/firefox" /usr/lib/firefox \ + --dev /dev --proc /proc --tmpfs /run --tmpfs /tmp --unshare-pid \ + --ro-bind "$AQUI" /bench --bind "$SAL" /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/'"${PAGS[$(( i % 2 ))]}"'"' \ + >"$SAL/nav-$i.log" 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 + colg=$((colg+1)); D=$RAIZ/work/cuelgue-$NIVEL-$i; mkdir -p "$D" + { echo "nivel=$NIVEL corrida=$i COLGADA a ${t}s" + echo "al lanzar: disponible=$d0 MiB psi_some_avg10=$p0 oom_kill=$o0" + echo "ahora: disponible=$(disp) MiB psi_some_avg10=$(psi) oom_kill=$(ooms)" + echo "--- ¿mató el kernel a alguien DURANTE esta corrida? delta=$(( $(ooms) - o0 ))"; } > "$D/resumen.txt" + ps -eo pid,ppid,stat,rss,wchan:24,comm | grep -iE 'bwrap|firefox|Web Con|Isolated|PID' > "$D/ps.txt" + for p in $(pgrep -f 'usr/lib/firefox/firefox' | head -10); do + { echo "--- pid $p comm=$(cat /proc/$p/comm 2>/dev/null)" + echo " 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 + cp "$SAL/nav-$i.log" "$D/navegador.log" 2>/dev/null + kill -TERM $BW 2>/dev/null; sleep 3; kill -KILL $BW 2>/dev/null; printf 'X' + else + wait $BW; rc=$? + od=$(( $(ooms) - o0 )) + if [ $rc -ne 0 ]; then muer=$((muer+1)); printf 'M' + echo "nivel=$NIVEL i=$i MUERTA rc=$rc t=${t}s disp=$d0 psi=$p0 oom_delta=$od" >> "$RAIZ/work/caza-presion.txt" + else ok=$((ok+1)); printf '.'; fi + [ $od -gt 0 ] && printf '(oom+%d)' $od + fi + done + echo ""; echo " nivel $NIVEL: ok=$ok COLGADAS=$colg muertas=$muer (disponible final $(disp) MiB, psi $(psi))" + para_hog +done diff --git a/scripts/diagnostico/cazar-cuelgue-headless.sh b/scripts/diagnostico/cazar-cuelgue-headless.sh index 2e7af34b..e4b7e73c 100755 --- a/scripts/diagnostico/cazar-cuelgue-headless.sh +++ b/scripts/diagnostico/cazar-cuelgue-headless.sh @@ -37,6 +37,65 @@ # 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. +# +# 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 diff --git a/scripts/diagnostico/presion-memoria.py b/scripts/diagnostico/presion-memoria.py new file mode 100755 index 00000000..6bb3fb3c --- /dev/null +++ b/scripts/diagnostico/presion-memoria.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""presion-memoria.py — generador de presión de memoria ACOTADO y con seguro. + +No apunta a un tamaño de hog sino a un SETPOINT de MemAvailable: crece hasta dejar la máquina con +los MiB pedidos disponibles y ahí se queda, corrigiendo si el resto del sistema se mueve. La +variable controlada es la que importa (memoria disponible), no la que es fácil de fijar. + +TRES SEGUROS, porque esta máquina es compartida con la granja y con otros agentes: + 1. oom_score_adj = 1000 ⇒ si el kernel tiene que matar a alguien, el PRIMERO soy yo, nunca un + build de 4 h ni el trabajo de otro agente. Subirlo no necesita privilegios; bajarlo sí. + 2. suelo duro: no crece por debajo de PISO_MIB de disponible, pase lo que pase. + 3. reloj: se muere solo a los SEGUNDOS, aunque quien lo lanzó desaparezca. + +⚠ El relleno es ALEATORIO A PROPÓSITO. El swap de esta máquina es zram (comprimido EN RAM): un hog +de ceros se comprime a nada y no genera ni un byte de presión real — mediría cero creyendo medir +mucho. Un bloque aleatorio de 4 KiB repetido es incompresible página a página. +""" +import os, sys, time, signal + +SETPOINT = int(sys.argv[1]) if len(sys.argv) > 1 else 800 # MiB disponibles objetivo +SEGUNDOS = int(sys.argv[2]) if len(sys.argv) > 2 else 600 +PISO_MIB = 350 +TROZO = 64 * 1024 * 1024 + +def disponible_mib(): + with open('/proc/meminfo') as f: + for l in f: + if l.startswith('MemAvailable:'): + return int(l.split()[1]) // 1024 + return 0 + +try: + with open('/proc/self/oom_score_adj', 'w') as f: + f.write('1000') # seguro 1: víctima designada +except OSError as e: + print(f"presion: NO pude marcarme como víctima del OOM ({e}) — abortando por seguridad") + sys.exit(1) + +signal.alarm(SEGUNDOS) # seguro 3: reloj de pared +bloque = os.urandom(4096) * (TROZO // 4096) # incompresible para zram +lastre = [] +print(f"presion: objetivo {SETPOINT} MiB disponibles, piso {PISO_MIB}, {SEGUNDOS}s, " + f"disponible ahora {disponible_mib()}", flush=True) +try: + while True: + d = disponible_mib() + if d > SETPOINT and d > PISO_MIB: # seguro 2: suelo duro + lastre.append(bytearray(bloque)) # bytearray: memoria propia, tocada de verdad + elif d < SETPOINT - 128 and lastre: + lastre.pop() # el sistema pidió; le devuelvo + else: + time.sleep(0.25) + if len(lastre) % 8 == 0: + print(f"presion: lastre={len(lastre)*64} MiB disponible={d}", flush=True) +except (MemoryError, KeyboardInterrupt): + pass diff --git a/scripts/diagnostico/verificar-arreglo-sandbox.sh b/scripts/diagnostico/verificar-arreglo-sandbox.sh new file mode 100755 index 00000000..7e70d5c4 --- /dev/null +++ b/scripts/diagnostico/verificar-arreglo-sandbox.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# verificar-arreglo-sandbox.sh — A/B del arreglo del cuelgue: arnés VIEJO contra arnés ARREGLADO, +# intercalados, bajo la presión de memoria en la que aparecieron los cuelgues. +# +# VIEJO = MOZ_DISABLE_CONTENT_SANDBOX=1 solamente ⇒ 2 segfaults de 'Sandbox Forked' por corrida +# ARREGLADO = las 5 MOZ_DISABLE_*_SANDBOX ⇒ 0 segfaults, 0 EPERM, 0 helpers +# +# El marcador que se mide NO es el cuelgue (2,5 %: haría falta muchísima corrida) sino el SEGFAULT, +# que está en el 100 % de las corridas del arnés viejo. Un mecanismo presente siempre se puede +# refutar con pocas muestras; un síntoma raro, no. El recuento de cuelgues va igual, como control. +set -u +RAIZ=/mnt/vvv/hammer; R=$RAIZ/store/.rootfs/sway-v2 +AQUI=$(cd "$(dirname "$0")" && pwd); N=${N:-50}; UMBRAL=${UMBRAL:-60} +A=$(ls -d $RAIZ/store/3d199174*-firefox | head -1) +VIEJO='MOZ_DISABLE_CONTENT_SANDBOX=1' +NUEVO='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' +declare -A SEGV COLG SHOT +for k in viejo nuevo; do SEGV[$k]=0; COLG[$k]=0; SHOT[$k]=0; done + +python3 "$AQUI/presion-memoria.py" 2000 3600 >/dev/null 2>&1 & HOG=$! +trap 'kill $HOG 2>/dev/null' EXIT +for _ in $(seq 1 40); do sleep 2; [ "$(awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo)" -le 2300 ] && break; done +echo "presión lista: disponible=$(awk '/MemAvailable/{print int($2/1024)}' /proc/meminfo) MiB" + +for i in $(seq 1 $N); do + for k in viejo nuevo; do + [ $k = viejo ] && ENV="$VIEJO" || ENV="$NUEVO" + SAL=$(mktemp -d) + bwrap --ro-bind "$R" / --ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1 \ + --ro-bind "$A/usr/lib/firefox" /usr/lib/firefox --dev /dev --proc /proc --tmpfs /run --tmpfs /tmp \ + --unshare-pid --ro-bind "$AQUI" /bench --bind "$SAL" /salida --unshare-user --uid 0 --gid 0 \ + --setenv PATH /usr/bin:/bin --setenv APAGA "$ENV" \ + /bin/sh -c 'export HOME=/tmp/h MOZ_HEADLESS=1 LIBGL_ALWAYS_SOFTWARE=1 LANG=C.UTF-8; mkdir -p $HOME; export $APAGA + LD_LIBRARY_PATH=/usr/lib/firefox /usr/lib/firefox/firefox --headless --screenshot /salida/x.png file:///bench/fuera-del-corpus.html' \ + >"$SAL/nav.log" 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 + COLG[$k]=$(( ${COLG[$k]} + 1 )); cp -r "$SAL" "$RAIZ/work/colgada-$k-$i" 2>/dev/null + kill -TERM $BW 2>/dev/null; sleep 3; kill -KILL $BW 2>/dev/null + else + wait $BW 2>/dev/null + SEGV[$k]=$(( ${SEGV[$k]} + $(grep -c 'exited on signal 11' "$SAL/nav.log") )) + [ -f "$SAL/x.png" ] && SHOT[$k]=$(( ${SHOT[$k]} + 1 )) + fi + rm -rf "$SAL" + done + [ $((i % 10)) = 0 ] && echo " $i/$N · viejo segv=${SEGV[viejo]} colg=${COLG[viejo]} · nuevo segv=${SEGV[nuevo]} colg=${COLG[nuevo]}" +done +echo "" +for k in viejo nuevo; do + printf "%-10s segfaults=%-4s COLGADAS=%-3s screenshots=%s/%s\n" "$k" "${SEGV[$k]}" "${COLG[$k]}" "${SHOT[$k]}" "$N" +done