Files
takana/scripts/cosmic/atuq-en-imagen.py
T
Sergio db24d206dc arnés de la imagen: el piso del sello se COMPRUEBA — un piso que falla callado no existe
Los 180 s de `PISO_SELLO` son de esta máquina (TCG sin KVM y con la carga que tenga el anfitrión,
igual que los +97/+120/+137 de la pintura). Con el anfitrión cargado el mismo arranque puede no
llegar al gancho que sella el éxito, y entonces la corrida suma al contador de caídas y envenena la
siguiente EN SILENCIO — que es exactamente como empezó todo esto.

Ahora que `toolkit.startup.last_success` se lee antes y después, eso se puede decir, así que se dice:

  ✓ el arranque SELLÓ el éxito (last_success X → Y): esta corrida no envenena la siguiente
  ⚠⚠ el sello NO se movió: este arranque no terminó ⇒ la corrida SUMA al contador. Subí PISO_SELLO

Es la diferencia entre un piso confiado y un piso comprobado, y cuesta cuatro líneas porque el dato
ya estaba medido y guardado.
2026-09-15 19:24:50 +00:00

766 lines
50 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""atuq-en-imagen.py — arranca la IMAGEN DE DISCO con COSMIC y le pide al navegador que pinte.
scripts/cosmic/atuq-en-imagen.py # lanza atuq y fotografía la pantalla
scripts/cosmic/atuq-en-imagen.py --only-cosmic # control: NO lanza atuq (línea de base)
── POR QUÉ ────────────────────────────────────────────────────────────────────────────────────
El §6.10.ter del SDD 26 dejó esto medido y sin explicar: en la imagen arrancada `atuq` corre —sus
extensiones sondean, WebRender inicializa— y la ventana NO APARECE, mientras que bajo `sway` headless
el mismo artefacto pinta. `scripts/cosmic/atuq-en-cosmic.sh` ya sacó una variable de encima:
**con cosmic-comp NESTEADO (backend winit, GL por software) el navegador pinta**, o sea que no es el
compositor por sí mismo. Lo que queda es el arranque de verdad: kms/DRM sobre virtio-gpu, el PID1 de
arje, el seat. Eso sólo se mide acá adentro.
La corrida anterior fue A MANO por el serial y no dejó ni un log que se pueda releer. Este guion
hace lo mismo pero REPETIBLE y con la evidencia guardada: maneja el serial, lanza el navegador con
`MOZ_LOG` del widget de Wayland, pide capturas por QMP y saca los logs por el serial.
── CÓMO HABLA CON LA VM ───────────────────────────────────────────────────────────────────────
`console-getty` corre `cosmic-start`, que tras su ventana de observación (3 min) hace `exec /bin/sh`
CON EL ENTORNO DE LA SESIÓN puesto (WAYLAND_DISPLAY, DBUS_SESSION_BUS_ADDRESS). O sea que el shell
del serial es exactamente desde donde un usuario lanzaría una app — y por eso no hace falta
reconstruir el entorno a mano, que es como se inventan diferencias que después se confunden con la
causa.
"""
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.
sys.stdout.reconfigure(line_buffering=True)
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
IMG = os.environ.get("IMG", "/mnt/cosecha/takana-cosmic-qemu.img")
OVMF_CODE = os.environ.get("OVMF_CODE", "/usr/share/edk2/x64/OVMF_CODE.4m.fd")
OVMF_VARS = os.environ.get("OVMF_VARS", "/usr/share/edk2/x64/OVMF_VARS.4m.fd")
MEM = os.environ.get("MEM", "2560")
SMP = os.environ.get("SMP", "2")
# ⚠ `VGA=1` reproduce el arranque del §6.10.ter, y la diferencia NO es cosmética: sin `-vga none`,
# QEMU agrega una VGA estándar ADEMÁS del virtio-gpu, OVMF pinta su GOP ahí y el kernel levanta
# `simpledrm` encima ⇒ el guest ve DOS dispositivos DRM. En el serial se lee tal cual:
#
# == cosmic :: kernel 6.16.12 · drm: card0 card1 renderD128 (corrida del §6.10.ter)
# == cosmic :: kernel 6.16.12 · drm: card0 renderD128 (con -vga none)
#
# Un compositor con dos tarjetas puede componer en la que el `screendump` NO muestra. Por eso el
# default acá es UNA sola (-vga none) y la reproducción del caso original es una opción explícita:
# son dos mediciones distintas y hay que poder pedir cada una por su nombre.
# Las dos pantallas van con ID EXPLÍCITO (`vga0`, `gpu0`) y no por el `-vga std` implícito, porque
# `screendump` de QMP fotografía UN dispositivo y hay que poder pedir cada uno por su nombre: con dos
# salidas, el compositor puede poner la ventana en la que la captura por defecto NO muestra, y eso se
# ve exactamente igual que «la ventana no aparece».
# Segundos desde el lanzamiento del navegador antes de los cuales NO se puede matar la VM sin
# envenenar el perfil: el sello del arranque (`last_success` + borrado de `recent_crashes`) aparece
# en disco entre +100 s y +137 s (medido 2026-09-15, `work/cuanto-1.txt`). 180 deja margen sobre la
# cota alta; es un piso del INSTRUMENTO y no una medida del producto.
# ⚠ EL PISO DEL SELLO, Y EL NÚMERO QUE LO JUSTIFICA — si esto se lee como elegido, el próximo que
# lo vea lo baja. Gecko da por bueno un arranque un rato DESPUÉS DEL LANZAMIENTO (no de la primera
# pintura) y ahí escribe `toolkit.startup.last_success` y limpia `recent_crashes`. Medido el
# 2026-09-15, leyendo el perfil en cada captura, y acotado por dos corridas independientes:
#
# el sello NO estaba a los +100 s (la corrida S1 vivió 100 s desde el lanzamiento y no selló)
# el sello YA estaba a los +137 s (la corrida `cuanto-1`: last_success pasó a su propia hora)
#
# ⇒ el hito cae entre +100 y +137 s, y el 180 es margen sobre la cota alta. Y la consecuencia es
# contraintuitiva: **cuanto más rápido pinta, MÁS fácil es envenenar el perfil**, porque
# `--until-paint` corta antes. Una serie sin este piso se rompe sola a la cuarta corrida.
PISO_SELLO = int(os.environ.get("PISO_SELLO", "180"))
DOS_PANTALLAS = os.environ.get("VGA") == "1"
VGA_ARGS = (["-vga", "none", "-device", "VGA,id=vga0"] if DOS_PANTALLAS else ["-vga", "none"])
PANTALLAS = ["gpu0", "vga0"] if DOS_PANTALLAS else ["gpu0"]
class Serial:
"""El serial de la VM. Todo lo que entra queda en el .log crudo, pase lo que pase."""
def __init__(self, ruta, log):
self.log = open(log, "ab", buffering=0)
for _ in range(120):
try:
self.s = socket.socket(socket.AF_UNIX); self.s.connect(ruta); break
except OSError:
time.sleep(0.5)
else:
raise SystemExit(f"no pude conectarme al serial {ruta}")
self.s.settimeout(1.0)
self.buf = b""
def leer(self, segundos):
fin = time.time() + segundos
while time.time() < fin:
try:
d = self.s.recv(65536)
except socket.timeout:
continue
if not d:
break
self.buf += d; self.log.write(d)
return self.buf
def esperar(self, marca, segundos, nombre=""):
"""Lee hasta ver `marca`. Devuelve lo leído desde la llamada anterior."""
vistos = len(self.buf)
fin = time.time() + segundos
while time.time() < fin:
try:
d = self.s.recv(65536)
except socket.timeout:
continue
if d:
self.buf += d; self.log.write(d)
if marca.encode() in self.buf[vistos:]:
return self.buf[vistos:].decode("utf-8", "replace")
print(f" ⚠ nunca llegó «{marca}» {nombre} en {segundos}s", flush=True)
return self.buf[vistos:].decode("utf-8", "replace")
def cmd(self, orden, segundos=30, fondo=False):
"""Manda una orden y devuelve su salida (delimitada por una marca propia).
⚠ DOS TRAMPAS MEDIDAS, las dos hacen que la orden NO CORRA y el guion siga como si nada:
· **el terminal hace ECO de lo que se le escribe**, así que una marca escrita literal en la
línea aparece en el serial ANTES de que el shell ejecute nada: el `esperar` la ve en el eco
y vuelve al instante, y las órdenes se pisan unas a otras. Por eso la marca se ARMA EN EL
GUEST partida en dos (`'FIN''-123'`): el eco muestra las comillas y sólo la salida trae la
marca entera.
· **`cmd & ; echo …` es un error de sintaxis** en ash: pegarle un `;` a un `&` no lanza nada
y el shell contesta «syntax error». Lo de fondo va en su propia línea (`fondo=True`).
"""
n = int(time.time() * 1000) % 100000
marca = f"FIN-{n}"
if fondo:
self.s.sendall(f"{orden}\n".encode()); time.sleep(0.5)
self.s.sendall(f"echo 'FIN''-{n}'\n".encode())
else:
self.s.sendall(f"{orden}; echo 'FIN''-{n}'\n".encode())
return self.esperar(marca, segundos, nombre=f"de «{orden[:40]}»")
def capturar(ruta_qmp, out, nombre):
"""Fotografía CADA pantalla del guest y devuelve {pantalla: fichero}.
Con dos salidas hay que mirar las dos: la ventana puede estar viva y dibujando en la que no se
fotografía, y eso es indistinguible de «no pinta» si sólo se mira una.
"""
ficheros = {p: os.path.join(out, f"{nombre}-{p}.ppm") for p in PANTALLAS}
qmp(ruta_qmp, [{"execute": "screendump", "arguments": {"filename": f, "device": p}}
for p, f in ficheros.items()])
return ficheros
def pintado(ppm):
"""¿Cuántos píxeles de la pantalla son la página de prueba?
Se cuenta el magenta con TOLERANCIA, no por color exacto, y la razón está medida: a los ~+590 s
sin una sola entrada `cosmic-idle` ATENÚA la pantalla y los 703 766 px de `(255,0,255)` pasan a
703 779 px de `(117,0,117)` —la misma ventana al 46 % de brillo—. Un contador por color exacto
lee eso como «desapareció la ventana», que es justo el error que este guion existe para no
cometer.
"""
try:
from PIL import Image
except Exception:
return None
im = Image.open(ppm).convert("RGB")
return sum(n for n, (r, g, b) in im.getcolors(1 << 24) if r > 90 and b > 90 and g < 40)
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 leer_sello_de_exito(texto):
"""Saca `toolkit.startup.last_success` de un volcado de `prefs.js`.
Es la OTRA MITAD del cálculo del contador de caídas: el contador sube mientras este sello no se
mueve, así que un contador que no cambió sólo deja de ser ambiguo —«no subió» contra «subió y se
limpió»— si se lee junto con el sello. Medido el 2026-09-15: pasó un día entero sin moverse.
"""
for linea in texto.splitlines():
if "user_pref" in linea and "toolkit.startup.last_success" in linea:
m = re.search(r"last_success\"?\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
`scripts/vigia-imagen.py`, que mira artefactos y no arranca ninguna VM: el número tiene que
llegarle por escrito o no le llega. Se guarda TODO lo que lo hace comparable —cómo se lanzó, con
qué vídeo, con cuánta RAM y cuántos cores— porque el tiempo depende de eso y de la carga de la
máquina anfitriona; un número sin sus condiciones invita a leerlo como si fuera del producto.
"""
dato = {
"medido": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"imagen": IMG,
"segundos_primera_pintura": segundos, # None = no pintó dentro de la ventana
"pantalla": pantalla, # EN CUÁL de las salidas apareció (medido 2026-09-14:
# con dos, la ventana cae en cualquiera de las dos)
"pantallas_fotografiadas": len(PANTALLAS), # con 1 y dos salidas, un ✗ NO es concluyente
"presupuesto_s": a.budget,
"ventana_s": a.wait,
"cadencia_s": a.cadence, # la RESOLUCIÓN del número: ±cadencia
"lanzamiento": "perfil del usuario" if a.as_user else "perfil nuevo en tmpfs",
"video": "vga+virtio-gpu (dos DRM)" if DOS_PANTALLAS else "virtio-gpu solo (-vga 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
# de mentir con datos ciertos.
destino = os.path.join(RAIZ, "docs/state/primera-pintura.json")
try:
historia = {"corridas": []}
if os.path.exists(destino):
with open(destino) as f:
historia = json.load(f)
historia.setdefault("corridas", []).append(dato)
historia["corridas"] = historia["corridas"][-30:]
os.makedirs(os.path.dirname(destino), exist_ok=True)
with open(destino, "w") as f:
json.dump(historia, f, indent=2, ensure_ascii=False)
f.write("\n")
pintaron = [c for c in historia["corridas"] if c.get("segundos_primera_pintura") is not None]
print(" anotada en docs/state/primera-pintura.json — %d de %d corridas pintaron"
% (len(pintaron), len(historia["corridas"])))
except (OSError, ValueError) as e:
print(f" ⚠ no pude anotar la medición: {e}")
def qmp(ruta, ordenes):
s = socket.socket(socket.AF_UNIX); s.settimeout(30); s.connect(ruta)
f = s.makefile("rwb", buffering=0)
f.readline() # greeting
f.write(b'{"execute":"qmp_capabilities"}\n'); f.readline()
salidas = []
for o in ordenes:
f.write(json.dumps(o).encode() + b"\n"); salidas.append(f.readline())
s.close()
return salidas
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only-cosmic", action="store_true", help="control: no lanza el navegador")
ap.add_argument("--as-user", action="store_true",
help="lanza `atuq` a secas —perfil por defecto, sin MOZ_ENABLE_WAYLAND— como se hizo a mano en el §6.10.ter")
ap.add_argument("--wait", type=int, default=600, help="segundos de observación como máximo")
ap.add_argument("--cadence", type=int, default=30, help="segundos entre capturas (la resolución de la medida)")
ap.add_argument("--budget", type=int, default=420,
help="segundos que puede tardar la primera pintura antes de dar ✗")
ap.add_argument("--until-paint", action="store_true",
help="cortar la observación en cuanto la página aparezca (más barato para medir el tiempo)")
ap.add_argument("--wayland-debug", action="store_true",
help="lanza el navegador con WAYLAND_DEBUG=1: registra CADA mensaje del "
"protocolo en los dos sentidos. Es la única forma de ver qué MANDA el "
"compositor sin recompilarlo (sus macros de debug están compiladas fuera)")
ap.add_argument("--set-rust-log", metavar="VALOR",
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, 4 o más reproduce el diálogo de Modo de resolución "
"de problemas (medido: con 3 guardado todavía pinta, porque el arranque "
"suma ANTES de comparar). Sólo tiene efecto con --as-user. ⚠ 0 es el valor "
"por DEFECTO y un pref igual al default no se persiste: si lo que querés "
"medir es quién limpia el contador, NO lo pinnees")
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("--watch-prefs", action="store_true",
help="en CADA captura lee también `recent_crashes` y `last_success` del perfil y "
"los imprime con el segundo. Es lo que mide CUÁNTO tarda un arranque en "
"darse por bueno: el valor de `last_success` es la hora de ARRANQUE, no la "
"de escritura, así que el momento en que cambia sólo se sabe mirándolo "
"seguido. Ignora `--until-paint`: lo interesante pasa DESPUÉS de pintar")
ap.add_argument("--repeat", type=int, metavar="N",
help="corre N fases IDÉNTICAS en el mismo arranque. Con `--wait` corto (60 s, o "
"sea antes de que pinte) mide el contador de caídas: cada fase lee "
"`recent_crashes` ANTES de lanzar, así que la serie 0,1,2… dice cuánto suma "
"un arranque interrumpido — y si no sube, el envenenamiento vino de otro lado")
ap.add_argument("--out", default=os.path.join(RAIZ, "work/atuq-imagen"))
a = ap.parse_args()
rc = [0] # lista para poder tocarlo desde dentro del try/finally
out = a.out; os.makedirs(out, exist_ok=True)
vars_fd = os.path.join(out, "OVMF_VARS.fd")
shutil.copy(OVMF_VARS, vars_fd)
ser_sock, qmp_sock = os.path.join(out, "serial.sock"), os.path.join(out, "qmp.sock")
for p in (ser_sock, qmp_sock):
if os.path.exists(p):
os.unlink(p)
qemu = [
"nice", "-n", "10", "qemu-system-x86_64",
"-machine", "q35", "-accel", "tcg", "-cpu", "max", "-m", MEM, "-smp", SMP,
*VGA_ARGS, "-device", "virtio-gpu-pci,id=gpu0", "-display", "none", "-nic", "none",
"-drive", f"if=pflash,format=raw,unit=0,file={OVMF_CODE},readonly=on",
"-drive", f"if=pflash,format=raw,unit=1,file={vars_fd}",
"-drive", f"file={IMG},format=raw,if=virtio",
"-serial", f"unix:{ser_sock},server,nowait",
"-qmp", f"unix:{qmp_sock},server,nowait",
"-no-reboot",
]
print("== arrancando la imagen (TCG, sin KVM — paciencia)")
def primero_en_la_fila():
# El repo es COMPARTIDO y casi siempre hay una tanda de la granja compilando en esta misma
# máquina (4 cores, 7,6 G). Si la RAM se acaba, el que tiene que morir es ESTA VM y no el
# build de otro agente: `oom_score_adj` alto es la única forma de decirlo por adelantado.
with open("/proc/self/oom_score_adj", "w") as f:
f.write("900")
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)
ser.esperar("compositor OK", 600, nombre="(arranque de cosmic-comp)")
print(" compositor arriba; esperando el shell del serial (ventana de observación)", flush=True)
# 420 s alcanzaban con el anfitrión ocioso y NO con carga ~13: la VM llegaba a «+15s» y el
# arnés se rendía antes de que `cosmic-start` soltara el shell. El margen es de espera, no de
# medida: subirlo no cambia ningún número, sólo deja de tirar corridas por carga ajena.
ser.esperar("fin de la ventana de observación", 780, nombre="(fin de observación)")
time.sleep(3)
ser.s.sendall(b"\n")
print(ser.cmd("echo ENTORNO: WD=$WAYLAND_DISPLAY XDG=$XDG_RUNTIME_DIR DBUS=$DBUS_SESSION_BUS_ADDRESS").strip())
print(ser.cmd("ls -l $XDG_RUNTIME_DIR; ls /usr/lib/dri").strip())
if a.set_rust_log is not None:
# `cosmic-start` hace `. /etc/cosmic-mode` ANTES de `export RUST_LOG="${RUST_LOG:-info}"`,
# así que el fichero es la perilla. Se escribe y se apaga: quien la usa es el arranque
# siguiente — el compositor de ESTA sesión ya arrancó y no se lo puede reconfigurar.
ser.cmd("grep -v '^RUST_LOG=' /etc/cosmic-mode > /tmp/cm 2>/dev/null; true")
if a.set_rust_log:
ser.cmd("printf '%%s\\n' \"RUST_LOG=%s\" >> /tmp/cm" % a.set_rust_log)
ser.cmd("cp /tmp/cm /etc/cosmic-mode && sync")
print("== /etc/cosmic-mode ahora dice:")
print(ser.cmd("cat /etc/cosmic-mode").strip())
return
capturar(qmp_sock, out, "antes")
print("== captura ANTES (%s)" % ", ".join(PANTALLAS), flush=True)
if a.only_cosmic:
print("== CONTROL: no se lanza el navegador")
else:
# Una página de un color imposible de confundir con el escritorio.
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><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.
# ⚠ `last_success` VA EN EL MISMO VOLCADO. Es la otra mitad del cálculo: sin
# él, un `recent_crashes` que no se movió es ambiguo entre «no subió» y «subió y
# lo limpió el gancho de cierre», que son conclusiones opuestas sobre la misma
# cifra.
volcado = ser.cmd("grep -E 'toolkit.startup.(recent_crashes|max_resumed|"
"last_success)|browser.sessionstore.max_resumed' %s/prefs.js" % d, 60)
print(volcado.strip())
estado["recent_crashes_antes"] = leer_contador_de_caidas(volcado)
estado["last_success_antes"] = leer_sello_de_exito(volcado)
# ⚠ EL AVISO QUE FALTABA. Medido el 2026-09-15: el arranque que encuentra el
# anterior sin terminar SUMA UNO y compara DESPUÉS, así que con 3 guardado esta
# corrida abre el diálogo de Modo de resolución de problemas y no el navegador —
# y como el arnés mata la VM antes del gancho de cierre, CADA corrida suma.
# Una serie se rompe sola a la cuarta; por eso se dice acá y no en el post-mortem.
previo = estado["recent_crashes_antes"]
if previo is not None and previo >= 3 and a.crashes is None:
print(" ⚠⚠ contador en %d: ESTA corrida va a abrir el DIÁLOGO, no el "
"navegador. Para medir la pintura, pasá --crashes 0" % previo)
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).
"""
# ⚠ `-9` A PROPÓSITO. Un `kill` a secas le da a Gecko su apagado ordenado, y un
# apagado ordenado es EXACTAMENTE la variable bajo prueba: lo que hace este arnés al
# terminar es matar la VM entera, que no avisa. Con `-KILL` la fase siguiente
# encuentra el mismo estado que encontraría tras una corrida matada de verdad.
ser.cmd("for p in /proc/[0-9]*; do grep -q atuq $p/comm 2>/dev/null && "
"kill -9 ${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))
if a.repeat:
# N fases iguales: lo que varía entre ellas no es la configuración sino el ESTADO que
# deja la anterior en el perfil, que es justo lo que se quiere medir.
fases = [("k%d" % i, None) for i in range(1, a.repeat + 1)]
for fase, semilla in fases:
print("\n===== FASE «%s» =====" % fase, flush=True)
if fase != fases[0][0]:
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)
vigilancia = ""
if a.watch_prefs and perfil_d:
# Una sola lectura, corta, por captura. `tr` junta las dos líneas para que
# entren en el renglón del avance: lo que importa es CUÁNDO cambian.
vigilancia = " prefs: " + " ".join(
l.strip() for l in ser.cmd(
"grep -hE 'toolkit.startup.(recent_crashes|last_success)' "
"%s/prefs.js | tr -d '\"' | tr '\\n' ' '" % perfil_d, 60).splitlines()
if "toolkit.startup" in l and "grep" not in l)
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}{vigilancia}", flush=True)
# ⚠ NO SE CORTA ANTES DEL SELLO, Y ESTO ES MEDIDO (2026-09-15). Gecko
# marca el arranque como bueno —escribe `toolkit.startup.last_success` y
# BORRA `recent_crashes`— y eso llega al disco entre los +100 s y los +137 s
# desde el lanzamiento (`work/cuanto-1.txt`, leyendo el perfil en cada
# captura). El reloj es el del ARRANQUE, no el de la pintura: `cierre-1`
# pintó a +120 s y selló, y la S1 de la serie pintó a +97 s, se cortó con
# `--until-paint` y dejó el contador en 1. Cortar en la pintura mata la VM
# antes del sello, y CADA corrida así suma uno: cuatro y el perfil abre el
# diálogo en vez del navegador. El piso es la diferencia entre medir el
# producto y medir el daño que hace el instrumento.
if a.until_paint and not a.watch_prefs and t >= PISO_SELLO:
break
if a.until_paint and t < PISO_SELLO:
print(f" (sigo hasta +{PISO_SELLO}s: antes de eso el arranque no está "
f"sellado y matar la VM envenena el perfil)", flush=True)
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}{vigilancia}", 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
if perfil_d and a.as_user:
# ⚠ EL ESTADO FINAL ES PARTE DE LA PRUEBA: el número cambia DENTRO de la
# corrida —lo escribe el ARRANQUE al encontrar el anterior sin terminar, no el
# kill— así que sin el «después» no se puede encadenar una corrida con la
# siguiente, que es lo que convierte esto en una serie legible (medido:
# 2 → 3 → 4 en tres fases de un mismo arranque). Se lee con el navegador
# todavía vivo, o sea que refleja el último volcado de prefs de Gecko.
# ⚠ `last_success` VA EN LOS DOS VOLCADOS. Es la otra mitad del cálculo, y
# sin él en el «después» una limpieza no dice si el arranque además SELLÓ el
# éxito: son dos preguntas que se contestan con el mismo grep y una salía muda.
volcado = ser.cmd("grep -E 'toolkit.startup.(recent_crashes|last_success)' %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)
estado["last_success_despues"] = leer_sello_de_exito(volcado)
# ⚠ EL PISO SE COMPRUEBA, NO SE CONFÍA. `PISO_SELLO` son 180 s medidos EN ESTA
# máquina (TCG sin KVM, con la carga que tenga el anfitrión): con el anfitrión
# cargado el mismo arranque puede no llegar al gancho, y entonces esta corrida
# SUMA al contador y envenena la siguiente — en silencio, que es como pasó la
# primera vez. Con el sello leído antes y después, eso se puede DECIR.
if estado["last_success_despues"] is not None and \
estado["last_success_despues"] == estado.get("last_success_antes"):
print(" ⚠⚠ el sello NO se movió (%s): este arranque no terminó ⇒ la corrida "
"SUMA al contador de caídas. Si usaste --until-paint, subí PISO_SELLO"
% estado["last_success_despues"])
elif estado["last_success_despues"] is not None:
print(" ✓ el arranque SELLÓ el éxito (last_success %s%s): esta corrida "
"no envenena la siguiente"
% (estado.get("last_success_antes"), estado["last_success_despues"]))
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("---- tamaño de los logs ----")
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:
qmp(qmp_sock, [{"execute": "quit"}])
except Exception:
pass
time.sleep(2); vm.terminate()
try:
vm.wait(20)
except Exception:
vm.kill()
# Las capturas salen en PPM (lo único que sabe QMP); a PNG para poder mirarlas.
try:
from PIL import Image
for f in sorted(os.listdir(out)):
if f.endswith(".ppm"):
Image.open(os.path.join(out, f)).save(os.path.join(out, f[:-4] + ".png"))
print("== capturas convertidas a PNG en", out)
except Exception as e:
print(" ⚠ sin conversión a PNG:", e)
return rc[0]
if __name__ == "__main__":
sys.exit(main())