gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

arnés de la imagen: el contador de caídas viaja con la medición, y las envenenadas se apartan

`atuq-en-imagen.py` ahora lee `toolkit.startup.recent_crashes` del perfil ANTES y DESPUÉS de cada
corrida y lo anota junto al tiempo de primera pintura (`recent_crashes_antes` / `_despues` /
`_fijado`). Los dos volcados, no uno: Gecko BORRA el contador al mostrar el diálogo de Modo de
resolución de problemas, así que un perfil envenenado se ve limpio si se lo mira después — y la
corrida siguiente parece arreglarse sola mientras la anterior parece intermitente.

Con eso, `vigia-imagen.py` saca de la tasa las corridas con el contador > 3 (ahí el sujeto no es el
navegador sino un diálogo modal) y para las 21 anteriores al campo dice que NO SE PUEDEN CLASIFICAR
en vez de contarlas como buenas. Un denominador se marca, no se borra.

Mandos y guardas nuevas:

· `--crashes N` fija el contador en el `user.js` del perfil: 0 limpia, >3 reproduce. Es el control en
  los dos sentidos, que es lo único que distingue causa de correlación con suerte;
· `WidgetScreen:5` en el MOZ_LOG y una sonda `ATUQ-DIAG` en la propia página (screen/outer/inner por
  `dump()`), que es lo que refutó la carrera con `wl_output`;
· la lista ordenada del protocolo ya no se ahoga en los ~60 modos que anuncia QEMU —el `head`
  cortaba antes del `set_window_geometry`— y trae `set_title`/`set_app_id`, que es lo que identificó
  la ventana;
· si QEMU no arranca, se dice en el primer segundo leyendo `qemu.log`. El fallo real es «Failed to
  get write lock» con otra VM sobre la misma imagen, y sin esta guarda se veía como diez minutos
  esperando una marca del serial: el socket queda creado y el `connect()` funciona.

Las fases (`--xulstore`), el `ATUQ-EXIT=$?`, la copia del log del arranque anterior y el `find` del
perfil sobre la raíz entera los escribió la otra sesión que trabaja este frente; conviven acá porque
el fichero es compartido.

2f90d8eb4015889eaac99453ceeecb106d576c70 · por Sergio · (hace 1 mes) · padre 70107b168 · ver el árbol en este commit

Delta semántico · 7

scripts/cosmic/atuq-en-imagen.py

scripts/vigia-imagen.py

2 archivos · +354 −113

diff --git a/scripts/cosmic/atuq-en-imagen.py b/scripts/cosmic/atuq-en-imagen.py
index 1705c74c..07c46191 100755
--- a/scripts/cosmic/atuq-en-imagen.py
+++ b/scripts/cosmic/atuq-en-imagen.py
@@ -23,7 +23,7 @@ del serial es exactamente desde donde un usuario lanzaría una app — y por eso
 reconstruir el entorno a mano, que es como se inventan diferencias que después se confunden con la
 causa.
 """
-import argparse, json, os, shutil, socket, subprocess, sys, time
+import argparse, json, os, re, shutil, socket, subprocess, sys, time
 
 # Sin esto la salida sale por tandas cuando se redirige a un fichero, y una corrida de 20 minutos se
 # lee como si estuviera colgada.
@@ -150,7 +150,26 @@ def pintado(ppm):
     return sum(n for n, (r, g, b) in im.getcolors(1 << 24) if r > 90 and b > 90 and g < 40)
 
 
-def anotar_medicion(segundos, a, pantalla=None):
+def leer_contador_de_caidas(texto):
+    """Saca el valor de `toolkit.startup.recent_crashes` de un volcado de `prefs.js`.
+
+    Devuelve `None` si el pref NO ESTÁ, que no es lo mismo que un 0 puesto a mano: «no está» es el
+    estado de un perfil cuyo arranque terminó bien —y es también lo que deja Gecko DESPUÉS de
+    mostrar el diálogo de Modo de resolución de problemas, que es la razón por la que la corrida
+    siguiente «se arregla sola» y la anterior parece intermitente.
+
+    Filtra por `user_pref` a propósito: el serial hace eco de la propia orden `grep`, y esa línea
+    contiene el nombre del pref sin su valor.
+    """
+    for linea in texto.splitlines():
+        if "user_pref" in linea and "toolkit.startup.recent_crashes" in linea:
+            m = re.search(r"recent_crashes\"?\s*,\s*(-?\d+)", linea)
+            if m:
+                return int(m.group(1))
+    return None
+
+
+def anotar_medicion(segundos, a, pantalla=None, extra=None):
     """Deja el tiempo de primera pintura en `docs/state/primera-pintura.json`.
 
     Va al estado del repo y no a un log porque quien pregunta «¿esta imagen se puede usar?» es
@@ -174,6 +193,12 @@ def anotar_medicion(segundos, a, pantalla=None):
         "mem_mb": int(MEM), "vcpus": int(SMP), "acel": "tcg",
         "guion": "scripts/cosmic/atuq-en-imagen.py",
     }
