From b405fc77dfee7904f5f78f838cd6479854419522 Mon Sep 17 00:00:00 2001 From: Sergio Date: Tue, 8 Sep 2026 15:59:23 +0000 Subject: [PATCH] cuelgue headless RESUELTO: el sandbox de Firefox no puede montar su userns dentro de bwrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No era la presión de memoria. La caza bajo presión (4 niveles × 20 corridas, con el nivel de presión MEDIDO por MemAvailable y PSI) reprodujo el cuelgue por primera vez en 282 corridas —2 de 80, 2,5%, compatible con el 3,5% original— pero SIN dosis-respuesta: los dos cuelgues cayeron en el nivel con PSI 0,00 y los dos niveles apretados dieron cero. Tres hipótesis muertas, cada una con su medición: · OOM se lleva al hijo → oom_kill de /proc/vmstat no se movió (delta=0) y los hijos vivían · presión de memoria → sin dosis-respuesta · fork server de Gecko → A/B del pref: el proceso forkserver desaparece (35 muestras → 0, o sea que el pref hizo efecto) y siguen los mismos 2 segfaults La causa apareció comparando el log de una colgada con el de una BUENA, que es lo que faltaba: en las 6 buenas TAMBIÉN revientan 2 hijos con SIGSEGV, después de escribir el PNG y por eso invisibles. Muestreando /proc adentro, se llaman 'Sandbox Forked': los ayudantes que el sandbox propio de Firefox forkea para montar su user-namespace, que no puede montar porque ya estamos dentro del de bwrap ('writing /proc/self/uid_map: EPERM'). Las tres cantidades bajan juntas hasta cero, que es forma de cadena causal: sandbox completo segv=7 SIN screenshot EPERM=9 SandboxForked=725 content.level=0 segv=2 con screenshot EPERM=2 SandboxForked=68 los 3 prefs .level a 0 segv=1 con screenshot EPERM=1 SandboxForked=33 las 5 MOZ_DISABLE_* segv=0 con screenshot EPERM=0 SandboxForked=0 Y el sandbox completo cuelga DETERMINISTA (rc=124 a los 180 s). Comparte familia con el intermitente pero no está probado que sean el mismo: al determinista le faltan los 'Failed to launch'. Queda dicho como lo que es. Arreglo para cualquier firefox headless en bwrap (donde el sandbox de Gecko no aporta nada, porque bwrap ya es la jaula): MOZ_DISABLE_{CONTENT,GMP,RDD,SOCKET_PROCESS,UTILITY}_SANDBOX=1 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014QPJteswQvP1L7zSBrQQe2 --- .../diagnostico/cazar-cuelgue-bajo-presion.sh | 95 +++++++++++++++++++ scripts/diagnostico/cazar-cuelgue-headless.sh | 59 ++++++++++++ scripts/diagnostico/presion-memoria.py | 56 +++++++++++ .../diagnostico/verificar-arreglo-sandbox.sh | 53 +++++++++++ 4 files changed, 263 insertions(+) create mode 100755 scripts/diagnostico/cazar-cuelgue-bajo-presion.sh create mode 100755 scripts/diagnostico/presion-memoria.py create mode 100755 scripts/diagnostico/verificar-arreglo-sandbox.sh 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