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

gnome: la cima ARRANCA en QEMU — muere en meta_launcher_new, causa localizada

gnome-shell bootea, compila sus esquemas, toma el DRM de virtio-gpu y se declara
Wayland display server. Después SIGSEGV. El backtrace del core no deja dudas:

  #4 g_variant_get (value=0x0, "(s&o)")     ← GVariant NULO
  #5 get_seat_proxy   src/backends/meta-launcher.c:407
  #6 meta_launcher_new (META_LAUNCHER_FLAG_TAKE_CONTROL)

Mutter pide la propiedad `Seat` del objeto **Session** de logind — `(s&o)` es su
firma estándar (nombre_del_seat, object_path). arje-logind-compat adquiere
org.freedesktop.login1 y sirve el Manager, pero NO expone un objeto Session con
esa propiedad ⇒ la lectura devuelve NULL y mutter desreferencia sin chequear. Que
mutter no valide es fragilidad suya; el hueco es nuestro.

Cinco eslabones hubo que armar antes de llegar a ese muro, ninguno anotado:
  1. gschemas.compiled NO se genera con DESTDIR seteado (meson lo dice en el log
     del build) ⇒ GSettings abortaba en el primer g_settings_new().
  2. GI_TYPELIB_PATH tiene que incluir /usr/lib/gnome-shell: St/Shell/Gvc/Shew se
     instalan aparte por ser privados del shell. Es el env sin análogo en KDE.
  3. /var/run no existía en la base metal; arje-logind-compat busca el bus en la
     ruta legacy y sin el symlink se iba a "modo idle".
  4. La política D-Bus de login1 faltaba (system.conf trae <deny own="*"/> y
     normalmente la instala systemd). PERTENECE al artefacto de arje: está en el
     script de imagen sólo para dejar visible qué falta empaquetar.
  5. /run/systemd/{sessions,seats,users} vacíos — libelogind resuelve la C-ABI
     sd-login LEYÉNDOLOS y nadie los escribe (el Announce de arje-logind-compat al
     bus del fractal falla con "identity mismatch"). gnome-start los escribe A
     MANO y está MARCADO COMO ANDAMIO. Con ese puente desaparece el "Failed to
     find any matching session".

Gotcha que costó una iteración: `kill -0` TIENE ÉXITO sobre un zombi (el padre no
lo cosechó todavía), así que el script reportaba "sin wayland-0 tras 45s" cuando
el shell había muerto en el primer segundo. Mirando State: de /proc/<pid>/status
sale el código real, 139.

Todo el ciclo (hidratar → imagen → bootear → sacar el core con debugfs → gdb) y
la lista de lo que falta quedan en docs/runbooks/gnome-qemu-desktop.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

5295aa35c6c4a0ef057aa2fc751f964bfda5df0f · por sergio · (hace 2 meses) · padre f6734d6ce · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

3 archivos · +482 −0