+    # ⚠ EL CONTADOR DE CAÍDAS DEL PERFIL VIAJA CON LA MEDICIÓN, y es lo que hace legible la serie:
+    # con `toolkit.startup.recent_crashes` por encima de 3 el sujeto de la corrida NO es el
+    # navegador sino el diálogo de Modo de resolución de problemas (§6.10.terdecies), así que un ✗
+    # de esas corridas no dice nada del producto. Sin este campo, una tasa cierta se lee como si
+    # fuera del navegador — que es exactamente lo que pasó con «3 de 13» y «2 de 10».
+    dato.update(extra or {})
     # ⚠ SE ACUMULAN, NO SE PISAN. Medido el 2026-09-14: dos corridas IDÉNTICAS (mismo comando,
     # misma imagen) dieron «+204 s» y «no pintó en 900 s». Un fichero de UNA medición convierte un
     # fenómeno intermitente en el número que le tocó a la última corrida, que es la forma más cara
@@ -228,6 +253,15 @@ def main():
                     help="escribe RUST_LOG en /etc/cosmic-mode de la IMAGEN y apaga; "
                          "cadena vacía = lo quita. El compositor lo lee en el arranque SIGUIENTE, "
                          "que es la única forma de subirle el nivel de log sin recompilar")
+    ap.add_argument("--crashes", type=int, metavar="N",
+                    help="fija `toolkit.startup.recent_crashes` del perfil del usuario antes de "
+                         "lanzar: 0 lo limpia, >3 reproduce el diálogo de Modo de resolución de "
+                         "problemas. Sólo tiene efecto con --as-user")
+    ap.add_argument("--xulstore", metavar="ANCHOxALTO",
+                    help="agrega una SEGUNDA fase en el mismo arranque con `xulstore.json` sembrado "
+                         "(p. ej. 1152x720): la ventana arranca con un tamaño YA PERSISTIDO. Es el "
+                         "control del 117x70 — si con semilla sale grande, lo que falla es que nadie "
+                         "le da tamaño en el primer arranque")
     ap.add_argument("--out", default=os.path.join(RAIZ, "work/atuq-imagen"))
     a = ap.parse_args()
 
@@ -262,6 +296,17 @@ def main():
 
     vm = subprocess.Popen(qemu, stdout=open(os.path.join(out, "qemu.log"), "wb"),
                           stderr=subprocess.STDOUT, preexec_fn=primero_en_la_fila)
+    # ⚠ QEMU TOMA UN LOCK DE ESCRITURA SOBRE LA IMAGEN, y sin esta comprobación el arnés espera
+    # 600 s una marca del serial que no va a llegar NUNCA: el socket queda creado, el `connect()`
+    # funciona y no falla nada — el error es UNA línea en `qemu.log` que nadie mira. Medido el
+    # 2026-09-15 al lanzar el control negativo con la corrida anterior todavía viva:
+    #     Failed to get "write" lock. Is another process using the image [...]?
+    # Las corridas sobre la misma imagen van EN SERIE; esto lo dice en el primer segundo.
+    time.sleep(2)
+    if vm.poll() is not None:
+        raise SystemExit("✗ QEMU no arrancó:\n   " +
+                         open(os.path.join(out, "qemu.log")).read().strip())
+
     try:
         ser = Serial(ser_sock, os.path.join(out, "serial.log"))
         print("== esperando a COSMIC…", flush=True)
@@ -295,124 +340,294 @@ def main():
             print("== CONTROL: no se lanza el navegador")
         else:
             # Una página de un color imposible de confundir con el escritorio.
