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.
This commit is contained in:
Sergio
2026-09-15 17:46:30 +00:00
parent 70107b1685
commit 2f90d8eb40
2 changed files with 350 additions and 109 deletions
+324 -109
View File
@@ -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:
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)
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
capturar(qmp_sock, out, "despues")
print("== captura DESPUES", flush=True)
def matar_al_navegador():
"""Deja la sesión sin ningún `atuq` vivo antes de la fase siguiente.
# 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")
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:
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
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
# 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)
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: 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))
# 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:
+26
View File
@@ -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"