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…
⎇ f4e1e99f19ea2e8d10ecde7b7da44a05f01c8a4b
scripts/cosmic/atuq-en-imagen.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
atuq: la ventana SI esta — mide 117x70 px (lo dice el protocolo)
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, 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».
53DOS_PANTALLAS = os.environ.get("VGA") == "1"
54VGA_ARGS = (["-vga", "none", "-device", "VGA,id=vga0"] if DOS_PANTALLAS else ["-vga", "none"])
55PANTALLAS = ["gpu0", "vga0"] if DOS_PANTALLAS else ["gpu0"]
56
57
58class Serial:
59 """El serial de la VM. Todo lo que entra queda en el .log crudo, pase lo que pase."""
60
61 def __init__(self, ruta, log):
62 self.log = open(log, "ab", buffering=0)
63 for _ in range(120):
64 try:
65 self.s = socket.socket(socket.AF_UNIX); self.s.connect(ruta); break
66 except OSError:
67 time.sleep(0.5)
68 else:
69 raise SystemExit(f"no pude conectarme al serial {ruta}")
70 self.s.settimeout(1.0)
71 self.buf = b""
72
73 def leer(self, segundos):
74 fin = time.time() + segundos
75 while time.time() < fin:
76 try:
77 d = self.s.recv(65536)
78 except socket.timeout:
79 continue
80 if not d:
81 break
82 self.buf += d; self.log.write(d)
83 return self.buf
84
85 def esperar(self, marca, segundos, nombre=""):
86 """Lee hasta ver `marca`. Devuelve lo leído desde la llamada anterior."""
87 vistos = len(self.buf)
88 fin = time.time() + segundos
89 while time.time() < fin:
90 try:
91 d = self.s.recv(65536)
92 except socket.timeout:
93 continue
94 if d:
95 self.buf += d; self.log.write(d)
96 if marca.encode() in self.buf[vistos:]:
97 return self.buf[vistos:].decode("utf-8", "replace")
98 print(f" ⚠ nunca llegó «{marca}» {nombre} en {segundos}s", flush=True)
99 return self.buf[vistos:].decode("utf-8", "replace")
100
101 def cmd(self, orden, segundos=30, fondo=False):
102 """Manda una orden y devuelve su salida (delimitada por una marca propia).
103
104 ⚠ DOS TRAMPAS MEDIDAS, las dos hacen que la orden NO CORRA y el guion siga como si nada:
105
106 · **el terminal hace ECO de lo que se le escribe**, así que una marca escrita literal en la
107 línea aparece en el serial ANTES de que el shell ejecute nada: el `esperar` la ve en el eco
108 y vuelve al instante, y las órdenes se pisan unas a otras. Por eso la marca se ARMA EN EL
109 GUEST partida en dos (`'FIN''-123'`): el eco muestra las comillas y sólo la salida trae la
110 marca entera.
111 · **`cmd & ; echo …` es un error de sintaxis** en ash: pegarle un `;` a un `&` no lanza nada
112 y el shell contesta «syntax error». Lo de fondo va en su propia línea (`fondo=True`).
113 """
114 n = int(time.time() * 1000) % 100000
115 marca = f"FIN-{n}"
116 if fondo:
117 self.s.sendall(f"{orden}\n".encode()); time.sleep(0.5)
118 self.s.sendall(f"echo 'FIN''-{n}'\n".encode())
119 else:
120 self.s.sendall(f"{orden}; echo 'FIN''-{n}'\n".encode())
121 return self.esperar(marca, segundos, nombre=f"de «{orden[:40]}»")
122
123
124def capturar(ruta_qmp, out, nombre):
125 """Fotografía CADA pantalla del guest y devuelve {pantalla: fichero}.
126
127 Con dos salidas hay que mirar las dos: la ventana puede estar viva y dibujando en la que no se
128 fotografía, y eso es indistinguible de «no pinta» si sólo se mira una.
129 """
130 ficheros = {p: os.path.join(out, f"{nombre}-{p}.ppm") for p in PANTALLAS}
131 qmp(ruta_qmp, [{"execute": "screendump", "arguments": {"filename": f, "device": p}}
132 for p, f in ficheros.items()])
133 return ficheros
134
135
136def pintado(ppm):
137 """¿Cuántos píxeles de la pantalla son la página de prueba?
138
139 Se cuenta el magenta con TOLERANCIA, no por color exacto, y la razón está medida: a los ~+590 s
140 sin una sola entrada `cosmic-idle` ATENÚA la pantalla y los 703 766 px de `(255,0,255)` pasan a
141 703 779 px de `(117,0,117)` —la misma ventana al 46 % de brillo—. Un contador por color exacto
142 lee eso como «desapareció la ventana», que es justo el error que este guion existe para no
143 cometer.
144 """
145 try:
146 from PIL import Image
147 except Exception:
148 return None
149 im = Image.open(ppm).convert("RGB")
150 return sum(n for n, (r, g, b) in im.getcolors(1 << 24) if r > 90 and b > 90 and g < 40)
151
152
153def anotar_medicion(segundos, a, pantalla=None):
154 """Deja el tiempo de primera pintura en `docs/state/primera-pintura.json`.
155
156 Va al estado del repo y no a un log porque quien pregunta «¿esta imagen se puede usar?» es
157 `scripts/vigia-imagen.py`, que mira artefactos y no arranca ninguna VM: el número tiene que
158 llegarle por escrito o no le llega. Se guarda TODO lo que lo hace comparable —cómo se lanzó, con
159 qué vídeo, con cuánta RAM y cuántos cores— porque el tiempo depende de eso y de la carga de la
160 máquina anfitriona; un número sin sus condiciones invita a leerlo como si fuera del producto.
161 """
162 dato = {
163 "medido": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
164 "imagen": IMG,
165 "segundos_primera_pintura": segundos, # None = no pintó dentro de la ventana
166 "pantalla": pantalla, # EN CUÁL de las salidas apareció (medido 2026-09-14:
167 # con dos, la ventana cae en cualquiera de las dos)
168 "pantallas_fotografiadas": len(PANTALLAS), # con 1 y dos salidas, un ✗ NO es concluyente
169 "presupuesto_s": a.budget,
170 "ventana_s": a.wait,
171 "cadencia_s": a.cadence, # la RESOLUCIÓN del número: ±cadencia
172 "lanzamiento": "perfil del usuario" if a.as_user else "perfil nuevo en tmpfs",
173 "video": "vga+virtio-gpu (dos DRM)" if DOS_PANTALLAS else "virtio-gpu solo (-vga none)",
174 "mem_mb": int(MEM), "vcpus": int(SMP), "acel": "tcg",
175 "guion": "scripts/cosmic/atuq-en-imagen.py",
176 }
177 # ⚠ SE ACUMULAN, NO SE PISAN. Medido el 2026-09-14: dos corridas IDÉNTICAS (mismo comando,
178 # misma imagen) dieron «+204 s» y «no pintó en 900 s». Un fichero de UNA medición convierte un
179 # fenómeno intermitente en el número que le tocó a la última corrida, que es la forma más cara
180 # de mentir con datos ciertos.
181 destino = os.path.join(RAIZ, "docs/state/primera-pintura.json")
182 try:
183 historia = {"corridas": []}
184 if os.path.exists(destino):
185 with open(destino) as f:
186 historia = json.load(f)
187 historia.setdefault("corridas", []).append(dato)
188 historia["corridas"] = historia["corridas"][-30:]
189 os.makedirs(os.path.dirname(destino), exist_ok=True)
190 with open(destino, "w") as f:
191 json.dump(historia, f, indent=2, ensure_ascii=False)
192 f.write("\n")
193 pintaron = [c for c in historia["corridas"] if c.get("segundos_primera_pintura") is not None]
194 print(" anotada en docs/state/primera-pintura.json — %d de %d corridas pintaron"
195 % (len(pintaron), len(historia["corridas"])))
196 except (OSError, ValueError) as e:
197 print(f" ⚠ no pude anotar la medición: {e}")
198
199
200def qmp(ruta, ordenes):
201 s = socket.socket(socket.AF_UNIX); s.settimeout(30); s.connect(ruta)
202 f = s.makefile("rwb", buffering=0)
203 f.readline() # greeting
204 f.write(b'{"execute":"qmp_capabilities"}\n'); f.readline()
205 salidas = []
206 for o in ordenes:
207 f.write(json.dumps(o).encode() + b"\n"); salidas.append(f.readline())
208 s.close()
209 return salidas
210
211
212def main():
213 ap = argparse.ArgumentParser()
214 ap.add_argument("--only-cosmic", action="store_true", help="control: no lanza el navegador")
215 ap.add_argument("--as-user", action="store_true",
216 help="lanza `atuq` a secas —perfil por defecto, sin MOZ_ENABLE_WAYLAND— como se hizo a mano en el §6.10.ter")
217 ap.add_argument("--wait", type=int, default=600, help="segundos de observación como máximo")
218 ap.add_argument("--cadence", type=int, default=30, help="segundos entre capturas (la resolución de la medida)")
219 ap.add_argument("--budget", type=int, default=420,
220 help="segundos que puede tardar la primera pintura antes de dar ✗")
221 ap.add_argument("--until-paint", action="store_true",
222 help="cortar la observación en cuanto la página aparezca (más barato para medir el tiempo)")
223 ap.add_argument("--wayland-debug", action="store_true",
224 help="lanza el navegador con WAYLAND_DEBUG=1: registra CADA mensaje del "
225 "protocolo en los dos sentidos. Es la única forma de ver qué MANDA el "
226 "compositor sin recompilarlo (sus macros de debug están compiladas fuera)")
227 ap.add_argument("--set-rust-log", metavar="VALOR",
228 help="escribe RUST_LOG en /etc/cosmic-mode de la IMAGEN y apaga; "
229 "cadena vacía = lo quita. El compositor lo lee en el arranque SIGUIENTE, "
230 "que es la única forma de subirle el nivel de log sin recompilar")
231 ap.add_argument("--out", default=os.path.join(RAIZ, "work/atuq-imagen"))
232 a = ap.parse_args()
233
234 rc = [0] # lista para poder tocarlo desde dentro del try/finally
235 out = a.out; os.makedirs(out, exist_ok=True)
236 vars_fd = os.path.join(out, "OVMF_VARS.fd")
237 shutil.copy(OVMF_VARS, vars_fd)
238 ser_sock, qmp_sock = os.path.join(out, "serial.sock"), os.path.join(out, "qmp.sock")
239 for p in (ser_sock, qmp_sock):
240 if os.path.exists(p):
241 os.unlink(p)
242
243 qemu = [
244 "nice", "-n", "10", "qemu-system-x86_64",
245 "-machine", "q35", "-accel", "tcg", "-cpu", "max", "-m", MEM, "-smp", SMP,
246 *VGA_ARGS, "-device", "virtio-gpu-pci,id=gpu0", "-display", "none", "-nic", "none",
247 "-drive", f"if=pflash,format=raw,unit=0,file={OVMF_CODE},readonly=on",
248 "-drive", f"if=pflash,format=raw,unit=1,file={vars_fd}",
249 "-drive", f"file={IMG},format=raw,if=virtio",
250 "-serial", f"unix:{ser_sock},server,nowait",
251 "-qmp", f"unix:{qmp_sock},server,nowait",
252 "-no-reboot",
253 ]
254 print("== arrancando la imagen (TCG, sin KVM — paciencia)")
255
256 def primero_en_la_fila():
257 # El repo es COMPARTIDO y casi siempre hay una tanda de la granja compilando en esta misma
258 # máquina (4 cores, 7,6 G). Si la RAM se acaba, el que tiene que morir es ESTA VM y no el
259 # build de otro agente: `oom_score_adj` alto es la única forma de decirlo por adelantado.
260 with open("/proc/self/oom_score_adj", "w") as f:
261 f.write("900")
262
263 vm = subprocess.Popen(qemu, stdout=open(os.path.join(out, "qemu.log"), "wb"),
264 stderr=subprocess.STDOUT, preexec_fn=primero_en_la_fila)
265 try:
266 ser = Serial(ser_sock, os.path.join(out, "serial.log"))
267 print("== esperando a COSMIC…", flush=True)
268 ser.esperar("compositor OK", 600, nombre="(arranque de cosmic-comp)")
269 print(" compositor arriba; esperando el shell del serial (ventana de observación)", flush=True)
270 # 420 s alcanzaban con el anfitrión ocioso y NO con carga ~13: la VM llegaba a «+15s» y el
271 # arnés se rendía antes de que `cosmic-start` soltara el shell. El margen es de espera, no de
272 # medida: subirlo no cambia ningún número, sólo deja de tirar corridas por carga ajena.
273 ser.esperar("fin de la ventana de observación", 780, nombre="(fin de observación)")
274 time.sleep(3)
275 ser.s.sendall(b"\n")
276 print(ser.cmd("echo ENTORNO: WD=$WAYLAND_DISPLAY XDG=$XDG_RUNTIME_DIR DBUS=$DBUS_SESSION_BUS_ADDRESS").strip())
277 print(ser.cmd("ls -l $XDG_RUNTIME_DIR; ls /usr/lib/dri").strip())
278
279 if a.set_rust_log is not None:
280 # `cosmic-start` hace `. /etc/cosmic-mode` ANTES de `export RUST_LOG="${RUST_LOG:-info}"`,
281 # así que el fichero es la perilla. Se escribe y se apaga: quien la usa es el arranque
282 # siguiente — el compositor de ESTA sesión ya arrancó y no se lo puede reconfigurar.
283 ser.cmd("grep -v '^RUST_LOG=' /etc/cosmic-mode > /tmp/cm 2>/dev/null; true")
284 if a.set_rust_log:
285 ser.cmd("printf '%%s\\n' \"RUST_LOG=%s\" >> /tmp/cm" % a.set_rust_log)
286 ser.cmd("cp /tmp/cm /etc/cosmic-mode && sync")
287 print("== /etc/cosmic-mode ahora dice:")
288 print(ser.cmd("cat /etc/cosmic-mode").strip())
289 return
290
291 capturar(qmp_sock, out, "antes")
292 print("== captura ANTES (%s)" % ", ".join(PANTALLAS), flush=True)
293
294 if a.only_cosmic:
295 print("== CONTROL: no se lanza el navegador")
296 else:
297 # Una página de un color imposible de confundir con el escritorio.
298 ser.cmd("mkdir -p /tmp/perfil /var/log/cosmic")
299 ser.cmd("printf '%s' '<!doctype html><body style=\"margin:0;background:#ff00ff\">"
300 "<h1 style=\"font:40px monospace\">ATUQ PINTA</h1>' > /tmp/pagina.html")
301 ser.cmd("printf '%s\\n' 'user_pref(\"browser.dom.window.dump.enabled\", true);' > /tmp/perfil/user.js")
302 ser.cmd("printf '%s\\n' 'user_pref(\"browser.shell.checkDefaultBrowser\", false);' >> /tmp/perfil/user.js")
303 # ⚠ LOS DOS LANZAMIENTOS NO SON EL MISMO EXPERIMENTO. El de por defecto fija el
304 # entorno (perfil nuevo, `MOZ_ENABLE_WAYLAND`) para que la medición no dependa de lo que
305 # haya quedado en el estado del usuario; `--como-usuario` reproduce lo que se tecleó a
306 # mano en el §6.10.ter —`atuq` pelado, con SU perfil y sin variables— que es lo único que
307 # sigue distinto entre aquella corrida y ésta. El `MOZ_LOG` va en los dos: no cambia el
308 # comportamiento, sólo deja rastro.
309 wd = "WAYLAND_DEBUG=1 " if a.wayland_debug else ""
310 if a.as_user:
311 lanz = (wd + "MOZ_LOG='Widget:5,WidgetWayland:5,WaylandBackend:5,timestamp' "
312 "MOZ_LOG_FILE=/var/log/cosmic/moz "
313 "/usr/bin/atuq file:///tmp/pagina.html > /var/log/cosmic/atuq.log 2>&1 &")
314 else:
315 lanz = (wd + "MOZ_ENABLE_WAYLAND=1 GDK_BACKEND=wayland "
316 "MOZ_LOG='Widget:5,WidgetWayland:5,WaylandBackend:5,timestamp' "
317 "MOZ_LOG_FILE=/var/log/cosmic/moz "
318 "/usr/bin/atuq --no-remote --profile /tmp/perfil file:///tmp/pagina.html "
319 "> /var/log/cosmic/atuq.log 2>&1 &")
320 print(ser.cmd(lanz, fondo=True).strip())
321 print(f"== navegador lanzado; observando {a.wait}s", flush=True)
322 t0 = time.time(); n = 0
323 primera = None # segundos hasta la PRIMERA captura con la página en pantalla
324 while time.time() - t0 < a.wait:
325 time.sleep(a.cadence); n += 1
326 tomas = capturar(qmp_sock, out, f"durante-{n}")
327 t = int(time.time() - t0)
328 medidas = {p: pintado(f) for p, f in tomas.items()}
329 px = max((v for v in medidas.values() if v is not None), default=None)
330 donde = next((p for p, v in medidas.items() if v and v > 100_000), None)
331 if primera is None and px and px > 100_000:
332 primera = t
333 print(f" +{t}s ██ PRIMERA PINTURA — {px} px de la página EN {donde}", flush=True)
334 if a.until_paint:
335 break
336 else:
337 vivos = ser.cmd("for c in /proc/[0-9]*/comm; do cat $c 2>/dev/null; done | grep -c atuq")
338 v = vivos.splitlines()[1].strip() if len(vivos.splitlines()) > 1 else "?"
339 detalle = " ".join(f"{p}={medidas[p]}" for p in PANTALLAS)
340 print(f" +{t}s px de la página: {detalle} procesos atuq: {v}", flush=True)
341
342 capturar(qmp_sock, out, "despues")
343 print("== captura DESPUES", flush=True)
344
345 # EL NÚMERO. No «pinta / no pinta»: CUÁNTO TARDA. Medido el 2026-09-14 sobre esta misma
346 # imagen, la primera pintura cae entre +137 s y +204 s con el perfil por defecto y ~+134 s
347 # con uno nuevo en tmpfs — y con la máquina anfitriona cargada pasa de los +300 s. Por eso
348 # el presupuesto es HOLGADO y se dice el número siempre: un guardián que sólo contestara
349 # «no pintó» a los 5 min habría firmado el error del §6.10.ter.
350 if primera is None:
351 print(f"✗ el navegador NO pintó en {a.wait}s — mirá {out}/despues.png y el MOZ_LOG:")
352 print(" `mapped 1` + «marked as visible & has buffer» + WaylandBufferSHM ⇒ estaba pintando"
353 " y la captura llegó antes; sin eso, es un fallo de verdad")
354 rc[0] = 1
355 else:
356 print(f"✓ primera pintura: +{primera}s (presupuesto {a.budget}s)")
357 if primera > a.budget:
358 print(f"✗ tardó {primera - a.budget}s MÁS que el presupuesto")
359 rc[0] = 1
360
361 # El número se PUBLICA donde lo pueda leer el vigía de la imagen, que es estático y no
362 # arranca nada: sin esto el dato vive en el scrollback del que corrió la VM y se pierde,
363 # que es exactamente lo que pasó con la corrida a mano del §6.10.ter.
364 anotar_medicion(primera, a, donde if primera is not None else None)
365
366 # La evidencia que no se puede leer de una captura: qué hizo el widget de Wayland.
367 print("---- atuq.log (cola) ----")
368 print(ser.cmd("tail -25 /var/log/cosmic/atuq.log", 60))
369 print("---- MOZ_LOG: creación de la ventana ----")
370 print(ser.cmd("grep -a -m 40 -E 'nsWindow::Create|CreateNative|Map|configure|Show|frame callback|Buffer' "
371 "/var/log/cosmic/moz.moz_log | head -40", 120))
372 print("---- MOZ_LOG: últimas líneas ----")
373 print(ser.cmd("tail -25 /var/log/cosmic/moz.moz_log", 60))
374 # El otro lado de la conversación. Gecko commitea a ciegas cuando nadie le devuelve
375 # frame callbacks (§6.10.nonies), y eso lo decide el COMPOSITOR: sin su log, «no pinta»
376 # sólo se puede mirar desde el cliente, que es la mitad que ya sabemos que miente.
377 if a.wayland_debug:
378 # Lo que INTERESA no es lo que pide el cliente sino lo que llega de vuelta: las
379 # líneas `[ ]` de entrada son eventos DEL COMPOSITOR. Sin `wl_callback.done` no
380 # hay frame callbacks, que es exactamente la hipótesis del §6.10.nonies.
381 # ⚠ EN `WAYLAND_DEBUG` LA FLECHA ES DEL CLIENTE. `-> obj.metodo()` es un PEDIDO que
382 # manda el navegador; los EVENTOS del compositor son las líneas SIN flecha. Filtrar
383 # por `->` es leer otra vez al cliente, que es justo la mitad que ya sabemos que
384 # miente. Y los nombres llevan `@id` en el medio, así que `wl_surface.commit` no
385 # engancha: hay que anclar en el método.
386 print("---- protocolo: EVENTOS del compositor (sin flecha) ----")
387 print(ser.cmd("grep -av ' -> ' /var/log/cosmic/atuq.log | "
388 "grep -a -E 'xdg_surface|xdg_toplevel|wl_callback|wl_output|wl_surface' | "
389 "tail -40", 180))
390 print("---- protocolo: cuentas (evento = sin flecha) ----")
391 print(ser.cmd("for k in xdg_toplevel# xdg_surface# wl_callback# wl_output#; do "
392 "printf 'EVENTOS %s ' $k; grep -av ' -> ' /var/log/cosmic/atuq.log | "
393 "grep -ac $k; done", 180))
394 print(ser.cmd("printf 'done() '; grep -av ' -> ' /var/log/cosmic/atuq.log | "
395 "grep -ac 'done('; printf 'configure() '; grep -av ' -> ' "
396 "/var/log/cosmic/atuq.log | grep -ac 'configure('", 180))
397 print("---- cosmic-comp: superficies, salidas y foco ----")
398 print(ser.cmd("grep -a -i -E 'output|workspace|focus|toplevel|surface|xdg|map' "
399 "/var/log/cosmic/cosmic-session.log | tail -40", 120))
400 print("---- cosmic-comp: últimas líneas ----")
401 print(ser.cmd("tail -30 /var/log/cosmic/cosmic-session.log", 60))
402 print("---- tamaño de los logs ----")
403 print(ser.cmd("wc -l /var/log/cosmic/moz.moz_log /var/log/cosmic/atuq.log "
404 "/var/log/cosmic/cosmic-session.log /var/log/cosmic/cosmic-start.log", 30))
405 finally:
406 try:
407 qmp(qmp_sock, [{"execute": "quit"}])
408 except Exception:
409 pass
410 time.sleep(2); vm.terminate()
411 try:
412 vm.wait(20)
413 except Exception:
414 vm.kill()
415
416 # Las capturas salen en PPM (lo único que sabe QMP); a PNG para poder mirarlas.
417 try:
418 from PIL import Image
419 for f in sorted(os.listdir(out)):
420 if f.endswith(".ppm"):
421 Image.open(os.path.join(out, f)).save(os.path.join(out, f[:-4] + ".png"))
422 print("== capturas convertidas a PNG en", out)
423 except Exception as e:
424 print(" ⚠ sin conversión a PNG:", e)
425 return rc[0]
426
427
428if __name__ == "__main__":
429 sys.exit(main())