-            ser.cmd("mkdir -p /tmp/perfil /var/log/cosmic")
+            ser.cmd("mkdir -p /var/log/cosmic")
+            # La página además DICE, por `dump()`, qué tamaño cree que tiene la ventana y qué
+            # pantalla ve el navegador. Con `screen=` y `outer=` en el log se sabe si el navegador
+            # ve la pantalla o no — y desde el §6.10.terdecies sabemos que SÍ la ve, así que lo que
+            # queda por contar es el otro lado: qué tamaño cree tener la VENTANA. Sin comillas
+            # simples: va dentro de un `printf '%s' '...'`.
             ser.cmd("printf '%s' '<!doctype html><body style=\"margin:0;background:#ff00ff\">"
-                    "<h1 style=\"font:40px monospace\">ATUQ PINTA</h1>' > /tmp/pagina.html")
-            ser.cmd("printf '%s\\n' 'user_pref(\"browser.dom.window.dump.enabled\", true);' > /tmp/perfil/user.js")
-            ser.cmd("printf '%s\\n' 'user_pref(\"browser.shell.checkDefaultBrowser\", false);' >> /tmp/perfil/user.js")
-            # ⚠ LOS DOS LANZAMIENTOS NO SON EL MISMO EXPERIMENTO. El de por defecto fija el
-            # entorno (perfil nuevo, `MOZ_ENABLE_WAYLAND`) para que la medición no dependa de lo que
-            # haya quedado en el estado del usuario; `--como-usuario` reproduce lo que se tecleó a
-            # mano en el §6.10.ter —`atuq` pelado, con SU perfil y sin variables— que es lo único que
-            # sigue distinto entre aquella corrida y ésta. El `MOZ_LOG` va en los dos: no cambia el
-            # comportamiento, sólo deja rastro.
-            wd = "WAYLAND_DEBUG=1 " if a.wayland_debug else ""
-            if a.as_user:
-                lanz = (wd + "MOZ_LOG='Widget:5,WidgetWayland:5,WaylandBackend:5,timestamp' "
-                        "MOZ_LOG_FILE=/var/log/cosmic/moz "
-                        "/usr/bin/atuq file:///tmp/pagina.html > /var/log/cosmic/atuq.log 2>&1 &")
-            else:
-                lanz = (wd + "MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland "
-                        "MOZ_LOG='Widget:5,WidgetWayland:5,WaylandBackend:5,timestamp' "
-                        "MOZ_LOG_FILE=/var/log/cosmic/moz "
-                        "/usr/bin/atuq --no-remote --profile /tmp/perfil file:///tmp/pagina.html "
-                        "> /var/log/cosmic/atuq.log 2>&1 &")
-            print(ser.cmd(lanz, fondo=True).strip())
-            print(f"== navegador lanzado; observando {a.wait}s", flush=True)
-            t0 = time.time(); n = 0
-            primera = None           # segundos hasta la PRIMERA captura con la página en pantalla
-            while time.time() - t0 < a.wait:
-                time.sleep(a.cadence); n += 1
-                tomas = capturar(qmp_sock, out, f"durante-{n}")
-                t = int(time.time() - t0)
-                medidas = {p: pintado(f) for p, f in tomas.items()}
-                px = max((v for v in medidas.values() if v is not None), default=None)
-                donde = next((p for p, v in medidas.items() if v and v > 100_000), None)
-                if primera is None and px and px > 100_000:
-                    primera = t
-                    print(f"   +{t}s  ██ PRIMERA PINTURA — {px} px de la página EN {donde}", flush=True)
-                    if a.until_paint:
-                        break
+                    "<h1 style=\"font:40px monospace\">ATUQ PINTA</h1><script>"
+                    "function diag(t){dump(\"ATUQ-DIAG \"+t+\" screen=\"+screen.width+\"x\"+screen.height"
+                    "+\" avail=\"+screen.availWidth+\"x\"+screen.availHeight"
+                    "+\" outer=\"+outerWidth+\"x\"+outerHeight"
+                    "+\" inner=\"+innerWidth+\"x\"+innerHeight"
+                    "+\" dpr=\"+devicePixelRatio+\"\\n\");}"
+                    "addEventListener(\"load\",function(){diag(\"load\");"
+                    "setTimeout(function(){diag(\"t10\");},10000);"
+                    "setTimeout(function(){diag(\"t60\");},60000);});"
+                    "</script>' > /tmp/pagina.html")
+
+            # ⚠ LO QUE DEJÓ LA CORRIDA ANTERIOR, ANTES DE PISARLO. El disco de esta VM es
+            # PERSISTENTE (`-drive` sin `snapshot=on`) ⇒ `/var/log/cosmic/` todavía tiene el
+            # arranque anterior, y el lanzamiento de esta corrida lo trunca. Ahí es donde salen los
+            # errores de JS del chrome, y NUNCA se habían mirado: se leía `tail -25`, que con
+            # `WAYLAND_DEBUG` son 25 líneas de protocolo y nada más. Copiarlo primero cuesta nada.
+            ser.cmd("rm -rf /var/log/cosmic-anterior; cp -a /var/log/cosmic /var/log/cosmic-anterior "
+                    "2>/dev/null; true", 120)
+            print("---- corrida ANTERIOR: cómo terminó y qué se quejó ----")
+            print(ser.cmd("grep -a -h -E 'ATUQ-EXIT|JavaScript error|Uncaught|NS_ERROR|"
+                          "###!!!|\\[Parent\\].*ERROR' /var/log/cosmic-anterior/atuq*.log "
+                          "2>/dev/null | head -20", 120))
+            # DÓNDE VIVE EL PERFIL, SIN ADIVINARLO. El `find` anterior miraba sólo `/root` y esta
+            # imagen lo desmintió en silencio: dijo «no encontré el perfil» y la sonda salió muda.
+            # `$HOME` lo decide todo y el shell del serial lo hereda de `cosmic-start`, así que se
+            # IMPRIME; y el `find` va sobre la raíz entera con `-xdev` (que deja fuera /proc y /sys).
+            print("== HOME del shell de la sesión y perfiles que ya existen en el disco:")
+            print(ser.cmd("echo HOME=$HOME; find / -xdev -maxdepth 7 -name prefs.js 2>/dev/null | head -5", 180))
+
+            def perfil_de_la_fase(semilla):
+                """Deja el perfil listo para la fase y devuelve su directorio (o None).
+
+                Las dos formas de lanzar NO son el mismo experimento y por eso conviven: el modo por
+                defecto usa un perfil NUEVO en tmpfs —primer arranque de verdad, que es el caso que
+                sale 117×70— y `--as-user` reproduce lo que se tecleó a mano en el §6.10.ter, con el
+                perfil del usuario y sin variables de entorno.
+                """
+                if a.as_user:
+                    hallado = ser.cmd("find / -xdev -maxdepth 7 -name prefs.js 2>/dev/null | head -3", 180)
+                    perfil = next((l.strip() for l in hallado.splitlines()
+                                   if l.strip().endswith("prefs.js") and l.strip().startswith("/")), None)
+                    if not perfil:
+                        print("   ⚠ no encontré el perfil del usuario: la sonda ATUQ-DIAG va a salir muda")
+                        return None
+                    d = os.path.dirname(perfil)
+                    print("== perfil del usuario:", d)
+                    # ⚠⚠ EL CONTADOR DE CAÍDAS DEL PERFIL, SIEMPRE A LA VISTA. Con
+                    # `toolkit.startup.recent_crashes` por encima de `max_resumed_crashes` (3 por
+                    # defecto) el navegador NO abre el navegador: abre el diálogo de Modo de
+                    # resolución de problemas. Y este arnés mata la VM con el navegador vivo, así
+                    # que CADA corrida fallida puede sumar uno: el instrumento degrada el sujeto y
+                    # la tasa medida deja de ser del producto. Sin este número impreso, una corrida
+                    # no se puede interpretar.
+                    volcado = ser.cmd("grep -E 'toolkit.startup.(recent_crashes|max_resumed)|"
+                                      "browser.sessionstore.max_resumed' %s/prefs.js" % d, 60)
+                    print(volcado.strip())
+                    estado["recent_crashes_antes"] = leer_contador_de_caidas(volcado)
+                else:
+                    d = "/tmp/perfil"
+                    ser.cmd("rm -rf /tmp/perfil && mkdir -p /tmp/perfil")
+                ujs = ['user_pref("browser.dom.window.dump.enabled", true);',
+                       'user_pref("browser.shell.checkDefaultBrowser", false);']
+                if a.crashes is not None:
+                    # El mismo mando en los DOS sentidos: 0 limpia el contador (¿pinta?) y un
+                    # número alto lo reproduce a propósito (¿se rompe?). `user.js` gana sobre
+                    # `prefs.js` en cada arranque, así que fija el valor de la corrida.
+                    ujs.append('user_pref("toolkit.startup.recent_crashes", %d);' % a.crashes)
+                    print("== recent_crashes FIJADO a %d para esta corrida" % a.crashes)
+                ser.cmd("rm -f %s/user.js" % d)
+                for linea in ujs:
+                    ser.cmd("printf '%%s\\n' '%s' >> %s/user.js" % (linea, d))
+                if semilla:
+                    # EL CONTROL. `browser-init.js` —el que trae el propio artefacto, leído del
+                    # `omni.ja` sellado— fija el tamaño de arranque UNA sola vez:
+                    #
+                    #     } else if (!document.documentElement.hasAttribute("width")) {
+                    #       let width  = Math.min(screen.availWidth  * 0.9, 1280);
+                    #       let height = Math.min(screen.availHeight * 0.9, 1040);
+                    #
+                    # y ese atributo, en un perfil ya usado, lo rellena el `xulstore.json`. Sembrarlo
+                    # a mano separa las dos explicaciones posibles del 117×70 en UNA corrida: si con
+                    # semilla la ventana sale grande, el fallo es «nadie le dio tamaño» (ese `if` no
+                    # corrió); si sale igual de chica, el tamaño no es lo que falla.
+                    w, h = semilla.lower().split("x")
+                    doc = {"chrome://browser/content/browser.xhtml":
+                           {"main-window": {"screenX": "0", "screenY": "0",
+                                            "width": w, "height": h, "sizemode": "normal"}}}
+                    ser.cmd("printf '%%s' '%s' > %s/xulstore.json" % (json.dumps(doc), d))
+                elif not a.as_user:
+                    # Sólo en el perfil DESECHABLE de tmpfs. ⚠ En `--as-user` el `xulstore.json` es
+                    # el estado del usuario —el tamaño que la ventana RECUERDA— y borrarlo cambiaría
+                    # el experimento sin decirlo: la corrida siguiente ya no sería «como lo abre el
+                    # usuario» sino un primer arranque disfrazado.
+                    ser.cmd("rm -f %s/xulstore.json" % d)
+                ser.cmd("sync")
+                print("== el perfil, ANTES de lanzar (user.js y el tamaño que RECUERDA):")
+                print(ser.cmd("cat %s/user.js; echo ---; cat %s/xulstore.json 2>/dev/null || "
+                              "echo '(sin xulstore.json: la ventana no recuerda ningún tamaño)'"
+                              % (d, d), 60).strip())
+                return d
+
+            def matar_al_navegador():
+                """Deja la sesión sin ningún `atuq` vivo antes de la fase siguiente.
+
+                Por PID leído de `/proc/*/comm`, no por `pkill -f atuq`: el patrón engancha la propia
+                línea de orden del shell del serial y ahí se mata la sesión entera (medido en el hub,
+                sale 144 y lo que sigue no corre).
+                """
+                ser.cmd("for p in /proc/[0-9]*; do grep -q atuq $p/comm 2>/dev/null && "
+                        "kill ${p#/proc/}; done; true", 60)
+                time.sleep(8)
+                salida = ser.cmd("for c in /proc/[0-9]*/comm; do cat $c 2>/dev/null; done | grep -c atuq")
+                # El serial hace ECO de la orden, así que la respuesta es la ÚLTIMA línea que sea un
+                # número y no la primera que aparezca: leer la primera devuelve un trozo del eco.
+                cuenta = next((l.strip() for l in reversed(salida.splitlines()) if l.strip().isdigit()), "?")
+                print("   navegadores vivos tras el kill: " + cuenta)
+
+            # LAS FASES. Una sola es la medición de siempre; con `--xulstore` se agrega una SEGUNDA
+            # en el MISMO arranque, que es un A/B de verdad: mismo compositor, misma sesión, misma
+            # carga del anfitrión. Dos arranques distintos no lo serían — y cada arranque cuesta
+            # cinco minutos de TCG.
+            # El estado del perfil que hay que ADJUNTAR a la medición: el contador de caídas antes
+            # y después. Vive acá afuera porque lo escribe la preparación de la fase y lo lee la
+            # anotación, y porque con `--crashes` fijado hay que poder distinguir el valor que
+            # PUSIMOS del que el perfil traía.
+            estado = {"recent_crashes_fijado": a.crashes}
+
+            fases = [("base", None)]
+            if a.xulstore:
+                fases.append(("semilla-%s" % a.xulstore, a.xulstore))
+
+            for fase, semilla in fases:
+                print("\n===== FASE «%s» =====" % fase, flush=True)
+                if semilla:
+                    matar_al_navegador()
+                perfil_d = perfil_de_la_fase(semilla)
+                log_atuq = "/var/log/cosmic/atuq-%s.log" % fase
+                log_moz = "/var/log/cosmic/moz-%s" % fase
+                capturar(qmp_sock, out, "%s-antes" % fase)
+                print("== captura ANTES de la fase (%s)" % ", ".join(PANTALLAS), flush=True)
+
+                wd = "WAYLAND_DEBUG=1 " if a.wayland_debug else ""
+                moz = ("MOZ_LOG='Widget:5,WidgetWayland:5,WaylandBackend:5,WidgetScreen:5,timestamp' "
+                       "MOZ_LOG_FILE=%s " % log_moz)
+                if a.as_user:
+                    orden = wd + moz + "/usr/bin/atuq file:///tmp/pagina.html"
                 else:
