tawasuyu

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 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 7c794568a963b18b68d747959e6bbd07cf2ebff7 scripts/cosmic/atuq-en-imagen.py Python · 765 líneas · 49.7 KB Historia Quién tocó qué crudo

arnés de la imagen: el piso del sello se COMPRUEBA — un piso que falla callado no existe db24d206d · Sergio ·

1#!/usr/bin/env python3
2"""atuq-en-imagen.py — arranca la IMAGEN DE DISCO con COSMIC y le pide al navegador que pinte.
3
4    scripts/cosmic/atuq-en-imagen.py                # lanza atuq y fotografía la pantalla
5    scripts/cosmic/atuq-en-imagen.py --only-cosmic  # control: NO lanza atuq (línea de base)
6
7── POR QUÉ ────────────────────────────────────────────────────────────────────────────────────
8El §6.10.ter del SDD 26 dejó esto medido y sin explicar: en la imagen arrancada `atuq` corre —sus
9extensiones sondean, WebRender inicializa— y la ventana NO APARECE, mientras que bajo `sway` headless
10el mismo artefacto pinta. `scripts/cosmic/atuq-en-cosmic.sh` ya sacó una variable de encima:
11**con cosmic-comp NESTEADO (backend winit, GL por software) el navegador pinta**, o sea que no es el
12compositor por sí mismo. Lo que queda es el arranque de verdad: kms/DRM sobre virtio-gpu, el PID1 de
13arje, el seat. Eso sólo se mide acá adentro.
14
15La corrida anterior fue A MANO por el serial y no dejó ni un log que se pueda releer. Este guion
16hace lo mismo pero REPETIBLE y con la evidencia guardada: maneja el serial, lanza el navegador con
17`MOZ_LOG` del widget de Wayland, pide capturas por QMP y saca los logs por el serial.
18
19── CÓMO HABLA CON LA VM ───────────────────────────────────────────────────────────────────────
20`console-getty` corre `cosmic-start`, que tras su ventana de observación (3 min) hace `exec /bin/sh`
21CON EL ENTORNO DE LA SESIÓN puesto (WAYLAND_DISPLAY, DBUS_SESSION_BUS_ADDRESS). O sea que el shell
22del serial es exactamente desde donde un usuario lanzaría una app — y por eso no hace falta
23reconstruir el entorno a mano, que es como se inventan diferencias que después se confunden con la
24causa.
25"""
26import argparse, json, os, re, shutil, socket, subprocess, sys, time
27
28# Sin esto la salida sale por tandas cuando se redirige a un fichero, y una corrida de 20 minutos se
29# lee como si estuviera colgada.
30sys.stdout.reconfigure(line_buffering=True)
31
32RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
33IMG = os.environ.get("IMG", "/mnt/cosecha/takana-cosmic-qemu.img")
34OVMF_CODE = os.environ.get("OVMF_CODE", "/usr/share/edk2/x64/OVMF_CODE.4m.fd")
35OVMF_VARS = os.environ.get("OVMF_VARS", "/usr/share/edk2/x64/OVMF_VARS.4m.fd")
36MEM = os.environ.get("MEM", "2560")
37SMP = os.environ.get("SMP", "2")
38
39# ⚠ `VGA=1` reproduce el arranque del §6.10.ter, y la diferencia NO es cosmética: sin `-vga none`,
40# QEMU agrega una VGA estándar ADEMÁS del virtio-gpu, OVMF pinta su GOP ahí y el kernel levanta
41# `simpledrm` encima ⇒ el guest ve DOS dispositivos DRM. En el serial se lee tal cual:
42#
43#     == cosmic :: kernel 6.16.12 · drm: card0 card1 renderD128    (corrida del §6.10.ter)
44#     == cosmic :: kernel 6.16.12 · drm: card0 renderD128          (con -vga none)
45#
46# Un compositor con dos tarjetas puede componer en la que el `screendump` NO muestra. Por eso el
47# default acá es UNA sola (-vga none) y la reproducción del caso original es una opción explícita:
48# son dos mediciones distintas y hay que poder pedir cada una por su nombre.
49# Las dos pantallas van con ID EXPLÍCITO (`vga0`, `gpu0`) y no por el `-vga std` implícito, porque
50# `screendump` de QMP fotografía UN dispositivo y hay que poder pedir cada uno por su nombre: con dos
51# salidas, el compositor puede poner la ventana en la que la captura por defecto NO muestra, y eso se
52# ve exactamente igual que «la ventana no aparece».
53# Segundos desde el lanzamiento del navegador antes de los cuales NO se puede matar la VM sin
54# envenenar el perfil: el sello del arranque (`last_success` + borrado de `recent_crashes`) aparece
55# en disco entre +100 s y +137 s (medido 2026-09-15, `work/cuanto-1.txt`). 180 deja margen sobre la
56# cota alta; es un piso del INSTRUMENTO y no una medida del producto.
57# ⚠ EL PISO DEL SELLO, Y EL NÚMERO QUE LO JUSTIFICA — si esto se lee como elegido, el próximo que
58# lo vea lo baja. Gecko da por bueno un arranque un rato DESPUÉS DEL LANZAMIENTO (no de la primera
59# pintura) y ahí escribe `toolkit.startup.last_success` y limpia `recent_crashes`. Medido el
60# 2026-09-15, leyendo el perfil en cada captura, y acotado por dos corridas independientes:
61#
62#     el sello NO estaba a los +100 s  (la corrida S1 vivió 100 s desde el lanzamiento y no selló)
63#     el sello YA estaba a los +137 s  (la corrida `cuanto-1`: last_success pasó a su propia hora)
64#
65# ⇒ el hito cae entre +100 y +137 s, y el 180 es margen sobre la cota alta. Y la consecuencia es
66# contraintuitiva: **cuanto más rápido pinta, MÁS fácil es envenenar el perfil**, porque
67# `--until-paint` corta antes. Una serie sin este piso se rompe sola a la cuarta corrida.
68PISO_SELLO = int(os.environ.get("PISO_SELLO", "180"))
69
70DOS_PANTALLAS = os.environ.get("VGA") == "1"
71VGA_ARGS = (["-vga", "none", "-device", "VGA,id=vga0"] if DOS_PANTALLAS else ["-vga", "none"])
72PANTALLAS = ["gpu0", "vga0"] if DOS_PANTALLAS else ["gpu0"]
73
74
75class Serial:
76    """El serial de la VM. Todo lo que entra queda en el .log crudo, pase lo que pase."""
77
78    def __init__(self, ruta, log):
79        self.log = open(log, "ab", buffering=0)
80        for _ in range(120):
81            try:
82                self.s = socket.socket(socket.AF_UNIX); self.s.connect(ruta); break
83            except OSError:
84                time.sleep(0.5)
85        else:
86            raise SystemExit(f"no pude conectarme al serial {ruta}")
87        self.s.settimeout(1.0)
88        self.buf = b""
89
90    def leer(self, segundos):
91        fin = time.time() + segundos
92        while time.time() < fin:
93            try:
94                d = self.s.recv(65536)
95            except socket.timeout:
96                continue
97            if not d:
98                break
99            self.buf += d; self.log.write(d)
100        return self.buf
101
102    def esperar(self, marca, segundos, nombre=""):
103        """Lee hasta ver `marca`. Devuelve lo leído desde la llamada anterior."""
104        vistos = len(self.buf)
105        fin = time.time() + segundos
106        while time.time() < fin:
107            try:
108                d = self.s.recv(65536)
109            except socket.timeout:
110                continue
111            if d:
112                self.buf += d; self.log.write(d)
113            if marca.encode() in self.buf[vistos:]:
114                return self.buf[vistos:].decode("utf-8", "replace")
115        print(f"   ⚠ nunca llegó «{marca}» {nombre} en {segundos}s", flush=True)
116        return self.buf[vistos:].decode("utf-8", "replace")
117
118    def cmd(self, orden, segundos=30, fondo=False):
119        """Manda una orden y devuelve su salida (delimitada por una marca propia).
120
121        ⚠ DOS TRAMPAS MEDIDAS, las dos hacen que la orden NO CORRA y el guion siga como si nada:
122
123        · **el terminal hace ECO de lo que se le escribe**, así que una marca escrita literal en la
124          línea aparece en el serial ANTES de que el shell ejecute nada: el `esperar` la ve en el eco
125          y vuelve al instante, y las órdenes se pisan unas a otras. Por eso la marca se ARMA EN EL
126          GUEST partida en dos (`'FIN''-123'`): el eco muestra las comillas y sólo la salida trae la
127          marca entera.
128        · **`cmd & ; echo …` es un error de sintaxis** en ash: pegarle un `;` a un `&` no lanza nada
129          y el shell contesta «syntax error». Lo de fondo va en su propia línea (`fondo=True`).
130        """
131        n = int(time.time() * 1000) % 100000
132        marca = f"FIN-{n}"
133        if fondo:
134            self.s.sendall(f"{orden}\n".encode()); time.sleep(0.5)
135            self.s.sendall(f"echo 'FIN''-{n}'\n".encode())
136        else:
137            self.s.sendall(f"{orden}; echo 'FIN''-{n}'\n".encode())
138        return self.esperar(marca, segundos, nombre=f"de «{orden[:40]}»")
139
140
141def capturar(ruta_qmp, out, nombre):
142    """Fotografía CADA pantalla del guest y devuelve {pantalla: fichero}.
143
144    Con dos salidas hay que mirar las dos: la ventana puede estar viva y dibujando en la que no se
145    fotografía, y eso es indistinguible de «no pinta» si sólo se mira una.
146    """
147    ficheros = {p: os.path.join(out, f"{nombre}-{p}.ppm") for p in PANTALLAS}
148    qmp(ruta_qmp, [{"execute": "screendump", "arguments": {"filename": f, "device": p}}
149                   for p, f in ficheros.items()])
150    return ficheros
151
152
153def pintado(ppm):
154    """¿Cuántos píxeles de la pantalla son la página de prueba?
155
156    Se cuenta el magenta con TOLERANCIA, no por color exacto, y la razón está medida: a los ~+590 s
157    sin una sola entrada `cosmic-idle` ATENÚA la pantalla y los 703 766 px de `(255,0,255)` pasan a
158    703 779 px de `(117,0,117)` —la misma ventana al 46 % de brillo—. Un contador por color exacto
159    lee eso como «desapareció la ventana», que es justo el error que este guion existe para no
160    cometer.
161    """
162    try:
163        from PIL import Image
164    except Exception:
165        return None
166    im = Image.open(ppm).convert("RGB")
167    return sum(n for n, (r, g, b) in im.getcolors(1 << 24) if r > 90 and b > 90 and g < 40)
168
169
170def leer_contador_de_caidas(texto):
171    """Saca el valor de `toolkit.startup.recent_crashes` de un volcado de `prefs.js`.
172
173    Devuelve `None` si el pref NO ESTÁ, que no es lo mismo que un 0 puesto a mano: «no está» es el
174    estado de un perfil cuyo arranque terminó bien —y es también lo que deja Gecko DESPUÉS de
175    mostrar el diálogo de Modo de resolución de problemas, que es la razón por la que la corrida
176    siguiente «se arregla sola» y la anterior parece intermitente.
177
178    Filtra por `user_pref` a propósito: el serial hace eco de la propia orden `grep`, y esa línea
179    contiene el nombre del pref sin su valor.
180    """
181    for linea in texto.splitlines():
182        if "user_pref" in linea and "toolkit.startup.recent_crashes" in linea:
183            m = re.search(r"recent_crashes\"?\s*,\s*(-?\d+)", linea)
184            if m:
185                return int(m.group(1))
186    return None
187
188
189def leer_sello_de_exito(texto):
190    """Saca `toolkit.startup.last_success` de un volcado de `prefs.js`.
191
192    Es la OTRA MITAD del cálculo del contador de caídas: el contador sube mientras este sello no se
193    mueve, así que un contador que no cambió sólo deja de ser ambiguo —«no subió» contra «subió y se
194    limpió»— si se lee junto con el sello. Medido el 2026-09-15: pasó un día entero sin moverse.
195    """
196    for linea in texto.splitlines():
197        if "user_pref" in linea and "toolkit.startup.last_success" in linea:
198            m = re.search(r"last_success\"?\s*,\s*(\d+)", linea)
199            if m:
200                return int(m.group(1))
201    return None
202
203
204def anotar_medicion(segundos, a, pantalla=None, extra=None):
205    """Deja el tiempo de primera pintura en `docs/state/primera-pintura.json`.
206
207    Va al estado del repo y no a un log porque quien pregunta «¿esta imagen se puede usar?» es
208    `scripts/vigia-imagen.py`, que mira artefactos y no arranca ninguna VM: el número tiene que
209    llegarle por escrito o no le llega. Se guarda TODO lo que lo hace comparable —cómo se lanzó, con
210    qué vídeo, con cuánta RAM y cuántos cores— porque el tiempo depende de eso y de la carga de la
211    máquina anfitriona; un número sin sus condiciones invita a leerlo como si fuera del producto.
212    """
213    dato = {
214        "medido": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
215        "imagen": IMG,
216        "segundos_primera_pintura": segundos,          # None = no pintó dentro de la ventana
217        "pantalla": pantalla,                          # EN CUÁL de las salidas apareció (medido 2026-09-14:
218                                                       # con dos, la ventana cae en cualquiera de las dos)
219        "pantallas_fotografiadas": len(PANTALLAS),     # con 1 y dos salidas, un ✗ NO es concluyente
220        "presupuesto_s": a.budget,
221        "ventana_s": a.wait,
222        "cadencia_s": a.cadence,                       # la RESOLUCIÓN del número: ±cadencia
223        "lanzamiento": "perfil del usuario" if a.as_user else "perfil nuevo en tmpfs",
224        "video": "vga+virtio-gpu (dos DRM)" if DOS_PANTALLAS else "virtio-gpu solo (-vga none)",
225        "mem_mb": int(MEM), "vcpus": int(SMP), "acel": "tcg",
226        "guion": "scripts/cosmic/atuq-en-imagen.py",
227    }
228    # ⚠ EL CONTADOR DE CAÍDAS DEL PERFIL VIAJA CON LA MEDICIÓN, y es lo que hace legible la serie:
229    # con `toolkit.startup.recent_crashes` por encima de 3 el sujeto de la corrida NO es el
230    # navegador sino el diálogo de Modo de resolución de problemas (§6.10.terdecies), así que un ✗
231    # de esas corridas no dice nada del producto. Sin este campo, una tasa cierta se lee como si
232    # fuera del navegador — que es exactamente lo que pasó con «3 de 13» y «2 de 10».
233    dato.update(extra or {})
234    # ⚠ SE ACUMULAN, NO SE PISAN. Medido el 2026-09-14: dos corridas IDÉNTICAS (mismo comando,
235    # misma imagen) dieron «+204 s» y «no pintó en 900 s». Un fichero de UNA medición convierte un
236    # fenómeno intermitente en el número que le tocó a la última corrida, que es la forma más cara
237    # de mentir con datos ciertos.
238    destino = os.path.join(RAIZ, "docs/state/primera-pintura.json")
239    try:
240        historia = {"corridas": []}
241        if os.path.exists(destino):
242            with open(destino) as f:
243                historia = json.load(f)
244        historia.setdefault("corridas", []).append(dato)
245        historia["corridas"] = historia["corridas"][-30:]
246        os.makedirs(os.path.dirname(destino), exist_ok=True)
247        with open(destino, "w") as f:
248            json.dump(historia, f, indent=2, ensure_ascii=False)
249            f.write("\n")
250        pintaron = [c for c in historia["corridas"] if c.get("segundos_primera_pintura") is not None]
251        print("   anotada en docs/state/primera-pintura.json — %d de %d corridas pintaron"
252              % (len(pintaron), len(historia["corridas"])))
253    except (OSError, ValueError) as e:
254        print(f"   ⚠ no pude anotar la medición: {e}")
255
256
257def qmp(ruta, ordenes):
258    s = socket.socket(socket.AF_UNIX); s.settimeout(30); s.connect(ruta)
259    f = s.makefile("rwb", buffering=0)
260    f.readline()                                   # greeting
261    f.write(b'{"execute":"qmp_capabilities"}\n'); f.readline()
262    salidas = []
263    for o in ordenes:
264        f.write(json.dumps(o).encode() + b"\n"); salidas.append(f.readline())
265    s.close()
266    return salidas
267
268
269def main():
270    ap = argparse.ArgumentParser()
271    ap.add_argument("--only-cosmic", action="store_true", help="control: no lanza el navegador")
272    ap.add_argument("--as-user", action="store_true",
273                    help="lanza `atuq` a secas —perfil por defecto, sin MOZ_ENABLE_WAYLAND— como se hizo a mano en el §6.10.ter")
274    ap.add_argument("--wait", type=int, default=600, help="segundos de observación como máximo")
275    ap.add_argument("--cadence", type=int, default=30, help="segundos entre capturas (la resolución de la medida)")
276    ap.add_argument("--budget", type=int, default=420,
277                    help="segundos que puede tardar la primera pintura antes de dar ✗")
278    ap.add_argument("--until-paint", action="store_true",
279                    help="cortar la observación en cuanto la página aparezca (más barato para medir el tiempo)")
280    ap.add_argument("--wayland-debug", action="store_true",
281                    help="lanza el navegador con WAYLAND_DEBUG=1: registra CADA mensaje del "
282                         "protocolo en los dos sentidos. Es la única forma de ver qué MANDA el "
283                         "compositor sin recompilarlo (sus macros de debug están compiladas fuera)")
284    ap.add_argument("--set-rust-log", metavar="VALOR",
285                    help="escribe RUST_LOG en /etc/cosmic-mode de la IMAGEN y apaga; "
286                         "cadena vacía = lo quita. El compositor lo lee en el arranque SIGUIENTE, "
287                         "que es la única forma de subirle el nivel de log sin recompilar")
288    ap.add_argument("--crashes", type=int, metavar="N",
289                    help="fija `toolkit.startup.recent_crashes` del perfil del usuario antes de "
290                         "lanzar: 0 lo limpia, 4 o más reproduce el diálogo de Modo de resolución "
291                         "de problemas (medido: con 3 guardado todavía pinta, porque el arranque "
292                         "suma ANTES de comparar). Sólo tiene efecto con --as-user. ⚠ 0 es el valor "
293                         "por DEFECTO y un pref igual al default no se persiste: si lo que querés "
294                         "medir es quién limpia el contador, NO lo pinnees")
295    ap.add_argument("--xulstore", metavar="ANCHOxALTO",
296                    help="agrega una SEGUNDA fase en el mismo arranque con `xulstore.json` sembrado "
297                         "(p. ej. 1152x720): la ventana arranca con un tamaño YA PERSISTIDO. Es el "
298                         "control del 117x70 — si con semilla sale grande, lo que falla es que nadie "
299                         "le da tamaño en el primer arranque")
300    ap.add_argument("--watch-prefs", action="store_true",
301                    help="en CADA captura lee también `recent_crashes` y `last_success` del perfil y "
302                         "los imprime con el segundo. Es lo que mide CUÁNTO tarda un arranque en "
303                         "darse por bueno: el valor de `last_success` es la hora de ARRANQUE, no la "
304                         "de escritura, así que el momento en que cambia sólo se sabe mirándolo "
305                         "seguido. Ignora `--until-paint`: lo interesante pasa DESPUÉS de pintar")
306    ap.add_argument("--repeat", type=int, metavar="N",
307                    help="corre N fases IDÉNTICAS en el mismo arranque. Con `--wait` corto (60 s, o "
308                         "sea antes de que pinte) mide el contador de caídas: cada fase lee "
309                         "`recent_crashes` ANTES de lanzar, así que la serie 0,1,2… dice cuánto suma "
310                         "un arranque interrumpido — y si no sube, el envenenamiento vino de otro lado")
311    ap.add_argument("--out", default=os.path.join(RAIZ, "work/atuq-imagen"))
312    a = ap.parse_args()
313
314    rc = [0]                 # lista para poder tocarlo desde dentro del try/finally
315    out = a.out; os.makedirs(out, exist_ok=True)
316    vars_fd = os.path.join(out, "OVMF_VARS.fd")
317    shutil.copy(OVMF_VARS, vars_fd)
318    ser_sock, qmp_sock = os.path.join(out, "serial.sock"), os.path.join(out, "qmp.sock")
319    for p in (ser_sock, qmp_sock):
320        if os.path.exists(p):
321            os.unlink(p)
322
323    qemu = [
324        "nice", "-n", "10", "qemu-system-x86_64",
325        "-machine", "q35", "-accel", "tcg", "-cpu", "max", "-m", MEM, "-smp", SMP,
326        *VGA_ARGS, "-device", "virtio-gpu-pci,id=gpu0", "-display", "none", "-nic", "none",
327        "-drive", f"if=pflash,format=raw,unit=0,file={OVMF_CODE},readonly=on",
328        "-drive", f"if=pflash,format=raw,unit=1,file={vars_fd}",
329        "-drive", f"file={IMG},format=raw,if=virtio",
330        "-serial", f"unix:{ser_sock},server,nowait",
331        "-qmp", f"unix:{qmp_sock},server,nowait",
332        "-no-reboot",
333    ]
334    print("== arrancando la imagen (TCG, sin KVM — paciencia)")
335
336    def primero_en_la_fila():
337        # El repo es COMPARTIDO y casi siempre hay una tanda de la granja compilando en esta misma
338        # máquina (4 cores, 7,6 G). Si la RAM se acaba, el que tiene que morir es ESTA VM y no el
339        # build de otro agente: `oom_score_adj` alto es la única forma de decirlo por adelantado.
340        with open("/proc/self/oom_score_adj", "w") as f:
341            f.write("900")
342
343    vm = subprocess.Popen(qemu, stdout=open(os.path.join(out, "qemu.log"), "wb"),
344                          stderr=subprocess.STDOUT, preexec_fn=primero_en_la_fila)
345    # ⚠ QEMU TOMA UN LOCK DE ESCRITURA SOBRE LA IMAGEN, y sin esta comprobación el arnés espera
346    # 600 s una marca del serial que no va a llegar NUNCA: el socket queda creado, el `connect()`
347    # funciona y no falla nada — el error es UNA línea en `qemu.log` que nadie mira. Medido el
348    # 2026-09-15 al lanzar el control negativo con la corrida anterior todavía viva:
349    #     Failed to get "write" lock. Is another process using the image [...]?
350    # Las corridas sobre la misma imagen van EN SERIE; esto lo dice en el primer segundo.
351    time.sleep(2)
352    if vm.poll() is not None:
353        raise SystemExit("✗ QEMU no arrancó:\n   " +
354                         open(os.path.join(out, "qemu.log")).read().strip())
355
356    try:
357        ser = Serial(ser_sock, os.path.join(out, "serial.log"))
358        print("== esperando a COSMIC…", flush=True)
359        ser.esperar("compositor OK", 600, nombre="(arranque de cosmic-comp)")
360        print("   compositor arriba; esperando el shell del serial (ventana de observación)", flush=True)
361        # 420 s alcanzaban con el anfitrión ocioso y NO con carga ~13: la VM llegaba a «+15s» y el
362        # arnés se rendía antes de que `cosmic-start` soltara el shell. El margen es de espera, no de
363        # medida: subirlo no cambia ningún número, sólo deja de tirar corridas por carga ajena.
364        ser.esperar("fin de la ventana de observación", 780, nombre="(fin de observación)")
365        time.sleep(3)
366        ser.s.sendall(b"\n")
367        print(ser.cmd("echo ENTORNO: WD=$WAYLAND_DISPLAY XDG=$XDG_RUNTIME_DIR DBUS=$DBUS_SESSION_BUS_ADDRESS").strip())
368        print(ser.cmd("ls -l $XDG_RUNTIME_DIR; ls /usr/lib/dri").strip())
369
370        if a.set_rust_log is not None:
371            # `cosmic-start` hace `. /etc/cosmic-mode` ANTES de `export RUST_LOG="${RUST_LOG:-info}"`,
372            # así que el fichero es la perilla. Se escribe y se apaga: quien la usa es el arranque
373            # siguiente — el compositor de ESTA sesión ya arrancó y no se lo puede reconfigurar.
374            ser.cmd("grep -v '^RUST_LOG=' /etc/cosmic-mode > /tmp/cm 2>/dev/null; true")
375            if a.set_rust_log:
376                ser.cmd("printf '%%s\\n' \"RUST_LOG=%s\" >> /tmp/cm" % a.set_rust_log)
377            ser.cmd("cp /tmp/cm /etc/cosmic-mode && sync")
378            print("== /etc/cosmic-mode ahora dice:")
379            print(ser.cmd("cat /etc/cosmic-mode").strip())
380            return
381
382        capturar(qmp_sock, out, "antes")
383        print("== captura ANTES (%s)" % ", ".join(PANTALLAS), flush=True)
384
385        if a.only_cosmic:
386            print("== CONTROL: no se lanza el navegador")
387        else:
388            # Una página de un color imposible de confundir con el escritorio.
389            ser.cmd("mkdir -p /var/log/cosmic")
390            # La página además DICE, por `dump()`, qué tamaño cree que tiene la ventana y qué
391            # pantalla ve el navegador. Con `screen=` y `outer=` en el log se sabe si el navegador
392            # ve la pantalla o no — y desde el §6.10.terdecies sabemos que SÍ la ve, así que lo que
393            # queda por contar es el otro lado: qué tamaño cree tener la VENTANA. Sin comillas
394            # simples: va dentro de un `printf '%s' '...'`.
395            ser.cmd("printf '%s' '<!doctype html><body style=\"margin:0;background:#ff00ff\">"
396                    "<h1 style=\"font:40px monospace\">ATUQ PINTA</h1><script>"
397                    "function diag(t){dump(\"ATUQ-DIAG \"+t+\" screen=\"+screen.width+\"x\"+screen.height"
398                    "+\" avail=\"+screen.availWidth+\"x\"+screen.availHeight"
399                    "+\" outer=\"+outerWidth+\"x\"+outerHeight"
400                    "+\" inner=\"+innerWidth+\"x\"+innerHeight"
401                    "+\" dpr=\"+devicePixelRatio+\"\\n\");}"
402                    "addEventListener(\"load\",function(){diag(\"load\");"
403                    "setTimeout(function(){diag(\"t10\");},10000);"
404                    "setTimeout(function(){diag(\"t60\");},60000);});"
405                    "</script>' > /tmp/pagina.html")
406
407            # ⚠ LO QUE DEJÓ LA CORRIDA ANTERIOR, ANTES DE PISARLO. El disco de esta VM es
408            # PERSISTENTE (`-drive` sin `snapshot=on`) ⇒ `/var/log/cosmic/` todavía tiene el
409            # arranque anterior, y el lanzamiento de esta corrida lo trunca. Ahí es donde salen los
410            # errores de JS del chrome, y NUNCA se habían mirado: se leía `tail -25`, que con
411            # `WAYLAND_DEBUG` son 25 líneas de protocolo y nada más. Copiarlo primero cuesta nada.
412            ser.cmd("rm -rf /var/log/cosmic-anterior; cp -a /var/log/cosmic /var/log/cosmic-anterior "
413                    "2>/dev/null; true", 120)
414            print("---- corrida ANTERIOR: cómo terminó y qué se quejó ----")
415            print(ser.cmd("grep -a -h -E 'ATUQ-EXIT|JavaScript error|Uncaught|NS_ERROR|"
416                          "###!!!|\\[Parent\\].*ERROR' /var/log/cosmic-anterior/atuq*.log "
417                          "2>/dev/null | head -20", 120))
418            # DÓNDE VIVE EL PERFIL, SIN ADIVINARLO. El `find` anterior miraba sólo `/root` y esta
419            # imagen lo desmintió en silencio: dijo «no encontré el perfil» y la sonda salió muda.
420            # `$HOME` lo decide todo y el shell del serial lo hereda de `cosmic-start`, así que se
421            # IMPRIME; y el `find` va sobre la raíz entera con `-xdev` (que deja fuera /proc y /sys).
422            print("== HOME del shell de la sesión y perfiles que ya existen en el disco:")
423            print(ser.cmd("echo HOME=$HOME; find / -xdev -maxdepth 7 -name prefs.js 2>/dev/null | head -5", 180))
424
425            def perfil_de_la_fase(semilla):
426                """Deja el perfil listo para la fase y devuelve su directorio (o None).
427
428                Las dos formas de lanzar NO son el mismo experimento y por eso conviven: el modo por
429                defecto usa un perfil NUEVO en tmpfs —primer arranque de verdad, que es el caso que
430                sale 117×70— y `--as-user` reproduce lo que se tecleó a mano en el §6.10.ter, con el
431                perfil del usuario y sin variables de entorno.
432                """
433                if a.as_user:
434                    hallado = ser.cmd("find / -xdev -maxdepth 7 -name prefs.js 2>/dev/null | head -3", 180)
435                    perfil = next((l.strip() for l in hallado.splitlines()
436                                   if l.strip().endswith("prefs.js") and l.strip().startswith("/")), None)
437                    if not perfil:
438                        print("   ⚠ no encontré el perfil del usuario: la sonda ATUQ-DIAG va a salir muda")
439                        return None
440                    d = os.path.dirname(perfil)
441                    print("== perfil del usuario:", d)
442                    # ⚠⚠ EL CONTADOR DE CAÍDAS DEL PERFIL, SIEMPRE A LA VISTA. Con
443                    # `toolkit.startup.recent_crashes` por encima de `max_resumed_crashes` (3 por
444                    # defecto) el navegador NO abre el navegador: abre el diálogo de Modo de
445                    # resolución de problemas. Y este arnés mata la VM con el navegador vivo, así
446                    # que CADA corrida fallida puede sumar uno: el instrumento degrada el sujeto y
447                    # la tasa medida deja de ser del producto. Sin este número impreso, una corrida
448                    # no se puede interpretar.
449                    # ⚠ `last_success` VA EN EL MISMO VOLCADO. Es la otra mitad del cálculo: sin
450                    # él, un `recent_crashes` que no se movió es ambiguo entre «no subió» y «subió y
451                    # lo limpió el gancho de cierre», que son conclusiones opuestas sobre la misma
452                    # cifra.
453                    volcado = ser.cmd("grep -E 'toolkit.startup.(recent_crashes|max_resumed|"
454                                      "last_success)|browser.sessionstore.max_resumed' %s/prefs.js" % d, 60)
455                    print(volcado.strip())
456                    estado["recent_crashes_antes"] = leer_contador_de_caidas(volcado)
457                    estado["last_success_antes"] = leer_sello_de_exito(volcado)
458                    # ⚠ EL AVISO QUE FALTABA. Medido el 2026-09-15: el arranque que encuentra el
459                    # anterior sin terminar SUMA UNO y compara DESPUÉS, así que con 3 guardado esta
460                    # corrida abre el diálogo de Modo de resolución de problemas y no el navegador —
461                    # y como el arnés mata la VM antes del gancho de cierre, CADA corrida suma.
462                    # Una serie se rompe sola a la cuarta; por eso se dice acá y no en el post-mortem.
463                    previo = estado["recent_crashes_antes"]
464                    if previo is not None and previo >= 3 and a.crashes is None:
465                        print("   ⚠⚠ contador en %d: ESTA corrida va a abrir el DIÁLOGO, no el "
466                              "navegador. Para medir la pintura, pasá --crashes 0" % previo)
467                else:
468                    d = "/tmp/perfil"
469                    ser.cmd("rm -rf /tmp/perfil && mkdir -p /tmp/perfil")
470                ujs = ['user_pref("browser.dom.window.dump.enabled", true);',
471                       'user_pref("browser.shell.checkDefaultBrowser", false);']
472                if a.crashes is not None:
473                    # El mismo mando en los DOS sentidos: 0 limpia el contador (¿pinta?) y un
474                    # número alto lo reproduce a propósito (¿se rompe?). `user.js` gana sobre
475                    # `prefs.js` en cada arranque, así que fija el valor de la corrida.
476                    ujs.append('user_pref("toolkit.startup.recent_crashes", %d);' % a.crashes)
477                    print("== recent_crashes FIJADO a %d para esta corrida" % a.crashes)
478                ser.cmd("rm -f %s/user.js" % d)
479                for linea in ujs:
480                    ser.cmd("printf '%%s\\n' '%s' >> %s/user.js" % (linea, d))
481                if semilla:
482                    # EL CONTROL. `browser-init.js` —el que trae el propio artefacto, leído del
483                    # `omni.ja` sellado— fija el tamaño de arranque UNA sola vez:
484                    #
485                    #     } else if (!document.documentElement.hasAttribute("width")) {
486                    #       let width  = Math.min(screen.availWidth  * 0.9, 1280);
487                    #       let height = Math.min(screen.availHeight * 0.9, 1040);
488                    #
489                    # y ese atributo, en un perfil ya usado, lo rellena el `xulstore.json`. Sembrarlo
490                    # a mano separa las dos explicaciones posibles del 117×70 en UNA corrida: si con
491                    # semilla la ventana sale grande, el fallo es «nadie le dio tamaño» (ese `if` no
492                    # corrió); si sale igual de chica, el tamaño no es lo que falla.
493                    w, h = semilla.lower().split("x")
494                    doc = {"chrome://browser/content/browser.xhtml":
495                           {"main-window": {"screenX": "0", "screenY": "0",
496                                            "width": w, "height": h, "sizemode": "normal"}}}
497                    ser.cmd("printf '%%s' '%s' > %s/xulstore.json" % (json.dumps(doc), d))
498                elif not a.as_user:
499                    # Sólo en el perfil DESECHABLE de tmpfs. ⚠ En `--as-user` el `xulstore.json` es
500                    # el estado del usuario —el tamaño que la ventana RECUERDA— y borrarlo cambiaría
501                    # el experimento sin decirlo: la corrida siguiente ya no sería «como lo abre el
502                    # usuario» sino un primer arranque disfrazado.
503                    ser.cmd("rm -f %s/xulstore.json" % d)
504                ser.cmd("sync")
505                print("== el perfil, ANTES de lanzar (user.js y el tamaño que RECUERDA):")
506                print(ser.cmd("cat %s/user.js; echo ---; cat %s/xulstore.json 2>/dev/null || "
507                              "echo '(sin xulstore.json: la ventana no recuerda ningún tamaño)'"
508                              % (d, d), 60).strip())
509                return d
510
511            def matar_al_navegador():
512                """Deja la sesión sin ningún `atuq` vivo antes de la fase siguiente.
513
514                Por PID leído de `/proc/*/comm`, no por `pkill -f atuq`: el patrón engancha la propia
515                línea de orden del shell del serial y ahí se mata la sesión entera (medido en el hub,
516                sale 144 y lo que sigue no corre).
517                """
518                # ⚠ `-9` A PROPÓSITO. Un `kill` a secas le da a Gecko su apagado ordenado, y un
519                # apagado ordenado es EXACTAMENTE la variable bajo prueba: lo que hace este arnés al
520                # terminar es matar la VM entera, que no avisa. Con `-KILL` la fase siguiente
521                # encuentra el mismo estado que encontraría tras una corrida matada de verdad.
522                ser.cmd("for p in /proc/[0-9]*; do grep -q atuq $p/comm 2>/dev/null && "
523                        "kill -9 ${p#/proc/}; done; true", 60)
524                time.sleep(8)
525                salida = ser.cmd("for c in /proc/[0-9]*/comm; do cat $c 2>/dev/null; done | grep -c atuq")
526                # El serial hace ECO de la orden, así que la respuesta es la ÚLTIMA línea que sea un
527                # número y no la primera que aparezca: leer la primera devuelve un trozo del eco.
528                cuenta = next((l.strip() for l in reversed(salida.splitlines()) if l.strip().isdigit()), "?")
529                print("   navegadores vivos tras el kill: " + cuenta)
530
531            # LAS FASES. Una sola es la medición de siempre; con `--xulstore` se agrega una SEGUNDA
532            # en el MISMO arranque, que es un A/B de verdad: mismo compositor, misma sesión, misma
533            # carga del anfitrión. Dos arranques distintos no lo serían — y cada arranque cuesta
534            # cinco minutos de TCG.
535            # El estado del perfil que hay que ADJUNTAR a la medición: el contador de caídas antes
536            # y después. Vive acá afuera porque lo escribe la preparación de la fase y lo lee la
537            # anotación, y porque con `--crashes` fijado hay que poder distinguir el valor que
538            # PUSIMOS del que el perfil traía.
539            estado = {"recent_crashes_fijado": a.crashes}
540
541            fases = [("base", None)]
542            if a.xulstore:
543                fases.append(("semilla-%s" % a.xulstore, a.xulstore))
544            if a.repeat:
545                # N fases iguales: lo que varía entre ellas no es la configuración sino el ESTADO que
546                # deja la anterior en el perfil, que es justo lo que se quiere medir.
547                fases = [("k%d" % i, None) for i in range(1, a.repeat + 1)]
548
549            for fase, semilla in fases:
550                print("\n===== FASE «%s» =====" % fase, flush=True)
551                if fase != fases[0][0]:
552                    matar_al_navegador()
553                perfil_d = perfil_de_la_fase(semilla)
554                log_atuq = "/var/log/cosmic/atuq-%s.log" % fase
555                log_moz = "/var/log/cosmic/moz-%s" % fase
556                capturar(qmp_sock, out, "%s-antes" % fase)
557                print("== captura ANTES de la fase (%s)" % ", ".join(PANTALLAS), flush=True)
558
559                wd = "WAYLAND_DEBUG=1 " if a.wayland_debug else ""
560                moz = ("MOZ_LOG='Widget:5,WidgetWayland:5,WaylandBackend:5,WidgetScreen:5,timestamp' "
561                       "MOZ_LOG_FILE=%s " % log_moz)
562                if a.as_user:
563                    orden = wd + moz + "/usr/bin/atuq file:///tmp/pagina.html"
564                else:
565                    orden = (wd + "MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland " + moz +
566                             "/usr/bin/atuq --no-remote --profile /tmp/perfil file:///tmp/pagina.html")
567                # ⚠ EL NAVEGADOR SE MURIÓ Y NADIE LO SUPO (medido 2026-09-15, 17:12): el shell
568                # imprimió «[1]+ Done» a los ~25 s y la corrida siguió 420 s fotografiando un
569                # escritorio sin navegador, para terminar diciendo «no pintó». Un proceso que sale
570                # deja un CÓDIGO, y ese código es la diferencia entre «se cayó» y «se fue solo»; va
571                # al log, en el subshell, para que quede escrito aunque el arnés muera después.
572                print(ser.cmd("( %s; echo ATUQ-EXIT=$? ) > %s 2>&1 &" % (orden, log_atuq), fondo=True).strip())
573                print(f"== navegador lanzado; observando {a.wait}s", flush=True)
574                t0 = time.time(); n = 0
575                primera = None       # segundos hasta la PRIMERA captura con la página en pantalla
576                donde = None
577                while time.time() - t0 < a.wait:
578                    time.sleep(a.cadence); n += 1
579                    tomas = capturar(qmp_sock, out, f"{fase}-durante-{n}")
580                    t = int(time.time() - t0)
581                    medidas = {p: pintado(f) for p, f in tomas.items()}
582                    px = max((v for v in medidas.values() if v is not None), default=None)
583                    donde = next((p for p, v in medidas.items() if v and v > 100_000), None)
584                    vigilancia = ""
585                    if a.watch_prefs and perfil_d:
586                        # Una sola lectura, corta, por captura. `tr` junta las dos líneas para que
587                        # entren en el renglón del avance: lo que importa es CUÁNDO cambian.
588                        vigilancia = "   prefs: " + " ".join(
589                            l.strip() for l in ser.cmd(
590                                "grep -hE 'toolkit.startup.(recent_crashes|last_success)' "
591                                "%s/prefs.js | tr -d '\"' | tr '\\n' ' '" % perfil_d, 60).splitlines()
592                            if "toolkit.startup" in l and "grep" not in l)
593                    if primera is None and px and px > 100_000:
594                        primera = t
595                        print(f"   +{t}s  ██ PRIMERA PINTURA — {px} px de la página EN {donde}{vigilancia}", flush=True)
596                        # ⚠ NO SE CORTA ANTES DEL SELLO, Y ESTO ES MEDIDO (2026-09-15). Gecko
597                        # marca el arranque como bueno —escribe `toolkit.startup.last_success` y
598                        # BORRA `recent_crashes`— y eso llega al disco entre los +100 s y los +137 s
599                        # desde el lanzamiento (`work/cuanto-1.txt`, leyendo el perfil en cada
600                        # captura). El reloj es el del ARRANQUE, no el de la pintura: `cierre-1`
601                        # pintó a +120 s y selló, y la S1 de la serie pintó a +97 s, se cortó con
602                        # `--until-paint` y dejó el contador en 1. Cortar en la pintura mata la VM
603                        # antes del sello, y CADA corrida así suma uno: cuatro y el perfil abre el
604                        # diálogo en vez del navegador. El piso es la diferencia entre medir el
605                        # producto y medir el daño que hace el instrumento.
606                        if a.until_paint and not a.watch_prefs and t >= PISO_SELLO:
607                            break
608                        if a.until_paint and t < PISO_SELLO:
609                            print(f"   (sigo hasta +{PISO_SELLO}s: antes de eso el arranque no está "
610                                  f"sellado y matar la VM envenena el perfil)", flush=True)
611                    else:
612                        vivos = ser.cmd("for c in /proc/[0-9]*/comm; do cat $c 2>/dev/null; done | grep -c atuq")
613                        v = vivos.splitlines()[1].strip() if len(vivos.splitlines()) > 1 else "?"
614                        detalle = " ".join(f"{p}={medidas[p]}" for p in PANTALLAS)
615                        print(f"   +{t}s  px de la página: {detalle}   procesos atuq: {v}{vigilancia}", flush=True)
616
617                capturar(qmp_sock, out, "%s-despues" % fase)
618                print("== captura DESPUES de la fase", flush=True)
619
620                # EL NÚMERO. No «pinta / no pinta»: CUÁNTO TARDA. Medido el 2026-09-14 sobre esta
621                # misma imagen, la primera pintura cae entre +137 s y +204 s con el perfil por
622                # defecto y ~+134 s con uno nuevo en tmpfs — y con la máquina anfitriona cargada pasa
623                # de los +300 s. Por eso el presupuesto es HOLGADO y se dice el número siempre: un
624                # guardián que sólo contestara «no pintó» a los 5 min habría firmado el error del
625                # §6.10.ter.
626                if primera is None:
627                    print(f"✗ el navegador NO pintó en {a.wait}s — mirá {out}/{fase}-despues.png y el MOZ_LOG:")
628                    print("  `mapped 1` + «marked as visible & has buffer» + WaylandBufferSHM ⇒ estaba pintando"
629                          " y la captura llegó antes; sin eso, es un fallo de verdad")
630                    rc[0] = 1
631                else:
632                    print(f"✓ primera pintura: +{primera}s   (presupuesto {a.budget}s)")
633                    if primera > a.budget:
634                        print(f"✗ tardó {primera - a.budget}s MÁS que el presupuesto")
635                        rc[0] = 1
636
637                if perfil_d and a.as_user:
638                    # ⚠ EL ESTADO FINAL ES PARTE DE LA PRUEBA: el número cambia DENTRO de la
639                    # corrida —lo escribe el ARRANQUE al encontrar el anterior sin terminar, no el
640                    # kill— así que sin el «después» no se puede encadenar una corrida con la
641                    # siguiente, que es lo que convierte esto en una serie legible (medido:
642                    # 2 → 3 → 4 en tres fases de un mismo arranque). Se lee con el navegador
643                    # todavía vivo, o sea que refleja el último volcado de prefs de Gecko.
644                    # ⚠ `last_success` VA EN LOS DOS VOLCADOS. Es la otra mitad del cálculo, y
645                    # sin él en el «después» una limpieza no dice si el arranque además SELLÓ el
646                    # éxito: son dos preguntas que se contestan con el mismo grep y una salía muda.
647                    volcado = ser.cmd("grep -E 'toolkit.startup.(recent_crashes|last_success)' %s/prefs.js || "
648                                      "echo '(el pref no está: arranque sin caídas pendientes)'"
649                                      % perfil_d, 60)
650                    estado["recent_crashes_despues"] = leer_contador_de_caidas(volcado)
651                    estado["last_success_despues"] = leer_sello_de_exito(volcado)
652                    # ⚠ EL PISO SE COMPRUEBA, NO SE CONFÍA. `PISO_SELLO` son 180 s medidos EN ESTA
653                    # máquina (TCG sin KVM, con la carga que tenga el anfitrión): con el anfitrión
654                    # cargado el mismo arranque puede no llegar al gancho, y entonces esta corrida
655                    # SUMA al contador y envenena la siguiente — en silencio, que es como pasó la
656                    # primera vez. Con el sello leído antes y después, eso se puede DECIR.
657                    if estado["last_success_despues"] is not None and \
658                       estado["last_success_despues"] == estado.get("last_success_antes"):
659                        print("   ⚠⚠ el sello NO se movió (%s): este arranque no terminó ⇒ la corrida "
660                              "SUMA al contador de caídas. Si usaste --until-paint, subí PISO_SELLO"
661                              % estado["last_success_despues"])
662                    elif estado["last_success_despues"] is not None:
663                        print("   ✓ el arranque SELLÓ el éxito (last_success %s → %s): esta corrida "
664                              "no envenena la siguiente"
665                              % (estado.get("last_success_antes"), estado["last_success_despues"]))
666                    print("---- el contador de caídas, DESPUÉS ----")
667                    print(volcado.strip())
668                # ⚠ SÓLO LA FASE BASE SE ANOTA. La serie de `primera-pintura.json` es una TASA del
669                # producto tal como se entrega; una fase con el tamaño de ventana sembrado a mano es
670                # otro experimento, y mezclarlos haría que el denominador dejara de significar nada.
671                if semilla is None:
672                    anotar_medicion(primera, a, donde if primera is not None else None, estado)
673                else:
674                    print("   (fase con semilla: NO se anota en la serie de la tasa)")
675
676                # La evidencia que no se puede leer de una captura: cómo terminó el proceso, qué se
677                # quejó el chrome, y qué hizo el widget de Wayland.
678                # QUÉ QUEDÓ PERSISTIDO. `browser.xhtml` lleva `persist="… width height sizemode"`,
679                # así que el tamaño con el que terminó la ventana se ESCRIBE en el perfil y lo hereda
680                # la corrida siguiente: si una corrida sale de 117×70 y lo guarda, el fallo se
681                # aprende. Sin este volcado, «intermitente» y «contagiado» se ven igual.
682                if perfil_d:
683                    print("---- el perfil, DESPUÉS: qué tamaño quedó recordado ----")
684                    print(ser.cmd("cat %s/xulstore.json 2>/dev/null || echo '(no escribió xulstore.json)'"
685                                  % perfil_d, 60))
686                print("---- cómo terminó, y de qué se quejó el chrome ----")
687                print(ser.cmd("grep -a -E 'ATUQ-EXIT|JavaScript error|Uncaught|NS_ERROR|###!!!' "
688                              "%s | head -25" % log_atuq, 120))
689                print("---- atuq.log (cabeza) ----")
690                print(ser.cmd("head -20 %s" % log_atuq, 60))
691                print("---- atuq.log (cola) ----")
692                print(ser.cmd("tail -20 %s" % log_atuq, 60))
693                print("---- MOZ_LOG: creación de la ventana ----")
694                print(ser.cmd("grep -a -m 40 -E 'nsWindow::Create|CreateNative|Map|configure|Show|frame callback|Buffer' "
695                              "%s.moz_log | head -40" % log_moz, 120))
696                # QUÉ PANTALLA VE GECKO — contestado el 2026-09-15 y por eso se queda: la sonda que
697                # ya contestó es la que avisa cuando la respuesta cambia. `New monitor 0 size
698                # [0,0 -> 1280 x 800]` ANTES de `Initial resize to 1 x 1` ⇒ la geometría de la salida
699                # NO es lo que falta.
700                print("---- MOZ_LOG: pantallas que ve Gecko y tamaños del nsWindow ----")
701                print(ser.cmd("grep -a -E 'WidgetScreen|Screen\\[|AddScreen|nsWindow::Resize|Initial resize|"
702                              "SetSizeMode|OnConfigure|SizeAllocate|LockAspect' "
703                              "%s.moz_log | head -50" % log_moz, 180))
704                # Y lo que dice el propio navegador, desde la página, por `dump()`: `screen=` es lo
705                # que el contenido ve de la pantalla y `outer=` el tamaño de la ventana. Dos fuentes
706                # independientes para el mismo número; si se contradicen, eso también es un dato.
707                print("---- la página: ATUQ-DIAG ----")
708                print(ser.cmd("grep -a ATUQ-DIAG %s | head -10" % log_atuq, 120))
709                print("---- MOZ_LOG: últimas líneas ----")
710                print(ser.cmd("tail -25 %s.moz_log" % log_moz, 60))
711                # El otro lado de la conversación. Gecko commitea a ciegas cuando nadie le devuelve
712                # frame callbacks (§6.10.nonies), y eso lo decide el COMPOSITOR: sin su log, «no
713                # pinta» sólo se puede mirar desde el cliente, que es la mitad que ya sabemos miente.
714                if a.wayland_debug:
715                    # ⚠ EN `WAYLAND_DEBUG` LA FLECHA ES DEL CLIENTE. `-> obj.metodo()` es un PEDIDO
716                    # que manda el navegador; los EVENTOS del compositor son las líneas SIN flecha.
717                    # Filtrar por `->` es leer otra vez al cliente. Y los nombres llevan `@id` en el
718                    # medio, así que `wl_surface.commit` no engancha: hay que anclar en el método.
719                    # QUIÉN ELIGE EL TAMAÑO: `attach` no lo dice —el tamaño del buffer está en
720                    # `create_buffer(…, W, H, stride, fmt)`— y lo que el cliente DECLARA querer está
721                    # en `set_window_geometry`. Puestos en orden y en los DOS sentidos se ve quién
722                    # propone (contestado en el §6.10.duodecies: lo propone el cliente).
723                    print("---- protocolo: el tamaño, en orden (→ = pedido del cliente) ----")
724                    # ⚠ El filtro EXCLUYE `mode(0,`: QEMU anuncia ~60 modos no-actuales y con ellos
725                    # dentro el `head` cortaba ANTES del `set_window_geometry` — la lista que existe
726                    # para ordenar dos cosas se quedaba sin una de las dos.
727                    print(ser.cmd("grep -a -n -E 'create_buffer|set_window_geometry|configure|ack_configure|"
728                                  "get_toplevel|set_min_size|set_max_size|set_title|set_app_id|\\.attach|"
729                                  "wl_output|\\.mode\\(|\\.geometry\\(|\\.done\\(\\)' "
730                                  "%s | grep -av 'mode(0,' | head -80" % log_atuq, 240))
731                    print("---- protocolo: EVENTOS del compositor (sin flecha) ----")
732                    print(ser.cmd("grep -av ' -> ' %s | "
733                                  "grep -a -E 'xdg_surface|xdg_toplevel|wl_callback|wl_output|wl_surface' | "
734                                  "tail -40" % log_atuq, 180))
735            print("---- cosmic-comp: superficies, salidas y foco ----")
736            print(ser.cmd("grep -a -i -E 'output|workspace|focus|toplevel|surface|xdg|map' "
737                          "/var/log/cosmic/cosmic-session.log | tail -40", 120))
738            print("---- tamaño de los logs ----")
739            print(ser.cmd("wc -l /var/log/cosmic/atuq-*.log /var/log/cosmic/moz-*.moz_log "
740                          "/var/log/cosmic/cosmic-session.log /var/log/cosmic/cosmic-start.log", 30))
741    finally:
742        try:
743            qmp(qmp_sock, [{"execute": "quit"}])
744        except Exception:
745            pass
746        time.sleep(2); vm.terminate()
747        try:
748            vm.wait(20)
749        except Exception:
750            vm.kill()
751
752    # Las capturas salen en PPM (lo único que sabe QMP); a PNG para poder mirarlas.
753    try:
754        from PIL import Image
755        for f in sorted(os.listdir(out)):
756            if f.endswith(".ppm"):
757                Image.open(os.path.join(out, f)).save(os.path.join(out, f[:-4] + ".png"))
758        print("== capturas convertidas a PNG en", out)
759    except Exception as e:
760        print("   ⚠ sin conversión a PNG:", e)
761    return rc[0]
762
763
764if __name__ == "__main__":
765    sys.exit(main())