From b08212a7f173ac4f3387a7d7bdecf1d1ba6c8b08 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 4 Sep 2026 01:56:45 +0000 Subject: [PATCH] =?UTF-8?q?waypipe:=20el=20escritorio=20en=20una=20ventana?= =?UTF-8?q?=20=E2=80=94=20ciclo=20de=20segundos,=20y=20el=20hueco=20de=20l?= =?UTF-8?q?a=20libc=20queda=20medido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos piezas para mirar la distro sin QEMU, que es lo que hacía falta para cazar el puntero invisible: ese defecto no lo ve ninguna métrica de clausura Y TAMPOCO una captura headless — el cursor no está en el framebuffer que devuelve screencopy. Hay que mirar la ventana. `hydrate-profile.py `: proyecta el cierre ENTERO de un perfil de `targets.toml`. Los `hydrate-*.sh` por escritorio traen la lista de raíces escrita a mano dentro del script, o sea DOS fuentes de verdad: hoy mismo se añadió `adwaita-cursors` a cosmic y sway y esos scripts —que no la conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva. Acá el cierre sale de `yupana.membresia()`, la misma función que usan build-state y vigia-imagen. Y trae la sonda que costó dos intentos: `hydrate` ENLAZA, y `linkat()` no cruza un punto de MONTAJE aunque sea el mismo disco. El store y `work/out` son dos binds del mismo /dev/sdb ⇒ `st_dev` COINCIDE y el hardlink falla igual. La primera versión comparaba `st_dev` y no disparó: salieron 174 «artefactos que no proyectaron» y un rootfs de 272 ficheros con pinta de problema de recetas. Ahora la sonda es FUNCIONAL —se intenta un enlace de verdad— y el error nombra el montaje del store. `sway-nested.sh`: arranca `escritorio-sway` como ventana del compositor que ya tenés delante. Evidencia de esta corrida, con el rootfs hidratado del perfil: [wlr] [xcursor] Loaded cursor theme 'default' at size 24 (62 available cursors) o sea el tema llamado literalmente `default` —el alias que fabrica `adwaita-cursors`— resolviendo a los 62 cursores de Adwaita. Antes de hoy esa línea no existía. Tres cosas que sólo salen corriéndolo: · **Ningún perfil incluye `musl`.** Ni `base`. El cierre de escritorio-sway no trae UN `ld-musl` y sway es `link=dynamic` con intérprete `/lib/ld-musl-x86_64.so.1`: la libc sale del bootstrap, no de una receta del perfil. Por eso el arnés monta DOS overlays. No es un bug, pero no estaba escrito en ningún lado y un rootfs de perfil solo no arranca. · **`yambar` no va en `bar { status_command … }`**: es una barra completa de layer-shell, no un productor de estado para swaybar. Puesta ahí, sway rechaza la config ENTERA y arranca pelado — se lee como «el escritorio está roto» cuando lo único mal es una línea. · **`grim` hay que apuntarlo al socket de NUESTRO sway.** Adentro `WAYLAND_DISPLAY` es el del HOST (es lo que sway necesita para nestear), así que un `grim` a secas devuelve una captura perfecta… del escritorio de al lado. La primera captura fue exactamente eso. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CGyBj4QyeCZiwLMkP9GJp6 --- scripts/hydrate-profile.py | 158 +++++++++++++++++++++++++++++++++++++ scripts/wlr/sway-nested.sh | 131 ++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100755 scripts/hydrate-profile.py create mode 100755 scripts/wlr/sway-nested.sh diff --git a/scripts/hydrate-profile.py b/scripts/hydrate-profile.py new file mode 100755 index 00000000..b74514b7 --- /dev/null +++ b/scripts/hydrate-profile.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# hydrate-profile.py — proyecta al FHS el cierre ENTERO de un perfil de `targets.toml`. +# +# ══ POR QUÉ EXISTE, SI YA HAY UN hydrate POR ESCRITORIO ════════════════════════════════════════ +# `scripts/{kde,gnome,cosmic}/hydrate-*.sh` traen la lista de raíces ESCRITA A MANO dentro del +# propio script. Desde que `docs/state/targets.toml` es la declaración de qué lleva cada imagen, +# eso son DOS fuentes de verdad para lo mismo, y divergen sin que nada lo diga: el 2026-09-04 se +# añadió `adwaita-cursors` a las raíces de cosmic y sway, y los scripts de hidratación —que no la +# conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva. +# La métrica y la imagen dirían cosas distintas, que es exactamente el modo de fallo que +# `vigia-imagen.py` existe para cazar. +# +# Acá el cierre sale de donde tiene que salir: `yupana.membresia()` sobre `targets.toml`, la misma +# función que usan `build-state.py` y `vigia-imagen.py`. Si el perfil cambia, esto cambia solo. +# +# ══ EL ARTEFACTO SE ELIGE POR HASH, NO POR FECHA ═══════════════════════════════════════════════ +# `hammer hash ` da el ArtifactHash de la receta de HOY sin construir. Con dos artefactos +# del mismo paquete conviviendo en el store —lo normal tras cualquier re-hash— el más reciente +# puede ser el viejo. La fecha miente; el hash no. +# +# ══ UN NODO SIN ARTEFACTO NO SE SALTEA EN SILENCIO ═════════════════════════════════════════════ +# Se listan todos y se sale con código ≠ 0 salvo `--allow-missing`. Un rootfs al que le faltan +# tres paquetes arranca y falla tres capas más abajo (`CLAUDE.md` regla 3, y la cicatriz de sway +# llegando a 121/121 sin emulador de terminal). +# +# uso: scripts/hydrate-profile.py [--into DIR] [--store DIR] +# [--link static|dynamic] [--keep] [--allow-missing] +# +# La superficie va en INGLÉS (`CLAUDE.md` regla 4); los mensajes, en castellano. +import os, sys, glob, shutil, subprocess + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import yupana +import targets as targets_mod + +ROOT = yupana.ROOT +HAMMER = str(ROOT / "target/release/hammer") + + +def opcion(nombre, defecto=None): + """`--flag valor`. Devuelve `defecto` si no está.""" + if nombre in sys.argv: + i = sys.argv.index(nombre) + if i + 1 < len(sys.argv): + return sys.argv[i + 1] + sys.exit("falta el valor de %s" % nombre) + return defecto + + +def main(): + libres = [a for i, a in enumerate(sys.argv[1:], 1) + if not a.startswith("--") and not (sys.argv[i - 1].startswith("--") + and sys.argv[i - 1] in FLAGS_CON_VALOR)] + if not libres: + sys.exit(__doc__ or "uso: scripts/hydrate-profile.py [--into DIR]") + perfil = libres[0] + store = opcion("--store", os.environ.get("HAMMER_STORE", str(ROOT / "store"))) + # Dónde cae el rootfs por defecto. Se puede fijar por entorno porque el sitio CORRECTO depende + # de cómo esté montado el store en cada máquina, no del perfil (ver la sonda EXDEV de abajo). + base_into = os.environ.get("HAMMER_ROOTFS_DIR", str(ROOT / "work/out")) + into = opcion("--into", os.path.join(base_into, perfil.replace("escritorio-", "") + "-rootfs")) + link = opcion("--link", "static") + keep = "--keep" in sys.argv + permitir_faltantes = "--allow-missing" in sys.argv + + perfiles = targets_mod.load() + if perfil not in perfiles: + sys.exit("perfil desconocido: %s (hay: %s)" % (perfil, ", ".join(sorted(perfiles)))) + colas = yupana.cargar_colas() + memb = yupana.membresia(colas, perfiles) + nodos = sorted(n for n, ps in memb.items() if perfil in ps and n[0] != "wanted") + if not nodos: + sys.exit("el cierre de %s salió vacío — ¿el perfil declara raíces?" % perfil) + + print("== %s: %d nodos en el cierre → %s" % (perfil, len(nodos), into)) + + # ── resolver ANTES de tocar el disco: si falta algo, que se sepa antes de borrar nada ────── + artefactos, faltan = [], [] + for cola, nombre in nodos: + receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus" + else "recipes/%s/%s.toml" % (cola, nombre)) + if not receta.exists(): + faltan.append((nombre, "sin receta")) + continue + r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)], + capture_output=True, text=True) + h = r.stdout.strip().replace("b3:", "") + if len(h) != 64: + faltan.append((nombre, "hash no computable")) + continue + cand = glob.glob(os.path.join(store, h + "-*")) + if not cand: + faltan.append((nombre, "no sellado (%s)" % h[:12])) + continue + artefactos.append((nombre, h, cand[0])) + + if faltan: + print("\n ⚠ %d nodo(s) SIN artefacto en el store:" % len(faltan)) + for nombre, motivo in faltan: + print(" %-28s %s" % (nombre, motivo)) + if not permitir_faltantes: + sys.exit("\nabortado sin tocar %s — con `--allow-missing` se hidrata lo que hay" % into) + print(" → se continúa por `--allow-missing`: el rootfs sale INCOMPLETO\n") + + if os.path.isdir(into) and not keep: + shutil.rmtree(into) + os.makedirs(into, exist_ok=True) + + # ── EXDEV: `hydrate` proyecta por HARDLINK, y `linkat()` NO cruza un punto de MONTAJE ─────── + # aunque los dos lados sean el mismo filesystem y el mismo disco. Acá el store y `work/out` son + # dos bind mounts del MISMO /dev/sdb: `st_dev` coincide y el hardlink falla igual — por eso la + # sonda es FUNCIONAL (se intenta un enlace de verdad) y no una comparación de `st_dev`, que fue + # el primer intento y no disparó. Sin esto salen 174 «artefactos que no proyectaron» y un rootfs + # de 272 ficheros que parece un problema de recetas. + sonda_src = os.path.join(store, ".hydrate-profile-probe") + sonda_dst = os.path.join(into, ".hydrate-profile-probe") + try: + with open(sonda_src, "w"): + pass + os.link(sonda_src, sonda_dst) + os.unlink(sonda_dst) + except OSError as e: + mp = subprocess.run(["findmnt", "-n", "-T", store, "-o", "TARGET"], + capture_output=True, text=True).stdout.strip() + sys.exit("EXDEV: no se puede enlazar del store a `%s` (%s).\n" + " `hydrate` ENLAZA, no copia, y un hardlink no cruza un montaje.\n" + " el store está montado en: %s\n" + " elegí un `--into` bajo ESE montaje (o fijá HAMMER_ROOTFS_DIR)." + % (into, e.strerror, mp or "?")) + finally: + if os.path.exists(sonda_src): + os.unlink(sonda_src) + + fallidos = [] + for i, (nombre, h, _d) in enumerate(artefactos, 1): + r = subprocess.run([HAMMER, "--store", store, "hydrate", h, + "--into", into, "--link", link], + capture_output=True, text=True) + if r.returncode != 0: + fallidos.append((nombre, (r.stderr or r.stdout).strip().splitlines()[-1:] or [""])) + if i % 25 == 0 or i == len(artefactos): + print(" %d/%d" % (i, len(artefactos))) + + if fallidos: + print("\n ✗ %d artefacto(s) no proyectaron:" % len(fallidos)) + for nombre, msg in fallidos: + print(" %-28s %s" % (nombre, msg[0] if msg else "")) + + n_fich = sum(len(f) for _, _, f in os.walk(into)) + du = subprocess.run(["du", "-sh", into], capture_output=True, text=True).stdout.split() + print("\n== %s: %d ficheros, %s" % (into, n_fich, du[0] if du else "?")) + sys.exit(1 if (fallidos or (faltan and not permitir_faltantes)) else 0) + + +FLAGS_CON_VALOR = {"--into", "--store", "--link"} + +if __name__ == "__main__": + main() diff --git a/scripts/wlr/sway-nested.sh b/scripts/wlr/sway-nested.sh new file mode 100755 index 00000000..69d3eb2f --- /dev/null +++ b/scripts/wlr/sway-nested.sh @@ -0,0 +1,131 @@ +#!/bin/sh +# sway-nested.sh — arranca el escritorio `escritorio-sway` como una VENTANA del compositor que ya +# tenés delante, sin QEMU, sin kernel y sin imagen de disco. Hermano de `sway-headless.sh` (que +# dibuja contra un backend headless y captura con grim) y de `sway-start.sh` (que arranca dentro de +# la VM). +# +# ══ PARA QUÉ, SI YA ESTÁ EL DE QEMU ════════════════════════════════════════════════════════════ +# Porque el ciclo de QEMU es de decenas de minutos —armar rootfs, imagen EFI, arrancar— y el de acá +# es de segundos, así que «¿esto se ve bien?» deja de costar una tanda. Y porque hay un tipo de +# defecto que SÓLO se ve mirando: el puntero invisible por falta de tema de cursor (arreglado el +# 2026-09-04 con `adwaita-cursors`) no lo caza ninguna métrica de clausura, y en una captura +# headless tampoco aparece — el cursor no está en el framebuffer que devuelve `grim`. +# +# NO sustituye al arranque en QEMU: acá no hay kernel nuestro, ni initramfs, ni DRM, ni PID 1. +# Prueba el compositor y sus clientes, que es justo lo que la VM tapa detrás de una pantalla negra. +# +# ══ LAS DOS CAPAS, Y POR QUÉ SON DOS ═══════════════════════════════════════════════════════════ +# `targets.toml` describe lo que la IMAGEN añade, no un sistema entero: ningún perfil —ni `base`— +# incluye `musl`, porque la libc, busybox y PID 1 salen del bootstrap (`work/metal-rootfs`), no de +# una receta del perfil. Medido: el cierre de `escritorio-sway` no trae UN solo `ld-musl`, y sway +# es `link = dynamic` con intérprete `/lib/ld-musl-x86_64.so.1`. Por eso acá van dos overlays: la +# capa base (por defecto el dev-fs de Alpine, que es lo que ya usan los `nested-*` de KDE) y encima +# el rootfs del perfil. Si algún día el perfil declarara su propia base, esto se queda en uno. +# +# ══ QUÉ MIRAR ═════════════════════════════════════════════════════════════════════════════════ +# Se abre una ventana con el fondo azul de la config, la barra de `yambar` y una terminal `foot`. +# Lo que hay que comprobar A OJO y ninguna captura headless muestra: **que el puntero se vea** al +# entrar el mouse en la ventana. +# +# uso: scripts/wlr/sway-nested.sh +# env: RFS (def /mnt/cosecha/escritorios/sway-rootfs) rootfs del perfil, ya hidratado con +# scripts/hydrate-profile.py +# BASE (def .dev-fs/alpine) capa con la libc +# W/H (def 1280x800) tamaño de la ventana +# APP (def foot) cliente que se lanza dentro +# OUT (def /mnt/cosecha/escritorios) dónde deja `sway-nested.png` +# SECS (def 0 = quedarse abierto) segundos antes de cerrar solo +set -u +ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT" +RFS="${RFS:-/mnt/cosecha/escritorios/sway-rootfs}" +BASE="${BASE:-$ROOT/.dev-fs/alpine}" +W="${W:-1280}"; H="${H:-800}"; APP="${APP:-foot}" +OUT="${OUT:-/mnt/cosecha/escritorios}"; SECS="${SECS:-0}" +mkdir -p "$OUT" + +[ -d "$RFS" ] || { echo "falta el rootfs del perfil: $RFS" >&2 + echo " scripts/hydrate-profile.py escritorio-sway --store /mnt/cosecha/store --into $RFS" >&2; exit 1; } +[ -d "$BASE" ] || { echo "falta la capa base con la libc: $BASE" >&2; exit 1; } + +# El socket del compositor del HOST. Es lo único del host que cruza: un socket, no una librería. +HOST_XDG="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +HOST_WL="${WAYLAND_DISPLAY:-wayland-0}" +case "$HOST_WL" in /*) SOCK="$HOST_WL" ;; *) SOCK="$HOST_XDG/$HOST_WL" ;; esac +[ -S "$SOCK" ] || { echo "no hay socket Wayland del host en $SOCK" >&2; exit 1; } +echo "== host: $SOCK rootfs: $RFS" + +exec bwrap \ + --overlay-src "$BASE" --overlay-src "$RFS" --tmp-overlay / \ + --dev /dev --proc /proc --tmpfs /tmp --tmpfs /run \ + --dev-bind-try /dev/dri /dev/dri \ + --ro-bind "$SOCK" /run/wl-host \ + --bind "$OUT" /salida \ + --unshare-pid --unshare-uts --hostname sway-nested \ + --uid 0 --gid 0 --die-with-parent \ + --setenv PATH /usr/bin:/bin:/usr/sbin:/sbin \ + --setenv HOME /root --setenv SHELL /bin/sh --setenv TERM xterm-256color \ + --setenv XDG_RUNTIME_DIR /run/user/0 \ + --setenv WAYLAND_DISPLAY /run/wl-host \ + --setenv WLR_BACKENDS wayland \ + --setenv WLR_RENDERER pixman \ + --setenv WLR_WL_OUTPUTS 1 \ + --setenv XDG_DATA_DIRS /usr/share \ + --setenv FONTCONFIG_PATH /etc/fonts \ + /bin/sh -c ' + set -u + mkdir -p /run/user/0 /root/.config/sway /etc; chmod 700 /run/user/0 + # /etc/passwd: sin él, cualquier getpwuid() falla y yambar no arranca (cicatriz de headless). + [ -s /etc/passwd ] || echo "root:x:0:0:root:/root:/bin/sh" > /etc/passwd + cat > /root/.config/sway/config </tmp/sway.log 2>&1 & + SWAY=$! + i=0 + while [ $i -lt 60 ]; do + S=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1) + [ -n "${S:-}" ] && break + kill -0 $SWAY 2>/dev/null || { echo " !! sway murió:"; tail -20 /tmp/sway.log; exit 1; } + i=$((i+1)); sleep 0.5 + done + [ -n "${S:-}" ] || { echo " !! sway no abrió su IPC en 30 s:"; tail -20 /tmp/sway.log; exit 1; } + echo " sway OK — ventana en tu pantalla" + export SWAYSOCK="$S" + # El cliente se lanza DESPUÉS del socket: los `exec` de la config disparan antes de que exista + # el workspace y mueren con «Failed to create launch context». + # + # ⚠ `yambar` NO va en un bloque `bar { status_command … }`: es una barra COMPLETA de layer-shell, + # no un productor de estado para swaybar. Puesta ahí, sway rechaza la config ENTERA («Error(s) + # loading config!») y arranca sin fondo ni bordes — un fallo que se lee como «el escritorio está + # roto» cuando lo único mal es una línea. Se lanza como cliente, igual que foot. + swaymsg exec '"$APP"' >/dev/null 2>&1 || '"$APP"' & + sleep 4 + echo "== clientes =="; swaymsg -t get_tree 2>/dev/null | tr "," "\n" | grep "\"app_id\"" | grep -v null | sort -u + # La línea que PRUEBA el tema de cursor: sin `adwaita-cursors` wlroots no encuentra el tema + # `default` y el puntero no se dibuja. Se reintenta porque el tema se carga cuando el puntero + # entra por primera vez, no al arrancar — mirarlo una sola vez daba un ⚠ falso. + echo "== cursores ==" + j=0; while [ $j -lt 10 ]; do + grep -i "cursor theme" /tmp/sway.log && break + j=$((j+1)); sleep 1 + [ $j -eq 10 ] && echo " ⚠ wlroots NO cargó ningún tema de cursor" + done + # La captura. ⚠ `grim` hay que apuntarlo al socket de NUESTRO sway: adentro `WAYLAND_DISPLAY` + # es el del HOST (es lo que sway necesita para nestear), así que un `grim` a secas captura la + # pantalla del host entera — sale una foto perfecta… del escritorio de al lado. Medido. + # El cursor NO sale en la captura (no está en el framebuffer de screencopy): para verlo hay que + # mirar la ventana. Esto prueba lo otro — que los clientes dibujaron. + WLSWAY=$(basename "$(ls /run/user/0/wayland-* 2>/dev/null | grep -v lock | head -1)") + if command -v grim >/dev/null 2>&1 && [ -n "${WLSWAY:-}" ]; then + WAYLAND_DISPLAY="$WLSWAY" grim /salida/sway-nested.png 2>/tmp/grim.log \ + && echo "== captura (por $WLSWAY): '"$OUT"'/sway-nested.png" \ + || { echo "== captura FALLÓ:"; tail -3 /tmp/grim.log; } + fi + echo "== sway.log (cola) =="; grep -vi "Basic output test" /tmp/sway.log | tail -4 + if [ '"$SECS"' -gt 0 ]; then sleep '"$SECS"'; kill $SWAY 2>/dev/null; else wait $SWAY; fi + '