-                    vivos = ser.cmd("for c in /proc/[0-9]*/comm; do cat $c 2>/dev/null; done | grep -c atuq")
-                    v = vivos.splitlines()[1].strip() if len(vivos.splitlines()) > 1 else "?"
-                    detalle = " ".join(f"{p}={medidas[p]}" for p in PANTALLAS)
-                    print(f"   +{t}s  px de la página: {detalle}   procesos atuq: {v}", flush=True)
-
-            capturar(qmp_sock, out, "despues")
-            print("== captura DESPUES", flush=True)
-
-            # EL NÚMERO. No «pinta / no pinta»: CUÁNTO TARDA. Medido el 2026-09-14 sobre esta misma
-            # imagen, la primera pintura cae entre +137 s y +204 s con el perfil por defecto y ~+134 s
-            # con uno nuevo en tmpfs — y con la máquina anfitriona cargada pasa de los +300 s. Por eso
-            # el presupuesto es HOLGADO y se dice el número siempre: un guardián que sólo contestara
-            # «no pintó» a los 5 min habría firmado el error del §6.10.ter.
-            if primera is None:
-                print(f"✗ el navegador NO pintó en {a.wait}s — mirá {out}/despues.png y el MOZ_LOG:")
-                print("  `mapped 1` + «marked as visible & has buffer» + WaylandBufferSHM ⇒ estaba pintando"
-                      " y la captura llegó antes; sin eso, es un fallo de verdad")
-                rc[0] = 1
-            else:
-                print(f"✓ primera pintura: +{primera}s   (presupuesto {a.budget}s)")
-                if primera > a.budget:
-                    print(f"✗ tardó {primera - a.budget}s MÁS que el presupuesto")
+                    orden = (wd + "MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland " + moz +
+                             "/usr/bin/atuq --no-remote --profile /tmp/perfil file:///tmp/pagina.html")
+                # ⚠ EL NAVEGADOR SE MURIÓ Y NADIE LO SUPO (medido 2026-09-15, 17:12): el shell
+                # imprimió «[1]+ Done» a los ~25 s y la corrida siguió 420 s fotografiando un
+                # escritorio sin navegador, para terminar diciendo «no pintó». Un proceso que sale
+                # deja un CÓDIGO, y ese código es la diferencia entre «se cayó» y «se fue solo»; va
+                # al log, en el subshell, para que quede escrito aunque el arnés muera después.
+                print(ser.cmd("( %s; echo ATUQ-EXIT=$? ) > %s 2>&1 &" % (orden, log_atuq), fondo=True).strip())
+                print(f"== navegador lanzado; observando {a.wait}s", flush=True)
+                t0 = time.time(); n = 0
+                primera = None       # segundos hasta la PRIMERA captura con la página en pantalla
+                donde = None
+                while time.time() - t0 < a.wait:
+                    time.sleep(a.cadence); n += 1
+                    tomas = capturar(qmp_sock, out, f"{fase}-durante-{n}")
+                    t = int(time.time() - t0)
+                    medidas = {p: pintado(f) for p, f in tomas.items()}
+                    px = max((v for v in medidas.values() if v is not None), default=None)
+                    donde = next((p for p, v in medidas.items() if v and v > 100_000), None)
+                    if primera is None and px and px > 100_000:
+                        primera = t
+                        print(f"   +{t}s  ██ PRIMERA PINTURA — {px} px de la página EN {donde}", flush=True)
+                        if a.until_paint:
+                            break
+                    else:
+                        vivos = ser.cmd("for c in /proc/[0-9]*/comm; do cat $c 2>/dev/null; done | grep -c atuq")
+                        v = vivos.splitlines()[1].strip() if len(vivos.splitlines()) > 1 else "?"
+                        detalle = " ".join(f"{p}={medidas[p]}" for p in PANTALLAS)
+                        print(f"   +{t}s  px de la página: {detalle}   procesos atuq: {v}", flush=True)
+
+                capturar(qmp_sock, out, "%s-despues" % fase)
+                print("== captura DESPUES de la fase", flush=True)
+
+                # EL NÚMERO. No «pinta / no pinta»: CUÁNTO TARDA. Medido el 2026-09-14 sobre esta
+                # misma imagen, la primera pintura cae entre +137 s y +204 s con el perfil por
+                # defecto y ~+134 s con uno nuevo en tmpfs — y con la máquina anfitriona cargada pasa
+                # de los +300 s. Por eso el presupuesto es HOLGADO y se dice el número siempre: un
+                # guardián que sólo contestara «no pintó» a los 5 min habría firmado el error del
+                # §6.10.ter.
+                if primera is None:
+                    print(f"✗ el navegador NO pintó en {a.wait}s — mirá {out}/{fase}-despues.png y el MOZ_LOG:")
+                    print("  `mapped 1` + «marked as visible & has buffer» + WaylandBufferSHM ⇒ estaba pintando"
+                          " y la captura llegó antes; sin eso, es un fallo de verdad")
                     rc[0] = 1