diff --git a/docs/runbooks/gnome-qemu-desktop.md b/docs/runbooks/gnome-qemu-desktop.md
new file mode 100644
index 00000000..6f690d53
--- /dev/null
+++ b/docs/runbooks/gnome-qemu-desktop.md
@@ -0,0 +1,93 @@
+# Runbook — arrancar gnome-shell en QEMU (y dónde está el muro hoy)
+
+Estado al 2026-07-29: **gnome-shell arranca, toma el rol de display server Wayland y muere de
+SIGSEGV en `meta_launcher_new`**. La causa está localizada con precisión (ver abajo); no es un
+misterio pendiente de diagnosticar, es una pieza de arje que falta implementar.
+
+Hermano de [`kde-qemu-desktop.md`](kde-qemu-desktop.md), del que reusa el andamiaje: la base
+`work/metal-rootfs`, la inyección de mesa-llvmpipe/LLVM/musl, el truco del console-getty y el propio
+`scripts/kde/run-qemu-desktop.sh`.
+
+## Ciclo completo
+
+```sh
+cd ~/hammer
+bash scripts/gnome/hydrate-gnome.sh          # cierre de gnome-shell → work/gnome-rootfs (108/108)
+bash scripts/gnome/qemu-desktop-image.sh     # funde base+gnome, parchea, → work/hammer-gnome-qemu.img
+IMG=work/hammer-gnome-qemu.img SERIAL=work/gnome-qemu-serial.log MON=work/gnome-monitor.sock \
+  TIMEOUT=150 DISP=none bash scripts/kde/run-qemu-desktop.sh
+grep -a "gnome-qemu ::" work/gnome-qemu-serial.log | awk '!seen[$0]++'
+```
+
+`DISP=gtk` para ver la ventana (necesita `DISPLAY=:0 GDK_BACKEND=x11`). **Regla heredada de KDE:
+validar escritorios con PANTALLA, nunca sólo por serial** — pero hoy no hace falta, porque no llega
+a pintar.
+
+## Sacar el core y el backtrace
+
+El `gnome-start` deja el core en la raíz ext4 y hace `sync`, así que sobrevive al SIGKILL de QEMU.
+
+```sh
+sfdisk -J work/hammer-gnome-qemu.img      # localizar hammer-root (part 2)
+dd if=work/hammer-gnome-qemu.img of=/tmp/root.img bs=1M skip=129 count=5113 status=none
+debugfs -R "dump /core.gnome-shell.<pid> /tmp/core.gnome-shell" /tmp/root.img
+gdb -batch -q -ex "set sysroot $PWD/work/gnome-qemu-rootfs" \
+    -ex "core-file /tmp/core.gnome-shell" -ex "bt 25" work/gnome-qemu-rootfs/usr/bin/gnome-shell
+```
+
+## EL MURO DE HOY — y por qué no es un bug de mutter que haya que rodear
+
+```
+#4  g_variant_get (value=0x0, format_string="(s&o)")        ← GVariant NULO
+#5  get_seat_proxy (...)            at src/backends/meta-launcher.c:407
+#6  meta_launcher_new (flags=META_LAUNCHER_FLAG_TAKE_CONTROL)  at meta-launcher.c:464
+...
+#15 main ()                         at src/main.c:664
+```
+
+Mutter pide la propiedad `Seat` del objeto **Session** de logind — el `(s&o)` es la firma estándar
+de esa propiedad: `(nombre_del_seat, object_path_del_seat)`. `arje-logind-compat` adquiere
+`org.freedesktop.login1` y sirve el **Manager**, pero no expone un objeto Session con esa propiedad,
+así que la lectura devuelve NULL. Mutter no chequea y desreferencia ⇒ SIGSEGV.
+
+Que mutter no valide es una fragilidad suya, pero **el hueco es nuestro**: falta el objeto Session.
+
+### La cadena completa que hubo que armar para llegar hasta acá
+
+Cada eslabón se descubrió arrancando la imagen, y ninguno estaba anotado antes:
+
+1. **`gschemas.compiled` no existe.** meson NO lo genera cuando `DESTDIR` está seteado (lo dice en
+   el log del build: *"Skipping custom install script because DESTDIR is set"*). Sin él GSettings
+   aborta en el primer `g_settings_new()`. Se compila en el primer arranque.
+2. **`GI_TYPELIB_PATH` con `/usr/lib/gnome-shell`.** St/Shell/Gvc/Shew se instalan APARTE de
+   `/usr/lib/girepository-1.0` porque son privados del shell. Es el env que no tiene análogo en KDE.
+3. **`/var/run` no existía.** `arje-logind-compat` busca el bus en la ruta legacy
+   `/var/run/dbus/system_bus_socket`; sin el symlink reporta `bus_present=false` y se va a modo idle.
+4. **Política D-Bus de `login1` ausente.** `system.conf` trae `<deny own="*"/>`; normalmente systemd
+   instala el fichero de política del servicio. Acá no hay systemd y el artefacto de
+   arje-logind-compat es sólo el binario ⇒ hubo que escribirla. **Pertenece al artefacto de arje,
+   no a la imagen**; está en el script de imagen sólo para que se vea qué falta empaquetar.
+5. **`/run/systemd/{sessions,seats,users}` vacíos.** `libelogind` (arje-sdlogin-compat) resuelve la
+   C-ABI sd-login LEYENDO esos ficheros, y nadie los escribe: el `Announce` de arje-logind-compat al
+   bus del fractal falla con `identity mismatch`, así que nunca hay sesión registrada. El
+   `gnome-start` los escribe A MANO — **eso es ANDAMIO, y está marcado como tal en el script**. Con
+   ese puente desaparece el `Failed to setup: Failed to find any matching session`.
+6. **Y entonces aparece el muro real**: el objeto Session de D-Bus (arriba).
+
+### Gotcha de diagnóstico que costó una iteración
+
+`kill -0 <pid>` **tiene éxito sobre un ZOMBI** — el hijo existe como PID hasta que el padre lo
+cosecha, y el padre es el propio `gnome-start`, que sólo hace `wait` al final. La primera versión del
+script usaba `kill -0` y reportaba *"sin wayland-0 tras 45s"* cuando en realidad el shell había
+muerto en el primer segundo. Se mira el `State:` de `/proc/<pid>/status` (vacío o `Z` = muerto), y
+así sale el código real: **139 = SIGSEGV**.
+
+## Lo que falta, en orden
+
+1. **Objeto Session en `arje-logind-compat`** con la propiedad `Seat` (y `TakeControl`/`TakeDevice`
+   sobre él, que es lo que mutter llama enseguida para el DRM master). Esto destraba el arranque.
+2. **Que escriba `/run/systemd/{sessions,seats,users}`** en vez del puente a mano — y de paso
+   arreglar el `identity mismatch` del Announce.
+3. **Empaquetar la política D-Bus** dentro del artefacto de arje-logind-compat.
+4. Recién después: el muro gráfico (GBM/EGL software sobre virtio-gpu), donde KDE ya dejó los fixes
+   pagos y precargados en `gnome-start-qemu.sh` (`GBM_ALWAYS_SOFTWARE`, `kms_swrast`, `LP_NUM_THREADS=1`).
diff --git a/scripts/gnome/gnome-start-qemu.sh b/scripts/gnome/gnome-start-qemu.sh
new file mode 100755
index 00000000..1c4ba993
--- /dev/null
+++ b/scripts/gnome/gnome-start-qemu.sh
@@ -0,0 +1,217 @@
+#!/bin/sh
+# gnome-start-qemu.sh — arranca gnome-shell (Wayland) en una VM QEMU con render por SOFTWARE
+# (kms_swrast/llvmpipe) sobre el DRM de virtio-gpu. Análogo de plasma-start-qemu.sh, y hereda sus
+# lecciones caras: el diagnóstico va POR SERIAL porque la ventana gtk se congela si mirada reinicia
+# su Xwayland (QEMU sigue vivo por dentro). Ver [[kde-metal-qemu-desktop]].
+#
+# Se instala como /usr/bin/gnome-start y lo ejecuta DIRECTO el console-getty (no vía login shell).
+# Salir: matar gnome-shell. Logs: /tmp/gnome-shell.log + tee al serial.
+#
+# DIFERENCIA CLAVE CON PLASMA: acá NO hay dos procesos (compositor + shell). gnome-shell ES el
+# compositor — embebe mutter como librería (libmutter-16.so) y `--wayland` lo hace display server.
+# Por eso no hay un equivalente de "kwin arriba pero plasmashell murió": si el shell cae, cae todo.
+set -u
+# getty ejecuta este script DIRECTO ⇒ PATH viene vacío. Primero eso, antes de invocar nada.
+export PATH=/usr/bin:/bin:/usr/sbin:/sbin
+say() { echo "== gnome-qemu :: $*" | tee -a /dev/console /dev/ttyS0 2>/dev/null; }
+dump() { echo "---- $1 ----" > /dev/ttyS0 2>/dev/null; cat "$1" > /dev/ttyS0 2>/dev/null; }
+
+export LD_LIBRARY_PATH=/usr/lib:/lib:/usr/lib/gnome-shell:/usr/lib/mutter-16
+# GI_TYPELIB_PATH es EL env crítico de esta sesión, y el que no tiene análogo en KDE: gnome-shell es
+# JavaScript sobre gjs y carga TODO por typelib. Los del sistema van en /usr/lib/girepository-1.0,
+# pero St/Shell/Gvc/Shew se instalan APARTE en /usr/lib/gnome-shell (son privados del shell). Sin
+# esta ruta, gjs no encuentra imports.gi.St y el shell muere en el primer require.
+export GI_TYPELIB_PATH=/usr/lib/girepository-1.0:/usr/lib/gnome-shell:/usr/lib/gnome-shell/girepository-1.0
+export XDG_DATA_DIRS=/usr/share
+export XDG_RUNTIME_DIR=/run/user/0
+export XDG_CONFIG_HOME=/root/.config
+export XDG_CACHE_HOME=/root/.cache
+export XDG_SESSION_TYPE=wayland
+export HOME=/root
+export GSETTINGS_SCHEMA_DIR=/usr/share/glib-2.0/schemas
+export LANG=C.UTF-8
+
+# --- render por software sobre virtio-gpu (los mismos fixes que costaron la campaña KDE) ---
+export LIBGL_ALWAYS_SOFTWARE=1
+export GALLIUM_DRIVER=llvmpipe
+# GBM_ALWAYS_SOFTWARE=1 — virtio-gpu-pci SIN 3D expone un nodo KMS-only. Sin esto gbm carga kms_swrast
+# pero deja gbm_dri->software=FALSE y dri2_initialize_drm entra al bloque `if(!software)` llamando
+# queryCompatibleRenderOnlyDeviceFd(), NULL en el driver software ⇒ SIGSEGV. Fue el MURO 2 de KDE.
+export GBM_ALWAYS_SOFTWARE=1
+# kms_swrast y NO swrast: el compositor abre card0 como KMS y crea un gbm device encima ⇒ necesita el
+# driver software PARA KMS (dumb buffers). Con 'swrast' pelado gbm_create_device falla.
+export MESA_LOADER_DRIVER_OVERRIDE=kms_swrast
+export LIBGL_DRIVERS_PATH=/usr/lib/dri
+# llvmpipe SINCRÓNICO: su JIT multi-thread segfaultea al componer la 1ra superficie en esta VM.
+export LP_NUM_THREADS=1
+# Análogos mutter de dos fixes de kwin: cursor por software (el plano de cursor por HW no se presenta
+# en virtio-gpu con render software ⇒ cursor invisible) y modeset simple en vez de atomic.
+export MUTTER_DEBUG_DISABLE_HW_CURSORS=1
+export MUTTER_DEBUG_FORCE_KMS_MODE=simple
+
+if [ "${DIAG:-1}" = 1 ]; then
+  export G_MESSAGES_DEBUG=all
+  export MUTTER_DEBUG=1
+  export EGL_LOG_LEVEL=debug
+  export LIBGL_DEBUG=verbose
+fi
+
+mkdir -p "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" /run/dbus /var/lib/dbus /root
+chmod 700 "$XDG_RUNTIME_DIR"
+[ -s /etc/machine-id ] || cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d - > /etc/machine-id
+cp -f /etc/machine-id /var/lib/dbus/machine-id 2>/dev/null || true
+
+# gschemas.compiled: meson NO lo genera cuando DESTDIR está seteado (lo dice en el log del build:
+# "Skipping custom install script because DESTDIR is set"). Sin él, GSettings aborta al primer
+# g_settings_new() y el shell no llega ni a abrir el DRM. Se compila acá, en el primer arranque.
+if [ ! -f "$GSETTINGS_SCHEMA_DIR/gschemas.compiled" ]; then
+  say "compilando $(ls $GSETTINGS_SCHEMA_DIR/*.xml 2>/dev/null | wc -l) esquemas GSettings..."
+  glib-compile-schemas "$GSETTINGS_SCHEMA_DIR" 2>/tmp/schemas.log \
+    && say "esquemas OK" || { say "!! glib-compile-schemas FALLÓ:"; dump /tmp/schemas.log; }
+fi
+
+say "GPU DRM: $(ls /dev/dri/ 2>/dev/null | tr '\n' ' ')"
+[ -e /dev/dri/card0 ] || say "!! no hay /dev/dri/card0 — ¿virtio-gpu no cargó?"
+
+# D-Bus de SISTEMA: mutter habla con logind por el bus de sistema. En esta distro login1 lo provee
+# arje-logind-compat; si no está, mutter cae a su ruta sin sesión (que es justo lo que libelogind
+# vino a cubrir del lado de la C-ABI). El bus tiene que existir igual.
+if [ ! -S /run/dbus/system_bus_socket ]; then
+  if dbus-daemon --system --fork 2>/tmp/dbus-system.log; then
+    say "system bus ON"
+  else
+    say "!! system bus NO arrancó:"; dump /tmp/dbus-system.log
+  fi
+fi
+
+# D-Bus de sesión: gnome-shell EXIGE uno (registra org.gnome.Shell y org.gnome.Mutter.*).
+if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
+  DBUS_SESSION_BUS_ADDRESS=$(dbus-daemon --session --fork --print-address 2>/dev/null)
+  export DBUS_SESSION_BUS_ADDRESS
+fi
+say "dbus sesión: ${DBUS_SESSION_BUS_ADDRESS:-NO ARRANCO}"
+
+# arje-logind-compat: el login1 del fractal. Va DESPUÉS del bus de sistema (registra
+# org.freedesktop.login1 ahí) y ANTES del shell. Mutter no sólo lo consulta por D-Bus: pregunta por
+# SU sesión vía la C-ABI sd-login, que libelogind resuelve LEYENDO /run/systemd/sessions. Sin este
+# daemon esos ficheros no existen y mutter muere en "Failed to find any matching session".
+if [ -x /usr/bin/arje-logind-compat ]; then
+  /usr/bin/arje-logind-compat >/tmp/logind-compat.log 2>&1 &
+  i=0
+  while [ $i -lt 40 ]; do
+    [ -d /run/systemd/sessions ] && [ -n "$(ls /run/systemd/sessions 2>/dev/null)" ] && break
+    i=$((i+1)); sleep 0.25
+  done
+  say "logind-compat: sesiones=$(ls /run/systemd/sessions 2>/dev/null | tr '\n' ' ') seats=$(ls /run/systemd/seats 2>/dev/null | tr '\n' ' ')"
+  [ -n "$(ls /run/systemd/sessions 2>/dev/null)" ] || dump /tmp/logind-compat.log
+else
+  say "!! sin /usr/bin/arje-logind-compat — mutter no va a hallar sesión"
+fi
+
+# ── PUENTE EXPLÍCITO: el estado de sesión que arje-logind-compat todavía no escribe ─────────────
+# ESTO ES ANDAMIO, NO LA SOLUCIÓN. arje-logind-compat adquiere org.freedesktop.login1 y sirve el
+# Manager por D-Bus, pero NO deja nada en /run/systemd/{sessions,seats,users} — su handshake con el
+# bus de arje falla ("Announce respuesta inesperada: identity mismatch"), así que nunca hay una
+# sesión registrada por el fractal. Y mutter no pregunta por D-Bus: usa la C-ABI sd-login, que
+# libelogind (arje-sdlogin-compat) resuelve LEYENDO esos ficheros. De ahí "Failed to find any
+# matching session" aun con login1 arriba.
+#
+# Escribimos el estado a mano, en el formato que libelogind lee (claves sacadas del propio .so:
+# LEADER/SEAT/CLASS/TYPE/ACTIVE/STATE/VTNR y CAN_GRAPHICAL del seat). Sirve para PROBAR que el resto
+# de la pila funciona; el arreglo de verdad es que arje-logind-compat registre la sesión del fractal
+# —o que el Announce deje de fallar— y escriba esto solo. Queda marcado para no confundir andamio
+# con producto.
+if [ -z "$(ls /run/systemd/sessions 2>/dev/null)" ]; then
+  say "puente: escribiendo estado de sesión a mano (arje-logind-compat no lo hizo)"
+  mkdir -p /run/systemd/sessions /run/systemd/seats /run/systemd/users
+  cat > /run/systemd/sessions/1 <<EOF
+UID=0
+NAME=root
+LEADER=$$
+SEAT=seat0
+TYPE=wayland
+CLASS=user
+ACTIVE=1
+STATE=active
+VTNR=1
+REMOTE=0
+EOF
+  cat > /run/systemd/seats/seat0 <<EOF
+CAN_GRAPHICAL=1
+CAN_MULTI_SESSION=1
+ACTIVE=1
+ACTIVE_SESSIONS=1
+SESSIONS=1
+EOF
+  cat > /run/systemd/users/0 <<EOF
+NAME=root
+STATE=active
+SESSIONS=1
+ACTIVE_SESSIONS=1
+EOF
+fi
+# XDG_SESSION_ID/SEAT/VTNR: libelogind los menciona entre sus símbolos, y son además el contrato
+# estándar por el que un proceso sabe cuál es SU sesión sin consultar por PID.
+export XDG_SESSION_ID=1
+export XDG_SEAT=seat0
+export XDG_VTNR=1
+
+if [ "${DIAG:-1}" = 1 ]; then
+  ulimit -c unlimited 2>/dev/null || true
+  echo '/core.%e.%p' > /proc/sys/kernel/core_pattern 2>/dev/null || true
+fi
+
+say "typelibs visibles: $(ls /usr/lib/girepository-1.0/*.typelib 2>/dev/null | wc -l) sistema + $(ls /usr/lib/gnome-shell/*.typelib 2>/dev/null | wc -l) del shell"
+say "lanzando gnome-shell --wayland (DRM virtio, software GL)..."
+/usr/bin/gnome-shell --wayland >/tmp/gnome-shell.log 2>&1 &
+SHELL_PID=$!
+# stream EN VIVO al serial (busybox sed no soporta -u ⇒ tail -f directo, sin pipes).
+( echo "==== gnome-shell.log (stream vivo) ===="; tail -f /tmp/gnome-shell.log ) > /dev/ttyS0 2>/dev/null &
+
+# OJO con el test de vivo: `kill -0` TIENE ÉXITO sobre un ZOMBI (el hijo existe como PID hasta que
+# el padre lo cosecha, y acá el padre es este script, que sólo hace wait al final). La primera
+# versión usaba kill -0 y por eso reportó "sin wayland-0 tras 45s" cuando en realidad gnome-shell ya
+# había muerto en el primer segundo: el diagnóstico decía "0 hilos" porque no quedaba proceso, sólo
+# la entrada zombi. Se mira el State de /proc en su lugar.
+alive() { s=$(sed -n 's/^State:[[:space:]]*//p' /proc/$1/status 2>/dev/null); case "$s" in ""|Z*) return 1;; *) return 0;; esac; }
+i=0
+while [ $i -lt 90 ]; do
+  [ -S "$XDG_RUNTIME_DIR/wayland-0" ] && break
+  alive $SHELL_PID || { wait $SHELL_PID; RC=$?; say "!! gnome-shell murió (código $RC) antes de exponer wayland-0:"; dump /tmp/gnome-shell.log; say "cores: $(ls /core.* 2>/dev/null | tr '\n' ' ' || echo NINGUNO)"; exec /bin/sh; }
+  i=$((i+1)); sleep 0.5
+done
+if [ -S "$XDG_RUNTIME_DIR/wayland-0" ]; then
+  say "compositor OK (wayland-0) — el shell ES el display server"
+else
+  say "!! sin wayland-0 tras 45s:"; dump /tmp/gnome-shell.log
+  # El shell NO murió: quedó COLGADO. Sin gdb en la imagen, el diagnóstico barato es dónde está
+  # bloqueado el hilo principal (wchan) y contra qué socket, que es lo que distingue "esperando una
+  # respuesta D-Bus que no llega" de "spinning en el renderer".
+  say "--- diagnóstico del cuelgue (pid $SHELL_PID) ---"
+  say "wchan: $(cat /proc/$SHELL_PID/wchan 2>/dev/null)"
+  say "state: $(grep -a ^State /proc/$SHELL_PID/status 2>/dev/null)"
+  say "stack principal:"; cat /proc/$SHELL_PID/stack > /dev/ttyS0 2>/dev/null
+  say "syscall: $(cat /proc/$SHELL_PID/syscall 2>/dev/null)"
+  say "fds abiertos:"; ls -l /proc/$SHELL_PID/fd 2>/dev/null > /dev/ttyS0
+  say "hilos: $(ls /proc/$SHELL_PID/task 2>/dev/null | wc -l)"
+  for t in $(ls /proc/$SHELL_PID/task 2>/dev/null); do
+    say "  tid $t wchan=$(cat /proc/$SHELL_PID/task/$t/wchan 2>/dev/null) comm=$(cat /proc/$SHELL_PID/task/$t/comm 2>/dev/null)"
+  done
+  exec /bin/sh
+fi
+export WAYLAND_DISPLAY=wayland-0
+
+( n=0; while sleep 15; do
+    n=$((n+1))
+    say "STATUS +$((n*15))s: gnome-shell=$(pgrep gnome-shell 2>/dev/null | wc -l)"
+  done ) &
+
+wait $SHELL_PID
+RC=$?
+say "gnome-shell salió (código $RC) — volcando log:"
+dump /tmp/gnome-shell.log
+cp /tmp/gnome-shell.log /gnome-shell.log 2>/dev/null || true
+say "cores en /: $(ls -la /core.* 2>/dev/null | tr '\n' ' ' || echo NINGUNO)"
+sync; sync
+say "== disco SYNCED. serial CONGELADO. matá QEMU desde el host. =="
+sleep 3600
diff --git a/scripts/gnome/qemu-desktop-image.sh b/scripts/gnome/qemu-desktop-image.sh
new file mode 100755
index 00000000..26cc6f76
--- /dev/null
+++ b/scripts/gnome/qemu-desktop-image.sh
@@ -0,0 +1,172 @@
+#!/usr/bin/env bash
+# qemu-desktop-image.sh — imagen de disco EFI GNOME para QEMU (virtio-gpu + software GL), auto-lanza
+# gnome-shell. Hermano de scripts/kde/qemu-desktop-image.sh; comparte con él la base metal, la
+# inyección de mesa/LLVM/musl y el truco del console-getty, y sólo diverge en lo que GNOME necesita.
+#
+#   work/metal-rootfs      base: arje-zero PID1 + busybox + kernel(EFI-stub) + firmware
+#   work/gnome-rootfs      cierre de gnome-shell (lo arma scripts/gnome/hydrate-gnome.sh, 108/108)
+#   + store/*-mesa-llvmpipe   GBM/EGL de software (el rootfs GNOME sólo trae iris_dri, que es Intel real)
+#   + store/*-llvm18          libLLVM que llvmpipe JIT-linkea
+#   + store/*-dejavu-fonts    el rootfs GNOME NO trae NINGUNA fuente
+#   → work/gnome-qemu-rootfs (hardlinks) → work/hammer-gnome-qemu.img
+#
+# Uso:  scripts/gnome/qemu-desktop-image.sh
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
+
+BASE="${BASE:-work/metal-rootfs}"
+GNOME="${GNOME:-work/gnome-rootfs}"
+MESA="${MESA:-$(ls -dt store/*-mesa-llvmpipe 2>/dev/null | head -1)}"
+LLVM="${LLVM:-$(ls -dt store/*-llvm18 2>/dev/null | head -1)}"
+FONTS="${FONTS:-$(ls -dt store/*-dejavu-fonts 2>/dev/null | head -1)}"
+MERGED="${MERGED:-work/gnome-qemu-rootfs}"
+IMG="${IMG:-work/hammer-gnome-qemu.img}"
+
+[ -d "$BASE" ]  || { echo "falta $BASE" >&2; exit 1; }
+[ -d "$GNOME" ] || { echo "falta $GNOME — corré scripts/gnome/hydrate-gnome.sh" >&2; exit 1; }
+[ -n "$MESA" ] && [ -d "$MESA" ] || { echo "falta mesa-llvmpipe en store/" >&2; exit 1; }
+
+echo "==> fundiendo rootfs por hardlinks (base metal + cierre GNOME)"
+rm -rf "$MERGED"
+cp -al "$BASE" "$MERGED"
+cp -al --remove-destination "$GNOME"/. "$MERGED"/
+
+echo "==> parcheando mesa software desde $MESA"
+# El cierre GNOME trae mesa con SÓLO iris_dri.so (Intel real). En QEMU no hay GPU: hace falta
+# kms_swrast. Se copian los .so de mesa-llvmpipe ENCIMA (--remove-destination rompe el hardlink).
+mkdir -p "$MERGED"/usr/lib/dri
+cp -a --remove-destination "$MESA"/usr/lib/dri/. "$MERGED"/usr/lib/dri/ 2>/dev/null || true
+for so in libglapi libgbm libEGL libGLESv2 libGL libgallium; do
+  cp -a --remove-destination "$MESA"/usr/lib/$so* "$MERGED"/usr/lib/ 2>/dev/null || true
+done
+ls "$MERGED"/usr/lib/dri/*swrast* >/dev/null 2>&1 && echo "   ✓ kms_swrast instalado" || { echo "   ✗ swrast no entró" >&2; exit 1; }
+
+echo "==> inyectando libLLVM.18 (llvmpipe lo JIT-linkea)"
+[ -n "$LLVM" ] && [ -d "$LLVM" ] || { echo "   ✗ falta llvm18 en store/ (set LLVM=)" >&2; exit 1; }
+cp -a --remove-destination "$LLVM"/usr/lib/libLLVM.so.18.1 "$MERGED"/usr/lib/
+ln -sf libLLVM.so.18.1 "$MERGED"/usr/lib/libLLVM.so.18
+ln -sf libLLVM.so.18.1 "$MERGED"/usr/lib/libLLVM.so
+
+echo "==> inyectando el runtime C++ de Alpine (deuda CONOCIDA, no accidente)"
+# libLLVM y mesa-llvmpipe se construyen con gcc ⇒ NEEDean libstdc++/libgcc_s. Y además —esto es
+# propio de GNOME y quedó documentado al auditar el cierre— **libmozjs-128.so también los NEEDea**,
+# o sea que la cadena gnome-shell → libgjs → libmozjs arrastra el runtime C++ de Alpine. Es la
+# "última milla" de matar-gcc que se cerró para cmake con -static-libstdc++ y que spidermonkey no
+# cubrió. Mientras eso no se re-selle (radio medido: 2), la imagen los inyecta o el shell no carga.
+for lib in libgcc_s.so.1 libstdc++.so.6; do
+  src=".dev-fs/alpine/usr/lib/$lib"
+  if [ -r "$src" ]; then cp -a --remove-destination "$src"* "$MERGED"/usr/lib/ 2>/dev/null || true
+  else echo "   ⚠ sin $lib ($src)"; fi
+done
+
+echo "==> inyectando musl — y los DOS sonames"
+# La base metal es 100% estática (arje-zero+busybox) y el cierre GNOME no trae libc. En musl el
+# loader ES libc (mismo fichero). Van dos nombres porque conviven dos toolchains: zig-cc emite
+# NEEDED `libc.so` (359 consumidores) y el gcc/clang de Alpine emite `libc.musl-x86_64.so.1`
+# (17: todo nss + spidermonkey). Es la MISMA libc; sin el segundo symlink esos 17 no cargan.
+MUSL="${MUSL:-/usr/lib/musl/lib/libc.so}"
+[ -r "$MUSL" ] || { echo "no encuentro musl libc ($MUSL) — set MUSL=" >&2; exit 1; }
+mkdir -p "$MERGED/usr/lib/musl/lib" "$MERGED/lib"
+cp -a --remove-destination "$MUSL" "$MERGED/usr/lib/musl/lib/libc.so"
+ln -sf /usr/lib/musl/lib/libc.so "$MERGED/lib/ld-musl-x86_64.so.1"
+ln -sf /usr/lib/musl/lib/libc.so "$MERGED/usr/lib/libc.so"
+ln -sf /usr/lib/musl/lib/libc.so "$MERGED/usr/lib/libc.musl-x86_64.so.1"
+echo "   ✓ libc.so + libc.musl-x86_64.so.1"
+
+if [ -n "$FONTS" ] && [ -d "$FONTS" ]; then
+  echo "==> inyectando fuentes ($(basename "$FONTS"))"
+  cp -a --remove-destination "$FONTS"/usr/share/fonts/. "$MERGED"/usr/share/fonts/ 2>/dev/null || \
+    { mkdir -p "$MERGED"/usr/share/fonts && cp -a "$FONTS"/usr/share/fonts/. "$MERGED"/usr/share/fonts/; }
+else
+  echo "   ⚠ sin dejavu-fonts en el store: el escritorio saldrá con tofu"
+fi
+
+echo "==> inyectando arje-logind-compat (el login1 del fractal)"
+# EL ESLABÓN QUE FALTABA, descubierto arrancando la imagen: mutter levanta como display server y
+# muere en `Failed to setup: Failed to find any matching session`. Ese chequeo es de logind — mutter
+# pregunta por su sesión vía la C-ABI sd-login, que acá provee libelogind (arje-sdlogin-compat)
+# LEYENDO /run/systemd/{sessions,users,seats}. Pero alguien tiene que ESCRIBIR esos ficheros, y ese
+# alguien es arje-logind-compat, el daemon D-Bus que se hace pasar por org.freedesktop.login1.
+# libelogind entra solo (es dep de mutter); el daemon no, porque nadie lo declara como dep de build.
+ALC="${ALC:-$(ls -dt store/*-arje-logind-compat 2>/dev/null | head -1)}"
+if [ -n "$ALC" ] && [ -d "$ALC" ]; then
+  install -Dm755 "$ALC"/usr/bin/arje-logind-compat "$MERGED"/usr/bin/arje-logind-compat
+  echo "   ✓ $(basename "$ALC" | cut -c1-12)"
+else
+  echo "   ✗ falta arje-logind-compat en store/ — mutter no hallará sesión" >&2; exit 1
+fi
+
+echo "==> política D-Bus para org.freedesktop.login1"
+# Segundo muro del mismo eslabón: con el bus ya visible, dbus RECHAZA que el daemon se adueñe del
+# nombre — "not allowed to own the service org.freedesktop.login1 due to security policies". El
+# system.conf de dbus trae `<deny own="*"/>` por defecto: cada servicio de sistema aporta su propio
+# fichero de política, y systemd normalmente instala el de login1. Acá no hay systemd, y el artefacto
+# de arje-logind-compat es sólo el binario ⇒ nadie pone la política. La escribimos: root puede
+# adueñarse del nombre, y cualquiera puede hablarle (mutter consulta login1 sin ser root).
+# TODO: esto pertenece al artefacto de arje-logind-compat, no a la imagen; acá está de forma
+# explícita para que se vea qué falta empaquetar del lado de arje.
+install -d "$MERGED/usr/share/dbus-1/system.d"
+cat > "$MERGED/usr/share/dbus-1/system.d/org.freedesktop.login1.conf" <<'XML'
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+  <policy user="root">
+    <allow own="org.freedesktop.login1"/>
+  </policy>
+  <policy context="default">
+    <allow send_destination="org.freedesktop.login1"/>
+    <allow receive_sender="org.freedesktop.login1"/>
+  </policy>
+</busconfig>
+XML
+chmod 644 "$MERGED/usr/share/dbus-1/system.d/org.freedesktop.login1.conf"
+echo "   ✓ org.freedesktop.login1.conf"
+
+echo "==> usuario messagebus (el system bus hace setuid a él)"
+for f in passwd:messagebus:x:81:81:messagebus:/dev/null:/sbin/nologin group:messagebus:x:81:; do
+  file="${f%%:*}"; line="${f#*:}"
+  if ! grep -q '^messagebus:' "$MERGED/etc/$file" 2>/dev/null; then
+    tmp="$MERGED/etc/$file.new"
+    { cat "$MERGED/etc/$file"; echo "$line"; } > "$tmp"
+    chmod 644 "$tmp"; mv "$tmp" "$MERGED/etc/$file"
+  fi
+done
+
+echo "==> instalando /usr/bin/gnome-start"
+install -Dm755 scripts/gnome/gnome-start-qemu.sh "$MERGED/usr/bin/gnome-start"
+
+echo "==> auto-lanzar GNOME: console-getty ejecuta gnome-start (no /bin/sh)"
+python3 - "$MERGED/ente/seed.card.json" <<'PY'
+import json, sys, os
+p = sys.argv[1]
+c = json.load(open(p))
+for g in c.get("genesis", []):
+    if g.get("label") == "console-getty":
+        argv = g["payload"]["Native"]["argv"]
+        argv[:] = [("/usr/bin/gnome-start" if a == "/bin/sh" else a) for a in argv]
+        print("  console-getty argv →", argv)
+tmp = p + ".new"
+with open(tmp, "w") as f:
+    json.dump(c, f, indent=2)
+os.chmod(tmp, 0o644)
+os.replace(tmp, p)
+PY
+
+# /var/run → /run: arje-logind-compat busca el bus de sistema en la ruta LEGACY
+# (/var/run/dbus/system_bus_socket) y dbus-daemon escucha en /run/dbus/system_bus_socket. En casi
+# toda distro /var/run es un symlink a /run desde hace 15 años; esta base metal es tan mínima que ni
+# siquiera tiene /var/run. Sin el enlace el daemon reporta `bus_present=false` y se va a "modo idle",
+# o sea que nunca registra login1 ni escribe /run/systemd/sessions ⇒ mutter no halla sesión.
+ln -sfn /run "$MERGED/var/run"
+
+mkdir -p "$MERGED/root/.config" "$MERGED/root/.cache" "$MERGED/run/user/0" "$MERGED/tmp"
+chmod 1777 "$MERGED/tmp"
+
+echo "==> rootfs fundido: $(du -sh "$MERGED" | cut -f1)"
+need_mb=$(du -sm "$MERGED" | cut -f1)
+ROOT_SIZE="${ROOT_SIZE:-$(( need_mb + 1536 ))}"
+echo "==> ROOT_SIZE=${ROOT_SIZE} MiB"
+
+export ROOTFS="$MERGED" IMG ROOT_SIZE
+export ESP_MB="${ESP_MB:-128}" STORE_SIZE="${STORE_SIZE:-512}" STATE_SIZE="${STATE_SIZE:-768}"
+exec ./scripts/install-image-efi.sh