-
-            # El número se PUBLICA donde lo pueda leer el vigía de la imagen, que es estático y no
-            # arranca nada: sin esto el dato vive en el scrollback del que corrió la VM y se pierde,
-            # que es exactamente lo que pasó con la corrida a mano del §6.10.ter.
-            anotar_medicion(primera, a, donde if primera is not None else None)
-
-            # La evidencia que no se puede leer de una captura: qué hizo el widget de Wayland.
-            print("---- atuq.log (cola) ----")
-            print(ser.cmd("tail -25 /var/log/cosmic/atuq.log", 60))
-            print("---- MOZ_LOG: creación de la ventana ----")
-            print(ser.cmd("grep -a -m 40 -E 'nsWindow::Create|CreateNative|Map|configure|Show|frame callback|Buffer' "
-                          "/var/log/cosmic/moz.moz_log | head -40", 120))
-            print("---- MOZ_LOG: últimas líneas ----")
-            print(ser.cmd("tail -25 /var/log/cosmic/moz.moz_log", 60))
-            # El otro lado de la conversación. Gecko commitea a ciegas cuando nadie le devuelve
-            # frame callbacks (§6.10.nonies), y eso lo decide el COMPOSITOR: sin su log, «no pinta»
-            # sólo se puede mirar desde el cliente, que es la mitad que ya sabemos que miente.
-            if a.wayland_debug:
-                # Lo que INTERESA no es lo que pide el cliente sino lo que llega de vuelta: las
-                # líneas `[  ]` de entrada son eventos DEL COMPOSITOR. Sin `wl_callback.done` no
-                # hay frame callbacks, que es exactamente la hipótesis del §6.10.nonies.
-                # ⚠ EN `WAYLAND_DEBUG` LA FLECHA ES DEL CLIENTE. `-> obj.metodo()` es un PEDIDO que
-                # manda el navegador; los EVENTOS del compositor son las líneas SIN flecha. Filtrar
-                # por `->` es leer otra vez al cliente, que es justo la mitad que ya sabemos que
-                # miente. Y los nombres llevan `@id` en el medio, así que `wl_surface.commit` no
-                # engancha: hay que anclar en el método.
-                # QUIÉN ELIGE EL TAMAÑO. `attach` no lo dice: el tamaño del buffer está en
-                # `create_buffer(…, W, H, stride, fmt)`, y lo que el cliente DECLARA querer está en
-                # `set_window_geometry`. Puestos en orden y en los DOS sentidos, se ve si el 117×70
-                # lo propone el cliente (y el compositor lo repite) o lo impone el compositor.
-                print("---- protocolo: el tamaño, en orden (→ = pedido del cliente) ----")
-                # Va `wl_output` en la MISMA lista y numerada: la pregunta es si la geometría de
-                # la salida (`mode`) llega ANTES o DESPUÉS de que el cliente fije su tamaño. Con dos
-                # greps separados eso no se puede ordenar.
-                print(ser.cmd("grep -a -n -E 'create_buffer|set_window_geometry|configure|ack_configure|"
-                              "get_toplevel|set_min_size|set_max_size|\\.attach|wl_output|\\.mode\\(|"
-                              "\\.geometry\\(|\\.done\\(\\)' "
-                              "/var/log/cosmic/atuq.log | head -70", 240))
-                print("---- protocolo: EVENTOS del compositor (sin flecha) ----")
-                print(ser.cmd("grep -av ' -> ' /var/log/cosmic/atuq.log | "
-                              "grep -a -E 'xdg_surface|xdg_toplevel|wl_callback|wl_output|wl_surface' | "
-                              "tail -40", 180))
-                print("---- protocolo: cuentas (evento = sin flecha) ----")
-                print(ser.cmd("for k in xdg_toplevel# xdg_surface# wl_callback# wl_output#; do "
-                              "printf 'EVENTOS %s ' $k; grep -av ' -> ' /var/log/cosmic/atuq.log | "
-                              "grep -ac $k; done", 180))
-                print(ser.cmd("printf 'done() '; grep -av ' -> ' /var/log/cosmic/atuq.log | "
-                              "grep -ac 'done('; printf 'configure() '; grep -av ' -> ' "
-                              "/var/log/cosmic/atuq.log | grep -ac 'configure('", 180))
+                else:
+                    print(f"✓ primera pintura: +{primera}s   (presupuesto {a.budget}s)")
+                    if primera > a.budget:
+                        print(f"✗ tardó {primera - a.budget}s MÁS que el presupuesto")
+                        rc[0] = 1
+
+                if perfil_d and a.as_user:
+                    # ⚠ EL ESTADO FINAL ES PARTE DE LA PRUEBA. Gecko BORRA el contador después de
+                    # mostrar el diálogo, así que un perfil envenenado se ve limpio en cuanto se
+                    # lo mira DESPUÉS: sin este segundo volcado, la corrida siguiente parece
+                    # «arreglada sola» y la anterior parece intermitente. Se lee con el navegador
+                    # todavía vivo, o sea que refleja el último volcado de prefs de Gecko.
+                    volcado = ser.cmd("grep -E 'toolkit.startup.recent_crashes' %s/prefs.js || "
+                                      "echo '(el pref no está: arranque sin caídas pendientes)'"
+                                      % perfil_d, 60)
+                    estado["recent_crashes_despues"] = leer_contador_de_caidas(volcado)
+                    print("---- el contador de caídas, DESPUÉS ----")
+                    print(volcado.strip())
+                # ⚠ SÓLO LA FASE BASE SE ANOTA. La serie de `primera-pintura.json` es una TASA del
+                # producto tal como se entrega; una fase con el tamaño de ventana sembrado a mano es
+                # otro experimento, y mezclarlos haría que el denominador dejara de significar nada.
+                if semilla is None:
+                    anotar_medicion(primera, a, donde if primera is not None else None, estado)
+                else:
+                    print("   (fase con semilla: NO se anota en la serie de la tasa)")
+
+                # La evidencia que no se puede leer de una captura: cómo terminó el proceso, qué se
+                # quejó el chrome, y qué hizo el widget de Wayland.
+                # QUÉ QUEDÓ PERSISTIDO. `browser.xhtml` lleva `persist="… width height sizemode"`,
+                # así que el tamaño con el que terminó la ventana se ESCRIBE en el perfil y lo hereda
+                # la corrida siguiente: si una corrida sale de 117×70 y lo guarda, el fallo se
+                # aprende. Sin este volcado, «intermitente» y «contagiado» se ven igual.
+                if perfil_d:
+                    print("---- el perfil, DESPUÉS: qué tamaño quedó recordado ----")
+                    print(ser.cmd("cat %s/xulstore.json 2>/dev/null || echo '(no escribió xulstore.json)'"
+                                  % perfil_d, 60))
+                print("---- cómo terminó, y de qué se quejó el chrome ----")
+                print(ser.cmd("grep -a -E 'ATUQ-EXIT|JavaScript error|Uncaught|NS_ERROR|###!!!' "
+                              "%s | head -25" % log_atuq, 120))
+                print("---- atuq.log (cabeza) ----")
+                print(ser.cmd("head -20 %s" % log_atuq, 60))
+                print("---- atuq.log (cola) ----")
+                print(ser.cmd("tail -20 %s" % log_atuq, 60))
+                print("---- MOZ_LOG: creación de la ventana ----")
+                print(ser.cmd("grep -a -m 40 -E 'nsWindow::Create|CreateNative|Map|configure|Show|frame callback|Buffer' "
+                              "%s.moz_log | head -40" % log_moz, 120))
+                # QUÉ PANTALLA VE GECKO — contestado el 2026-09-15 y por eso se queda: la sonda que
+                # ya contestó es la que avisa cuando la respuesta cambia. `New monitor 0 size
+                # [0,0 -> 1280 x 800]` ANTES de `Initial resize to 1 x 1` ⇒ la geometría de la salida
+                # NO es lo que falta.
+                print("---- MOZ_LOG: pantallas que ve Gecko y tamaños del nsWindow ----")
+                print(ser.cmd("grep -a -E 'WidgetScreen|Screen\\[|AddScreen|nsWindow::Resize|Initial resize|"
+                              "SetSizeMode|OnConfigure|SizeAllocate|LockAspect' "
+                              "%s.moz_log | head -50" % log_moz, 180))
+                # Y lo que dice el propio navegador, desde la página, por `dump()`: `screen=` es lo
+                # que el contenido ve de la pantalla y `outer=` el tamaño de la ventana. Dos fuentes
+                # independientes para el mismo número; si se contradicen, eso también es un dato.
+                print("---- la página: ATUQ-DIAG ----")
+                print(ser.cmd("grep -a ATUQ-DIAG %s | head -10" % log_atuq, 120))
+                print("---- MOZ_LOG: últimas líneas ----")
+                print(ser.cmd("tail -25 %s.moz_log" % log_moz, 60))
+                # El otro lado de la conversación. Gecko commitea a ciegas cuando nadie le devuelve
+                # frame callbacks (§6.10.nonies), y eso lo decide el COMPOSITOR: sin su log, «no
+                # pinta» sólo se puede mirar desde el cliente, que es la mitad que ya sabemos miente.
+                if a.wayland_debug:
+                    # ⚠ EN `WAYLAND_DEBUG` LA FLECHA ES DEL CLIENTE. `-> obj.metodo()` es un PEDIDO
+                    # que manda el navegador; los EVENTOS del compositor son las líneas SIN flecha.
+                    # Filtrar por `->` es leer otra vez al cliente. Y los nombres llevan `@id` en el
+                    # medio, así que `wl_surface.commit` no engancha: hay que anclar en el método.
+                    # QUIÉN ELIGE EL TAMAÑO: `attach` no lo dice —el tamaño del buffer está en
+                    # `create_buffer(…, W, H, stride, fmt)`— y lo que el cliente DECLARA querer está
+                    # en `set_window_geometry`. Puestos en orden y en los DOS sentidos se ve quién
+                    # propone (contestado en el §6.10.duodecies: lo propone el cliente).
+                    print("---- protocolo: el tamaño, en orden (→ = pedido del cliente) ----")
+                    # ⚠ El filtro EXCLUYE `mode(0,`: QEMU anuncia ~60 modos no-actuales y con ellos
+                    # dentro el `head` cortaba ANTES del `set_window_geometry` — la lista que existe
+                    # para ordenar dos cosas se quedaba sin una de las dos.
+                    print(ser.cmd("grep -a -n -E 'create_buffer|set_window_geometry|configure|ack_configure|"
+                                  "get_toplevel|set_min_size|set_max_size|set_title|set_app_id|\\.attach|"
+                                  "wl_output|\\.mode\\(|\\.geometry\\(|\\.done\\(\\)' "
+                                  "%s | grep -av 'mode(0,' | head -80" % log_atuq, 240))
+                    print("---- protocolo: EVENTOS del compositor (sin flecha) ----")
+                    print(ser.cmd("grep -av ' -> ' %s | "
+                                  "grep -a -E 'xdg_surface|xdg_toplevel|wl_callback|wl_output|wl_surface' | "
+                                  "tail -40" % log_atuq, 180))
             print("---- cosmic-comp: superficies, salidas y foco ----")
             print(ser.cmd("grep -a -i -E 'output|workspace|focus|toplevel|surface|xdg|map' "
                           "/var/log/cosmic/cosmic-session.log | tail -40", 120))
-            print("---- cosmic-comp: últimas líneas ----")
-            print(ser.cmd("tail -30 /var/log/cosmic/cosmic-session.log", 60))
             print("---- tamaño de los logs ----")
-            print(ser.cmd("wc -l /var/log/cosmic/moz.moz_log /var/log/cosmic/atuq.log "
+            print(ser.cmd("wc -l /var/log/cosmic/atuq-*.log /var/log/cosmic/moz-*.moz_log "
                           "/var/log/cosmic/cosmic-session.log /var/log/cosmic/cosmic-start.log", 30))
     finally:
         try:
diff --git a/scripts/vigia-imagen.py b/scripts/vigia-imagen.py
index cef8c4f4..b43bcc3e 100644
--- a/scripts/vigia-imagen.py
+++ b/scripts/vigia-imagen.py
@@ -286,18 +286,44 @@ def informe_primera_pintura():
               and "dos DRM" in (c.get("video") or "")
               and c.get("segundos_primera_pintura") is None]
     corridas = [c for c in corridas if c not in ciegas]
+    # ⚠ Y SE SEPARAN LAS CORRIDAS CON EL PERFIL ENVENENADO. Medido el 2026-09-15 (§6.10.terdecies):
+    # con `toolkit.startup.recent_crashes` por encima de `max_resumed_crashes` —3 por defecto— el
+    # navegador NO abre una ventana de navegador: abre el diálogo de Modo de resolución de problemas,
+    # que es modal y mide 117×70. El ✗ de esas corridas es cierto y no dice NADA del producto. Y el
+    # contador lo subía este mismo andamiaje al matar la VM con el navegador vivo, así que la serie
+    # vieja está contaminada: las corridas anteriores a que el arnés anotara el campo NO SE PUEDEN
+    # clasificar, y eso se dice en vez de contarlas como si fueran buenas.
+    def envenenada(c):
+        fijado = c.get("recent_crashes_fijado")          # lo que PUSO el arnés (user.js gana)
+        valor = fijado if fijado is not None else c.get("recent_crashes_antes")
+        return valor is not None and valor > 3
+
+    envenenadas = [c for c in corridas if envenenada(c)]
+    corridas = [c for c in corridas if c not in envenenadas]
+    sin_clasificar = [c for c in corridas if "recent_crashes_antes" not in c
+                      and c.get("recent_crashes_fijado") is None]
+
     pintaron = [c["segundos_primera_pintura"] for c in corridas
                 if c.get("segundos_primera_pintura") is not None]
     n, m = len(pintaron), len(corridas)
     marca = "✓" if n == m else ("✗" if n == 0 else "⚠")
     if ciegas:
         marca = "⚠"      # con corridas ciegas de por medio, un 6/6 no es un ✓: es un «lo que se pudo medir»
+    if sin_clasificar:
+        marca = "⚠"      # una tasa con corridas de procedencia desconocida adentro no es una tasa
     if pintaron:
         print("   %s pintaron %d de %d corridas CONCLUYENTES · primera pintura %s"
               % (marca, n, m,
                  ("+%ds" % pintaron[0]) if n == 1 else "+%d…+%ds" % (min(pintaron), max(pintaron))))
     else:
         print("   %s NINGUNA de %d corridas pintó" % (marca, m))
+    if envenenadas:
+        print("   ⊘ %d corridas APARTE: el perfil tenía recent_crashes > 3 ⇒ lo que abrió fue el "
+              "diálogo de Modo de resolución de problemas, no el navegador (SDD 26 §6.10.terdecies)"
+              % len(envenenadas))
+    if sin_clasificar:
+        print("   ⚠ %d de esas %d corridas son de ANTES de que se anotara el contador de caídas: "
+              "no se puede decir si midieron el navegador o el diálogo" % (len(sin_clasificar), m))
     for c in corridas[-6:]:
         seg = c.get("segundos_primera_pintura")
         print("     %s %-9s %-10s %-22s %-26s %s"