El §7.novies dio la función por cerrada: las seis etapas del guardián de metal en verde, con el
navegador de verdad y el diálogo a la vista. Lo que seguía abierto era la decisión 1 del §7.sexies
—«en qué imágenes se declaran»—, escrita como NO mientras ninguna app llimphi pudiera pintar. Ese
motivo se cayó el 2026-09-18, así que antes de tomarla se volvió a medir en vez de darla por sabida:
atuq sealed perfiles=[cosmic, gnome, kde, sway]
puriy-costura sealed perfiles=[cosmic, gnome, kde, sway]
boveda sealed perfiles=[]
shuma-pregunta sealed perfiles=[]
`sealed` con `perfiles: []` es sellado ≠ instalado: la lección de `foot`, que targets.toml repetía
QUINCE veces antes de hoy y que igual volvió a morder. Las dos entran a los cuatro perfiles de
escritorio, las dos o ninguna —sin el dueño `vault.match` no ofrece nada; sin el diálogo,
`Command::new` falla y TODO `vault.fill` se deniega—: media bóveda es una que niega todo en
silencio. ~43 M por imagen (22 M + 21 M medidos), contra los ~1,25 GiB que ya lleva el §6.7.
Y al declararlas apareció el hueco de una capa más arriba: la receta instalaba `/usr/bin/boveda` y
nada más, y los lanzadores de los cuatro escritorios leen `/usr/share/applications`. La app viajaría
en la imagen sin existir para quien la usa — la misma forma de fallo que esto viene persiguiendo.
Entra `boveda.desktop`, con tres cosas medidas antes de escribirlo:
· el icono existe: `dialog-password` está en breeze-icons (6), adwaita (1) y cosmic-icons (2). El
cuarto perfil lleva sólo hicolor, que no trae iconos: ahí cae al genérico, que es degradarse;
· lo acepta el `desktop-file-validate` del store, con `atuq.desktop` de control. Deja un hint sobre
`Security`, y las dos formas de callarlo lo cambian por uno PEOR (dos categorías principales ⇒ la
app aparece dos veces en el menú). Se queda como está;
· ⚠ y lo que NO puede hacer: emparejar la ventana con el lanzador. `llimphi_ui::run` no llama nunca
a `with_name` ⇒ winit no manda `set_app_id` y la ventana sale SIN app_id y con el título
"llimphi". Por eso no hay `StartupWMClass`. Vale para toda app llimphi; se arregla en llimphi.
La receta se reconstruyó en el worker con la guarda del §7.quinquies puesta (`### receta verificada
3f1072cc` antes de compilar nada, porque el latido revierte la receta cada media hora y un acierto
de caché sobre la vieja imprime SELLADA en cero segundos): `b3:b0c6adc4` ⇒ `b3:3f1072cc`, 22 M, con
el árbol mirado por dentro y la entrada dentro del artefacto.
Y el guardián de coherencia pasa de CINCO lugares a SEIS: el sexto es `targets.toml` —quién DECLARA
al dueño en la imagen—, con control positivo (`atuq` tiene que estar, o el chequeo está leyendo el
campo equivocado) y su propio control negativo, el tercero. Probado en los dos sentidos: cuatro
perfiles en verde, y `--negative-control-perfil` en rojo.
Abierto, y dicho como lo que es: quién levanta la app con la sesión (atado a la decisión 2 del
§7.sexies, la raíz de las claves), y que el único proveedor de GL de las cuatro imágenes es iris
—mesa-llvmpipe en ningún perfil—, que la bóveda hereda y no agrega.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3467 lines
241 KiB
Markdown
3467 lines
241 KiB
Markdown
# SDD 26 — `atuq`: el envoltorio Gecko de la distro
|
||
|
||
Escrito 2026-09-05, a partir de la pregunta del usuario: *«ya habiendo compilado firefox y waterfox,
|
||
qué tan complicado vs provechoso suena crear nuestro propio envoltorio, en vez de zen?»*, y de su
|
||
aclaración de por qué la pregunta existe: **`puriy` quedó muy verde y es difícil sacarlo de ahí**.
|
||
|
||
`atuq` es *zorro* en quechua. El nombre estaba libre —verificado con `grep -ri atuq` sobre
|
||
`/mnt/vvv/takana` y `/mnt/vvv/tawasuyu`, cero coincidencias— y dice exactamente lo que la cosa es:
|
||
otro fox, con marca propia y sin usar la de Mozilla (ver el §branding de `recipes/firefox.toml`).
|
||
|
||
**Este documento no reemplaza a `puriy` ni lo cancela.** `puriy` sigue siendo el motor soberano; el
|
||
§9 explica por qué `atuq` es su andamio y no su desvío.
|
||
|
||
---
|
||
|
||
## 1. La corrección que ordena todo: no hay envoltorio fuera del chrome
|
||
|
||
La intuición natural —una carcasa Llimphi con nuestros widgets, y Gecko adentro pintando la página—
|
||
**no es posible**. Gecko no tiene API de embebido en escritorio desde que murió XULRunner; GeckoView
|
||
existe y es de Android. No hay forma soportada de meter el motor en una ventana de `mirada`.
|
||
|
||
⇒ un envoltorio de Gecko es, necesariamente, **chrome-level**: JS/CSS/XHTML *dentro* del propio
|
||
Firefox, más extensiones, más procesos externos que hablen con él.
|
||
|
||
Y eso es exactamente lo que Zen es. Zen no toca el motor: su diferencia entera —workspaces, split
|
||
view, glance, compact mode— vive en el chrome, más una herramienta (`surfer`) que reaplica sus
|
||
parches sobre el árbol de Firefox y recompila. **Su costo no es el motor: es la cinta de correr del
|
||
rebase cada cuatro semanas.** Copiar su método sería heredar su costo sin heredar su equipo.
|
||
|
||
## 2. La decisión de arquitectura: artefacto DERIVADO, no fork de fuente
|
||
|
||
Si `atuq` parchea el árbol, **cada iteración de UI cuesta un build de Gecko de cuatro horas**. Eso
|
||
mata el proyecto en la primera semana de diseño, porque el diseño de chrome es iterativo por
|
||
naturaleza.
|
||
|
||
Pero nosotros no distribuimos un binario: sellamos artefactos. Entonces `atuq` es una receta cuyo
|
||
`[deps]` incluye `firefox`, y cuyas fases copian el árbol instalado e inyectan encima:
|
||
|
||
| Qué se inyecta | Mecanismo de Firefox | Sin recompilar |
|
||
|---|---|---|
|
||
| Marca, iconos, nombre | `distribution/`, `application.ini` | ✅ |
|
||
| Prefs de fábrica | `defaults/pref/*.js` + autoconfig (`.cfg`) | ✅ |
|
||
| Política de empresa | `distribution/policies.json` | ✅ |
|
||
| Chrome propio (dientes, layout) | `userChrome.css` + re-empaque de `omni.ja` | ✅ |
|
||
| Extensiones nuestras | `distribution/extensions/` | ✅ |
|
||
|
||
**Ventajas que esto tiene sobre el método de Zen, y que son estructurales:**
|
||
|
||
1. **Iteración en segundos, no en horas.** El chrome se toca sin volver a compilar C++.
|
||
2. **No mueve el `ArtifactHash` de `firefox`.** El corpus no se invalida; `waterfox` y cualquier otro
|
||
fork siguen compartiendo el mismo artefacto base.
|
||
3. **Las CVE se heredan gratis.** Un fork de fuente te vuelve dueño del reloj de seguridad del
|
||
binario más atacado de la máquina — es lo que hundió a casi todos los forks de Firefox, y el
|
||
reproche histórico a Waterfox. Un derivado se reconstruye solo cuando sube `firefox`.
|
||
|
||
**Los dos gotchas reales del camino, que son trabajo pero chico:**
|
||
|
||
- **`omni.ja` es un zip ⇒ hay que re-empacarlo determinista** (mtimes fijos, orden estable) o el
|
||
artefacto deja de reproducir bit a bit. Es la misma disciplina que ya exige la imagen del lab.
|
||
- **El `jarlog` del PGO ordena el `omni.ja` para el arranque** (§3.bis). Re-empacarlo a lo bruto
|
||
tira esa optimización a la basura sin que nadie lo note. El re-empaque preserva el orden.
|
||
- **Hay que invalidar el startup cache** tras tocarlo (fichero `.purgecaches` junto al binario, o
|
||
bump del BuildID). Si no, los cambios no se ven y **parece que el overlay no agarró**: un falso
|
||
negativo de los caros, de la misma familia que el cache-hit que congela regresiones.
|
||
|
||
### 2.bis Cuándo SÍ hay que parchear el árbol
|
||
|
||
Cuando algo necesite tocar C++ del motor. Hoy conocemos un caso: `sct` en su forma fuerte (§6.1).
|
||
La regla es: **nada baja al árbol antes de que exista la toolchain del §3**, porque hasta entonces
|
||
cada intento cuesta cuatro horas. Y cuando baje, el vigía de parches (`scripts/vigia-parches.py`,
|
||
commit `aa200a4`) es lo que convierte esas cuatro horas en un minuto para saber si el parche agarra.
|
||
|
||
## 3. La puerta de las optimizaciones: no es un flag, es una toolchain
|
||
|
||
El usuario decidió habilitar las optimizaciones. Lo que hay que saber antes de intentarlo:
|
||
|
||
`recipes/firefox.toml` va con `compiler = "gcc"` por el **muro 3** (zig enlaza `libc++` estática para
|
||
musl y el `configure` de Mozilla exige encontrar un `NEEDED …libc++`; es negativa de upstream, no una
|
||
perilla). El problema es que **PGO, LTO y BOLT son cadena de clang en Gecko**:
|
||
|
||
- `--enable-lto=cross` quiere clang + lld. El LTO de GCC sobre Gecko no está soportado upstream.
|
||
- `--enable-profile-use` espera `-fprofile-instr-use` (clang), no `-fprofile-use` (gcc).
|
||
- BOLT necesita `llvm-bolt`.
|
||
|
||
Y **el corpus no tiene clang usable como compilador**. Verificado en las recetas, no supuesto:
|
||
|
||
- `recipes/clang18.toml` compila `ninja -C build libclang.so clang-resource-headers` y su fase
|
||
`install` copia **sólo** `build/lib/libclang.so*` y las cabeceras `clang-c`. Está ahí porque
|
||
`bindgen` carga `libclang.so` en runtime. **No hay driver `clang`, ni `lld`, ni `libc++`.**
|
||
- `llvm18` se selló con `-DLLVM_ENABLE_PROJECTS=""` — LLVM a secas.
|
||
|
||
⇒ la primera versión de este documento concluyó que hacía falta **una receta `llvm-toolchain`
|
||
(clang+lld+libc++ desde fuente)**. **Era caro de más, y se corrigió el mismo día mirando el lab en
|
||
vez de suponerlo:**
|
||
|
||
- `.dev-fs/alpine` **ya trae clang22 + llvm22 22.1.8** — la misma major que usa el APKBUILD de
|
||
Alpine para este mismo Firefox (`_llvmver=22`).
|
||
- **`Compiler::Clang` ya existe en takana**, cableado de punta a punta (`parse_compiler` lo acepta;
|
||
`takana-build` pone `CC=clang`, `CXX=clang++`, `AR=llvm-ar`). Ninguna receta lo usaba.
|
||
- Faltaba **sólo `ld.lld`**: `apk add lld` ⇒ dos paquetes, cero upgrades.
|
||
|
||
Y los tres muros caen sin perder lo que gcc daba: el sondeo de linker se satisface con
|
||
`--enable-linker=lld`, el `ar` lo pone takana solo, y el `NEEDED` de la stdlib de C++ existe porque
|
||
clang++ de Alpine usa la `libstdc++` **compartida** — la prueba no es teórica: Alpine construye este
|
||
Firefox con clang22 y **sin** `libcxx` en sus makedepends.
|
||
|
||
**La huella del lab no se movió, y se midió antes de tocar nada.** `lld` no casa ningún prefijo de
|
||
`TOOLCHAIN_PREFIXES` (`takana-core/src/lab.rs`), así que los 43 paquetes que entran en `hash_inputs`
|
||
salieron idénticos ⇒ **los 837 artefactos sellados quedan intactos**. Eso abarata el cambio hoy y a
|
||
la vez deja un agujero escrito: la versión de `lld` no es parte de la identidad del artefacto, y sólo
|
||
expone a las recetas `compiler="clang"` — hoy, una. Cerrarlo cuesta re-hashear el corpus entero.
|
||
|
||
**Hecho el 2026-09-05** (commit `cb3ecd5`): `firefox` va con `compiler = "clang"`,
|
||
`--enable-linker=lld`, `--enable-lto=cross`, `--enable-packed-relative-relocs` y
|
||
`--with-unsigned-addon-scopes=app,system`. Falta construirlo.
|
||
|
||
**Riesgos de PGO, escritos antes de empezar:**
|
||
|
||
- El PGO de Mozilla **corre el navegador** para juntar el perfil (`profileserver.py`, headless +
|
||
marionette). Dentro de un sandbox hermético, sin red y sin X11, eso hay que hacerlo andar.
|
||
Headless no necesita display, pero sí necesita que el árbol de perfil se genere adentro.
|
||
- **El perfil resultante es un input del artefacto.** O entra en `hash_inputs`, o `atuq` deja de ser
|
||
determinista y no lo vamos a notar: sería otra forma del lab que no está en `hash_inputs`.
|
||
- Ganancia esperable, para calibrar expectativas: PGO+LTO en Gecko son del orden de 10-25% en carga
|
||
de página y JS. **Hoy estamos ATRÁS de Zen en este eje, no adelante** — Zen hereda la
|
||
configuración de Mozilla tal cual. Esto no es una ventaja nuestra: es una deuda que se salda.
|
||
|
||
### 3.bis El diff contra Alpine — y no es sólo PGO
|
||
|
||
Medido el 2026-09-05 trayendo el `APKBUILD` y el `mozconfig` de `community/firefox` de aports y el
|
||
`PKGBUILD` de Arch, y comparándolos línea a línea con el mozconfig de `recipes/firefox.toml`.
|
||
**Alpine es nuestro propio upstream** —de ahí salen los once parches de musl— así que la comparación
|
||
no es contra una distro lejana: es contra la que ya construye este mismo código.
|
||
|
||
| | `recipes/firefox.toml` | Alpine `community/firefox` |
|
||
|---|---|---|
|
||
| LTO | **no** | `--enable-lto=cross` |
|
||
| PGO | **no** | `--enable-profile-use=cross` + `merged.profdata` + `jarlog` |
|
||
| Linker | GNU ld (via gcc) | `--enable-linker=lld` (lld 22) |
|
||
| RELR | **no** | `--enable-packed-relative-relocs` |
|
||
| Sandbox RLBox | **APAGADO** (`--without-wasm-sandboxed-libraries`) | `--with-wasi-sysroot` |
|
||
| Extensiones sin firmar | **no declarado** | `--with-unsigned-addon-scopes=app,system` |
|
||
| Allocator | `--disable-jemalloc` | `--disable-jemalloc` — **igual, no es desviación nuestra** |
|
||
| Librerías | todo bundleado | `--with-system-{icu,nss,nspr,av1,vpx,webp,png,jpeg,zlib,ffi,libevent,pixman,pipewire}` |
|
||
|
||
Arch hace lo mismo en el eje de velocidad («Do 3-tier PGO», `--enable-lto=cross`,
|
||
`--enable-profile-use=cross`). **Ni Alpine ni Arch usan BOLT** (cero menciones en los dos ficheros)
|
||
⇒ BOLT no es la desventaja, es el extra. Lo que nos separa del resto del mundo es **PGO + LTO**, del
|
||
orden de 10-20% en carga de página y JS.
|
||
|
||
**Tres lecturas que hay que sacar de esa tabla:**
|
||
|
||
1. **RLBox apagado es una desventaja de SEGURIDAD, no de velocidad, y probablemente pesa más que el
|
||
PGO.** Es la jaula wasm alrededor de los parsers de fuentes y medios —graphite, ogg, expat,
|
||
woff2—, o sea exactamente el código que come entrada no confiable. Encenderlo pide `wasi-sdk` +
|
||
`wasi-compiler-rt` en el corpus: receta propia, no un flag.
|
||
2. **`--with-unsigned-addon-scopes=app,system` nos falta y `atuq` lo NECESITA** para shipear sus
|
||
propias extensiones desde `distribution/extensions/` (§6.1, §7). Es un flag de `configure` ⇒
|
||
**una capacidad de `atuq` que no se resuelve en el overlay: exige tocar la base.**
|
||
3. **Las librerías del sistema son diferencia a propósito, no deuda.** Alpine usa las suyas; nuestro
|
||
corpus *es* la fuente y bundlear es lo que mantiene la clausura cerrada.
|
||
|
||
**Corolario que ordena el plan: todo esto entra en UN SOLO rebuild de `firefox`.** Cada rebuild son
|
||
cuatro horas y re-sella la cola Gecko entera (waterfox incluido), así que LTO, PGO, RELR, lld, RLBox
|
||
y el scope de addons se juntan en una sola pasada, no en seis.
|
||
|
||
### 3.ter Los dos muros del PGO que las distros no tienen
|
||
|
||
**El perfil se junta corriendo el navegador, y las dos distros lo hacen bajo `xvfb-run`** (Alpine:
|
||
`xvfb-run -a -s "-screen 0 1920x1080x24" ./mach python build/pgo/profileserver.py`; Arch, idéntico).
|
||
**Nosotros no tenemos X11 en el corpus** —es la decisión Wayland-only de toda la distro— así que ese
|
||
camino no existe acá. La salida takana-nativa es correrlo bajo un **sway headless**
|
||
(`WLR_BACKENDS=headless`), que ya tenemos del frente wlr/sway y arranca en segundos.
|
||
|
||
**✅ EL MURO DEL DISPLAY ESTÁ DESPEJADO (2026-09-06).** No es una previsión: se corrió. `sway`
|
||
headless con `WLR_BACKENDS=headless` y `WLR_RENDERER=pixman` arranca en **gioser**, que es un LXC
|
||
**sin `/dev/dri`** —o sea sin GPU ninguna, todo software— y dibuja píxeles de verdad:
|
||
`docs/evidencia/sway-headless-gioser-sin-gpu-2026-09-06.png` (sway 1.10 / wlroots 0.18.2 /
|
||
foot 1.27.0, los tres binarios nuestros, salida `HEADLESS-1` a 1280x720). El ciclo entero es de
|
||
~2 min con `scripts/wlr/sway-headless.sh`.
|
||
|
||
Dos cosas que hubo que resolver y conviene tener escritas antes de repetirlo:
|
||
· **Hidratar bajo el montaje del store.** `hydrate-profile.py` ENLAZA, y un hardlink no cruza
|
||
montajes: con `--into work/...` sale `EXDEV` porque el store vive en otro disco. Con
|
||
`--into store/.rootfs/sway` son 193/193 nodos, 41.677 ficheros y **4,9 G que no ocupan** (son
|
||
hardlinks al store).
|
||
· **Inyectar el loader de musl.** `sway` salió DINÁMICO y pide `/lib/ld-musl-x86_64.so.1`, que el
|
||
cierre no trae; el error es `exec: /usr/bin/sway: not found`, que se lee como «falta el binario»
|
||
y lo que falta es su intérprete. Se resuelve con
|
||
`--ro-bind /usr/lib/musl/lib/libc.so /lib/ld-musl-x86_64.so.1` (en musl el `libc.so` ES el
|
||
loader). Es la misma familia que el resto de la noche: el mensaje nombra lo que buscó, no lo que
|
||
falta.
|
||
|
||
**Y el perfil no es determinista.** Los contadores dependen del timing de la corrida, así que dos
|
||
generaciones del profdata no dan los mismos bytes — a las distros no les importa porque no persiguen
|
||
bit-repro; a nosotros nos rompe el invariante. La salida: **generar el perfil UNA vez y sellarlo como
|
||
artefacto propio, consumido por hash** desde `firefox`. El build optimizado vuelve a ser determinista
|
||
aunque su insumo no lo sea, y el perfil se regenera a propósito, no por accidente.
|
||
|
||
### 3.quinquies El PGO, y los tres muros que el plan no tenía escritos (2026-09-07)
|
||
|
||
El §3.ter daba dos muros: el display (X11 que no tenemos) y el no-determinismo del perfil. Los dos
|
||
eran ciertos. Aparecieron **tres más**, y ninguno se ve sin construir:
|
||
|
||
1. **`libclang_rt.profile.a` no existía.** El lab trae clang 22.1.8 pero **ninguna runtime de
|
||
compiler-rt** — su `lib/` del resource dir ni existe, exactamente como ya documentaba
|
||
`wasi-compiler-rt` para el caso wasm. El build instrumentado murió en el minuto 38:56 con
|
||
`ld.lld: cannot open …/libclang_rt.profile.a`. De ahí sale `recipes/compiler-rt-profile.toml`.
|
||
|
||
2. **Los sandboxes de Firefox matan la corrida de perfilado.** Con ellos puestos los procesos hijo
|
||
mueren con **signal 11**, el que renderiza no llega a nacer, el servidor local registra **0 GET**
|
||
y el único `.profraw` que queda es el del padre arrancando — un perfil de nada, con todo en
|
||
verde. Es lo mismo que hace el `profileserver.py` de Mozilla. Sólo aplica a la corrida de
|
||
entrenamiento: el firefox que se distribuye lleva sus sandboxes intactos.
|
||
|
||
3. **El worker no puede bajar del mirror de fuentes.** Tiene `scripts/fuentes/mirror-env.sh` desde
|
||
el 2026-09-01 pero no la clave del Storage Box, así que una receta pineada al mirror —y el perfil
|
||
es la primera— no se construye allá. Se sembró el artefacto sellado, que es content-addressed:
|
||
consigue lo mismo sin mover una credencial de máquina.
|
||
|
||
**Cómo se verificó que el PGO llegó, y por qué NO como RLBox.** Con RLBox el artefacto delata la
|
||
jaula (símbolos `w2c_*`, cero → 634). Acá el discriminante análogo serían las secciones
|
||
`.text.hot`/`.text.unlikely` que clang emite al particionar por temperatura — y **no sirve**: con
|
||
lld y ThinLTO el enlazador las fusiona, así que dan cero en el binario con PGO y sin él. La
|
||
evidencia que sí vale está un nivel por debajo del `configure`: **189 invocaciones del compilador
|
||
llevan `-fprofile-use`**, el configure encontró `llvm-profdata`, y no hay ni un aviso de perfil que
|
||
no cuadre. `libxul.so` pasa de 226.078.752 a 227.802.816 bytes, consistente con inlining de caminos
|
||
calientes. Es evidencia de build, no de artefacto, y conviene decirlo así.
|
||
|
||
⚠⚠ **LEER PRIMERO: LOS PORCENTAJES DE LAS TRES TABLAS QUE SIGUEN ESTÁN DILUIDOS Y NO SON LO
|
||
QUE PARECEN.** Miden el ciclo completo del proceso, del que **~16 s son arranque**. La corrección,
|
||
con el control que faltaba, está más abajo en «EL CONTROL QUE FALTABA». Se dejan tal cual se
|
||
publicaron, sin retocar, porque el error de método es el que enseña.
|
||
|
||
**LA GANANCIA, MEDIDA (2026-09-07).** Mismo rootfs, mismo script, corridas INTERCALADAS A/B/A/B
|
||
para que la deriva de carga afecte a las dos, mediana de 7, calentamiento descartado. Lo único que
|
||
cambia entre variantes es un `--ro-bind` de `/usr/lib/firefox`.
|
||
|
||
| página | sin PGO | con PGO | |
|
||
|---|---|---|---|
|
||
| DOM+layout+strings, **fuera** del corpus de entrenamiento | 23.540 ms | 21.327 ms | **−9,4 %** |
|
||
| SunSpider `3d-raytrace`, **dentro** del corpus | 16.034 ms | 15.878 ms | −1,0 % |
|
||
|
||
Los rangos no se solapan en ninguna de las dos (23.185–23.654 vs 21.030–21.650), así que las dos
|
||
diferencias son reales y no ruido.
|
||
|
||
⚠ **Y el resultado sale al revés de lo esperable, que es lo interesante.** La intuición dice que
|
||
medir sobre el conjunto de ENTRENAMIENTO infla la ganancia; acá el corpus da el número MÁS BAJO. La
|
||
razón es que `3d-raytrace` es aritmética pura en un bucle caliente, y ese bucle **no lo ejecuta el
|
||
C++ de SpiderMonkey: lo ejecuta código máquina que el JIT genera en tiempo de ejecución**. El PGO
|
||
optimiza el intérprete, el GC y el propio compilador JIT — no el código que el JIT emite. Un
|
||
benchmark JIT-bound es casi ciego al PGO por construcción.
|
||
|
||
Donde sí se ve es en el camino DOM/layout/arranque, que es C++ de principio a fin. Y eso es también
|
||
lo que un usuario percibe: la medición cubre el ciclo COMPLETO del proceso (arrancar → renderizar →
|
||
capturar → salir), no el rendimiento en régimen de una página ya cargada. De ahí que hasta la página
|
||
de SunSpider tarde 16 s: casi todo es arranque.
|
||
|
||
**Corolario para el corpus de entrenamiento — APLICADO Y MEDIDO (2026-09-08).** El corpus estaba
|
||
sesgado hacia JS justo donde el PGO menos rinde, así que se le sumaron 10 páginas de maquetación
|
||
propias (`scripts/pgo-corpus/`): flexbox, grid, tablas con los dos algoritmos de layout, texto en
|
||
columnas, selectores contra 600 reglas, pintado, transforms, SVG, scroll pegajoso y layout
|
||
thrashing. El perfil pasó de 5.885.254.799 ejecuciones registradas a **38.498.366.335 (6,5×)**.
|
||
|
||
| | sin PGO | PGO v1 (36 pág.) | PGO v2 (46 pág.) |
|
||
|---|---|---|---|
|
||
| DOM/maquetación, fuera del corpus | 23.285 ms | 21.050 ms (−9,6 %) | **20.640 ms (−11,4 %)** |
|
||
| SunSpider `3d-raytrace` | 16.003 ms | 15.846 ms (−1,0 %) | 15.857 ms (−0,9 %) |
|
||
|
||
**La mejora en maquetación es real y no de medianas:** SEIS de las siete muestras de v1 son más
|
||
lentas que TODAS las de v2; sólo una cae dentro del rango de v2. En SunSpider, en cambio, v1 y v2
|
||
son **indistinguibles** —los valores se entrelazan por completo— que es justo lo esperable: ese
|
||
camino lo ejecuta el JIT y el PGO no lo toca. Un corpus mejor no puede mejorar lo que el PGO no
|
||
alcanza.
|
||
|
||
⚠ Una de las siete corridas de v2 dio 120.029 ms contra ~20.600 de las otras seis: un atípico
|
||
transitorio. **La mediana lo ignora por diseño**; una media lo habría convertido en «v2 es
|
||
catastróficamente peor». Fue la razón de elegir mediana antes de ver un solo número.
|
||
|
||
**EL JARLOG: PROBADO, MEDIDO, Y APARCADO PORQUE ROMPE `atuq` (2026-09-08).** Desde
|
||
`d9833a58` el perfil trae también el `jarlog` y `firefox` lo consume con `--with-pgo-jarlog`. No
|
||
hizo falta la extensión `Quitter` de Mozilla, que este documento daba por bloqueante: su
|
||
`profileserver.py` sólo traduce `JARLOG_FILE` a `MOZ_JAR_LOG_FILE` en el entorno del navegador.
|
||
|
||
| | sin PGO | v1 (36 pág.) | v2 (46 pág.) | v3 (= v2 + jarlog) |
|
||
|---|---|---|---|---|
|
||
| DOM/maquetación | 23.008 ms | 20.851 (−9,4 %) | 20.527 (−10,8 %) | 20.502 (−10,9 %) |
|
||
| SunSpider | 16.006 ms | 15.859 (−0,9 %) | 15.869 (−0,9 %) | 15.838 (−1,0 %) |
|
||
|
||
**El jarlog solo aporta −0,1 % y −0,2 %: indistinguible de cero.** Y el argumento no es que los
|
||
rangos se solapen —que se solapan— sino que **la misma variante varía ~1 % entre corridas de días
|
||
distintos** (v1 dio 20.851 hoy y 21.050 ayer). El suelo de ruido del banco es **diez veces** el
|
||
efecto buscado.
|
||
|
||
Eso NO es un fallo del jarlog: es que el banco corre con la **caché de página caliente**, y ahí
|
||
reordenar `omni.ja` no ahorra ninguna lectura. Su terreno es el arranque EN FRÍO, que este
|
||
instrumento no reproduce; medirlo pediría vaciar la caché entre corridas, que es una acción de
|
||
sistema. **Lo que sí está probado es que la función existe**, y del lado del artefacto: `libxul` es
|
||
IDÉNTICO byte a byte entre v2 y v3 y el `omni.ja` difiere en 26 bytes — el jarlog tocó exactamente
|
||
lo que debía tocar y nada más. **Y sí costaba.** Al reconstruir `atuq` sobre ese firefox, murió:
|
||
|
||
zipfile.BadZipFile: Bad magic number for central directory (rebrand.py)
|
||
|
||
El jarlog convierte el `omni.ja` al **formato «jar optimizado» de Mozilla**, que mueve el directorio
|
||
central al principio:
|
||
|
||
sin jarlog: PK\003\004 ZIP estándar — `zipfile` lo abre, 5306 entradas
|
||
con jarlog: \376\204#\0 `zipfile` lo rechaza
|
||
|
||
Y `atuq` reempaqueta el `omni.ja` con `zipfile` para su branding. O sea que el jarlog **rompe el
|
||
navegador propio de la distro**.
|
||
|
||
**Aparcado, y la cuenta es asimétrica:** el coste está MEDIDO y el beneficio NO. Cambiar algo que
|
||
funciona por una ganancia que no se pudo medir, rompiendo algo que sí funcionaba, es mal negocio.
|
||
Se retoma cuando (1) se mida el arranque EN FRÍO, que es su terreno, y (2) `rebrand.py` sepa leer el
|
||
jar optimizado o des-optimizarlo antes. El blob `92497cdd…` del mirror ya trae el jarlog, así que
|
||
retomarlo es cambiar una línea y re-pinear el sha256 — no volver a perfilar.
|
||
|
||
⚠ **Y la lección de método, que es la que más vale:** el jarlog parecía GRATIS. «Es lo que hace
|
||
upstream y no cuesta nada» — lo escribí yo, en este documento, hace unas horas. El coste no apareció
|
||
midiendo el jarlog sino **construyendo lo que dependía de él**. Una función que se declara gratuita
|
||
sin haber reconstruido a sus consumidores no es gratuita: es no medida.
|
||
|
||
⚠ **Dato que los datos delatan:** los dos valores atípicos del banco son 120.034 y 120.030 ms, o sea
|
||
exactamente el `timeout 120` del arnés. No son ruido de carga: son corridas COLGADAS al arrancar en
|
||
headless, 2 de 56 (~3,5 %). Sin perseguir, pero anotado.
|
||
|
||
⚠ Y lo que este banco NO puede afirmar: las 10 páginas nuevas ejercitan los mismos subsistemas que
|
||
la página de medición, así que el parentesco con lo que ahora se entrena es mayor que antes. El
|
||
−11,4 % es real para ESTA carga; generalizarlo a «cualquier página» sería justo el error que el
|
||
propio corpus de Mozilla cometía al revés.
|
||
|
||
## EL CONTROL QUE FALTABA: el arranque eran 16 s, y sin restarlo ningún porcentaje significa nada (2026-09-08)
|
||
|
||
Todo lo de arriba mide **el ciclo completo del proceso**: arrancar firefox, renderizar, capturar el
|
||
PNG y salir. Nunca se midió cuánto de eso era la página. Faltaba el control más barato posible —una
|
||
página VACÍA— y con él las cifras publicadas se leen distinto:
|
||
|
||
| carga | total sin PGO | total con PGO | **trabajo** sin → con | mejora del TRABAJO |
|
||
|---|---|---|---|---|
|
||
| `vacia.html` (arranque puro) | 16.026 ms | 15.883 ms | — (ES la línea base) | **−0,9 %** |
|
||
| DOM/maquetación | 23.307 ms | 20.627 ms | 7.281 → 4.744 ms | **−34,8 %** |
|
||
| carga ajena (canvas/JSON/img) | 20.576 ms | 20.517 ms | 4.550 → 4.634 ms | **+1,8 % (nada)** |
|
||
| SunSpider `3d-raytrace` | 15.992 ms | 15.877 ms | **−34 → −6 ms** | bajo el ruido |
|
||
|
||
Las cuatro cargas y las dos variantes, **en una sola sesión intercalada**: restar el arranque de una
|
||
corrida al total de otra no vale, porque la misma variante deriva ~1 % entre días.
|
||
|
||
**Qué cambia, en concreto:**
|
||
|
||
1. **El «−10,9 %» que publiqué es correcto como número y engañoso como afirmación.** Es real para el
|
||
ciclo completo, que es lo que un usuario espera al abrir el navegador. Pero se leía como
|
||
«firefox es un 11 % más rápido», y sobre el trabajo de la página la mejora es **−34,8 %**: el
|
||
PGO rinde TRES VECES más de lo que dije. El error subestimaba mi propio resultado.
|
||
|
||
2. **La explicación que di del SunSpider era aire.** Escribí que el −1,0 % se explicaba porque un
|
||
benchmark JIT-bound es ciego al PGO. Mirá la fila: su trabajo mide **−34 ms**, negativo — la
|
||
página con el benchmark tardó MENOS que la vacía. `3d-raytrace` corre en decenas de ms y su
|
||
trabajo es más chico que la variación entre corridas. **Ahí no había nada que medir.** El −1,0 %
|
||
era el −0,9 % del arranque, y yo le colgué encima una teoría sobre el JIT.
|
||
|
||
La teoría puede ser cierta —el mecanismo es real y está bien descrito— pero **esta medición nunca
|
||
la probó**, y la presenté como si sí. Un número real más un mecanismo real no hacen una
|
||
explicación: hace falta que el número mida el mecanismo.
|
||
|
||
3. **La carga ajena confirma que el PGO no es magia.** +1,8 % sobre su trabajo, o sea cero: mejora
|
||
lo que se le entrenó (maquetación, −34,8 %) y no lo que no (canvas/JSON, 0 %). Eso es exactamente
|
||
lo que un PGO honesto debe hacer, y es mejor evidencia de que funciona que el número grande.
|
||
|
||
4. **El jarlog no se rehabilita.** Su −0,1 % diluido pasa a ~−0,5 % sin diluir; sigue muy por debajo
|
||
del ~1 % que la misma variante deriva entre días. La conclusión de aparcarlo no se mueve.
|
||
|
||
⚠ **La lección, que es la misma de toda esta jornada con otro disfraz.** El instrumento funcionaba,
|
||
las medianas eran correctas, las corridas estaban intercaladas, los rangos no se solapaban — todo el
|
||
rigor estadístico estaba puesto **sobre una cantidad que no era la que yo creía estar midiendo**.
|
||
Ninguna cantidad de repeticiones lo habría revelado: sólo lo revela un control, y el control costó
|
||
siete corridas de una página vacía. **Antes de refinar la precisión de un número, hay que gastar una
|
||
medición en averiguar qué mide.**
|
||
|
||
Y el detalle que lo vuelve barato de recordar: el control salió NEGATIVO en una fila. Un valor
|
||
imposible es la forma más limpia que tiene una medición de avisar que el modelo está mal, y por eso
|
||
esa fila se deja publicada con su signo menos en lugar de redondearla a cero.
|
||
|
||
**Y la pregunta que un insumo no determinista obliga a hacer, respondida: `firefox` SIGUE
|
||
REPRODUCIENDO bit a bit** (`verificar-repro.sh`: REPRODUCEN 1, DERIVA 0, NO-DETERMINISMO 0). Era el
|
||
riesgo real de esta unidad, no el rendimiento: si el `-fprofile-use` hubiera metido cualquier
|
||
decisión dependiente del orden o del timing, habríamos cambiado velocidad por el invariante del
|
||
proyecto. Congelar el perfil como fuente pineada era el argumento; esto es la medición.
|
||
|
||
### 3.quater La cadena wasm, y las tres cosas que costó (2026-09-06)
|
||
|
||
`--without-wasm-sandboxed-libraries` se cambió por `--with-wasi-sysroot=/usr/share/wasi-sysroot`,
|
||
que es literalmente el flag de Alpine. Detrás hay cinco recetas nuestras, en este orden obligatorio:
|
||
|
||
```
|
||
wasi-libc-headers → wasi-compiler-rt → wasi-libc → wasi-libcxx → wasi-sdk → firefox
|
||
```
|
||
|
||
`wasi-libc-headers` existe **sólo** para romper el ciclo del bootstrap y pinea un commit VIEJO a
|
||
propósito (triple `wasm32-wasi`), mientras `wasi-libc` pinea el nuevo (`wasm32-wasip1`, el que
|
||
clang 22.1 usa de verdad). Es lo mismo que hace Alpine y no es un descuido.
|
||
|
||
**Lo que costó, que es la parte reusable:**
|
||
|
||
1. **`check-symbols` falla por sesgo de clang, no por un artefacto malo.** El lab trae clang 22.1.8
|
||
y el snapshot del árbol viene de uno anterior: sobra `#define __wasip1__ 1` y nada más. Se
|
||
reconcilia el snapshot; **no** se salta con `make no-check-symbols`, que se lleva por delante la
|
||
comparación de símbolos —la mitad que sí vale y que pasaba byte a byte.
|
||
|
||
2. **Faltaba `wasi-libcxx`, y el hueco era visible en el grafo sin construir nada.** El configure
|
||
murió a los 6 s con `'cstring' file not found`, culpando a los headers de WASI, que estaban
|
||
perfectos: `<cstring>` es C++ y las librerías enjauladas no son todas C. El `wasi-sdk` de Alpine
|
||
declara `wasi-libc wasi-libcxx wasi-compiler-rt` —TRES— y la cadena tenía dos. **El `depends`
|
||
del paquete ajeno equivalente es una comprobación de completitud gratis**, antes de gastar CPU.
|
||
|
||
3. **Un directorio VACÍO que sí importa.** Con `wasi-libcxx` sellado el configure volvió a morir
|
||
igual, y `cstring` estaba en el artefacto: clang no lo buscaba. Falta el `mkdir -p
|
||
include/c++/v1` que Alpine tiene en su `package()` y que parece ruido de empaquetado — es la
|
||
sonda por la que el driver decide que el sysroot tiene layout de libc++. Medido dentro del lab:
|
||
sin ese directorio, `'cstring' file not found`; con él, exit 0. Es el **reverso exacto de la
|
||
regla 3 del `CLAUDE.md`**: allá un directorio vacío es un artefacto mentiroso, acá es una
|
||
declaración dirigida al compilador.
|
||
|
||
De ahí sale el criterio de guardián que ahora usan estas recetas: **«existe» no es «se encuentra»**.
|
||
`wasi-libcxx` corre hoy la misma prueba que hace el configure de firefox, contra un sysroot
|
||
fusionado con el de su dep, así que un fallo aparece en el segundo 20 de una receta chica —con la
|
||
lista de búsqueda de clang impresa— y no en el segundo 6 de un build de cuatro horas que además
|
||
culpa a otro. Y `firefox` exige RLBox **en el binario**: sobre `b3:352d7880` (el último con RLBox
|
||
apagado) los patrones `w2c_`, `rlbox` y `wasm2c` dan cero los tres en `libxul.so`, así que
|
||
cualquiera apareciendo prueba que la jaula entró. Es la tercera repetición de la lección de
|
||
`MOZ_REQUIRE_SIGNING` y `MOZ_BUILD_DATE`: una bandera que el configure acepta y el artefacto ignora.
|
||
|
||
**Evidencia de reproducibilidad cruzada (2026-09-06).** Las cinco recetas se construyeron por
|
||
separado en las DOS máquinas —el hub (gioser, 4c) y el worker (`dev.gioser.net`, LXC 6c)— y los
|
||
cinco artefactos salen **byte a byte idénticos**, comparando el árbol completo de cada uno:
|
||
|
||
| receta | sha256 del árbol | |
|
||
|---|---|---|
|
||
| `wasi-libc-headers` | `0d34f9cb27530606…` | ✓ |
|
||
| `wasi-compiler-rt` | `0c4fc6fdf08ef908…` | ✓ |
|
||
| `wasi-libc` | `c13acdf896f2b78c…` | ✓ |
|
||
| `wasi-libcxx` | `b1d9aeec08499063…` | ✓ |
|
||
| `wasi-sdk` | `b49f7a74a5caed3f…` | ✓ |
|
||
|
||
No es un trámite: **el lab NO entra en `hash_inputs`**, así que dos labs distintos pueden sellar
|
||
bytes distintos en la misma dirección del store y nada lo detecta. Esto dice que para esta cadena
|
||
los dos labs coinciden de verdad, y no sólo que coinciden los `ArtifactHash`.
|
||
|
||
## 4. Lo que `atuq` NO promete
|
||
|
||
Esta sección existe antes que la lista de features a propósito, con el mismo criterio que el modelo
|
||
de adversario de `qullqa`: **lo que no se promete se escribe primero, porque una promesa falsa de
|
||
privacidad es peor que no ofrecer nada.**
|
||
|
||
**`atuq` no es Tor Browser y no va a tener «modo Tor».** Tor Browser no es «Firefox + SOCKS»: su
|
||
valor son las contramedidas de huella (RFP, letterboxing, normalización de fuentes/canvas/timers) y
|
||
sobre todo **un conjunto de anonimato donde todos se ven idénticos**. `atuq` es, por construcción, un
|
||
binario único en el mundo: musl, Wayland-only, branding propio, extensiones propias. Alguien saliendo
|
||
por Tor desde `atuq` es **más** identificable, no menos, y su conjunto de anonimato son diez
|
||
personas. Para anonimato: Tor Browser, y lo decimos nosotros primero.
|
||
|
||
Lo que sí se ofrece, y es honesto porque es otra cosa: **proxy por contenedor** (§6.8) — separación
|
||
de tráfico, no anonimato.
|
||
|
||
**`atuq` tampoco promete** anti-fingerprinting propio (es un programa de investigación con equipo
|
||
full-time) ni bloqueo de anuncios propio (se shipea uBO y listo).
|
||
|
||
## 5. Contexto verificado al escribir (2026-09-05)
|
||
|
||
De `docs/state/build-state.json`, no de memoria:
|
||
|
||
| Dato | Valor |
|
||
|---|---|
|
||
| Recetas / nodos | 838 / 840 |
|
||
| Selladas | 837 |
|
||
| `firefox` 154.0 | **sealed**, cola `corpus` |
|
||
| `waterfox` 6.7.1.1 | **never** — primer build en curso |
|
||
| Plataforma compartida | `gtk3`, `atk`, `nodejs`, `clang18`, `cbindgen`: todas sealed |
|
||
|
||
**`atuq` no arranca hasta que `waterfox` selle.** Waterfox es la prueba de la tesis del reúso —«las
|
||
diferencias reales con `firefox.toml` son TRES: la fuente, el branding y la versión»—. Si esa tesis
|
||
falla, el precio de todo este documento cambia y hay que releerlo.
|
||
|
||
## 6. Los diferenciadores, priorizados
|
||
|
||
La columna «quién más lo tiene» es lo que evita que nos contemos un cuento.
|
||
|
||
| # | Qué | Quién más lo tiene | Pieza que ya existe | Costo |
|
||
|---|---|---|---|---|
|
||
| 6.1 | **`sct` — transparencia de scripts** | **nadie** | `puriy-sct`, `puriy-sct-testigo` | medio |
|
||
| 6.2 | **Descargas direccionadas por contenido** | nadie | store CAS de takana, `tejido` | bajo |
|
||
| 6.3 | **Archivo personal + RAG local** | Rewind/Recall (nube, Windows); SingleFile (guarda, no busca) | `khipu`, `rag-motor`, `willay-rag` | medio |
|
||
| 6.4 | **Historial y perfil sobre `qullqa`** | nadie | `qullqa-core`, `qullqa-pozo` | medio |
|
||
| 6.5 | **Foco por `cortafuegos`, no por extensión** ✅ **cadena y navegador; falta quién lo aplica** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `nftables` (nuevo en el corpus) | bajo |
|
||
| 6.6 | **Medios por fuera del navegador** | extensiones sueltas; de fábrica no | `foreign-ytdlp`, `-platform`, `-dlna` | bajo |
|
||
| 6.7 | **IA local en la barra lateral** ✅ **motor**; falta modelo y barra | Chrome/Edge son nube; Zen no tiene | `llama-cpp` (NUEVO en el corpus) — `rimay`/`iniy` NO servían, ver §6.7 | bajo |
|
||
| 6.8 | **Proxy por contenedor** ✅ **v0.5** | nadie de fábrica | — (API de Firefox) | bajo |
|
||
| 6.9 | **Torrent adentro** | **Vivaldi lo trae; Brave tuvo WebTorrent** | `shared/foreign-torrent` (SDD propio, sobre librqbit) | bajo |
|
||
|
||
### 6.1 `sct` es la joya
|
||
|
||
BLAKE3 a todo lo que se ejecuta, y **negarse a correr código que ningún testigo vio nunca**. Es lo
|
||
más cerca que hay de «Certificate Transparency, pero para el JavaScript que te corre en la cara», y
|
||
no lo tiene nadie: ni Zen, ni Brave, ni Tor Browser.
|
||
|
||
En tawasuyu ya está escrito y —dice su README— la lógica es **agnóstica del transporte** y está
|
||
certificada sin red; `puriy-sct-testigo` es sólo el cable HTTP.
|
||
|
||
- **v1 — HECHA 2026-09-10, y con una corrección de este propio párrafo.** La extensión intercepta el
|
||
cuerpo de cada `<script src>` con `filterResponseData` y se lo pasa al **host** (§7), que hashea y
|
||
decide. Decía «consulta al testigo antes de dejarla pasar», y las dos mitades había que ajustarlas:
|
||
el cable del testigo es un POST con **postcard**, así que un JS no puede ser su cliente (§7.quater);
|
||
y v1 **observa y avisa, no bloquea** — que es lo que `puriy-sct` dice de su propia v1, y además
|
||
bloquear cada script contra un viaje entre procesos no es «más seguro», es un navegador que nadie
|
||
usa. Medida de punta a punta por `scripts/test-atuq-sct.py`, con servidor HTTP real, seis cargas de
|
||
página y dos controles.
|
||
- **v2, forma fuerte:** gancho en el script loader de Gecko. Eso sí es parche de árbol ⇒ §2.bis ⇒
|
||
después de la toolchain del §3. Es también lo único que ve los scripts **inline** —
|
||
`filterResponseData` entrega el cuerpo de una PETICIÓN, y un inline viaja dentro del HTML— y lo único
|
||
que puede negarse a ejecutar.
|
||
|
||
#### 6.1.bis Lo que costó armarla, que es lo que no se puede deducir (2026-09-10)
|
||
|
||
Cinco cosas se midieron en vez de suponerse, y cada una tenía su forma de fallar en silencio:
|
||
|
||
| Qué | Cómo falla si se supone mal |
|
||
|---|---|
|
||
| El manifiesto va en `/usr/lib/mozilla/native-messaging-hosts/`, no en el appdir (`XRESysNativeManifests` es un `/usr/lib/mozilla` **compilado**) | el puerto se cierra «sin error y sin mensaje» |
|
||
| **El manifiesto NO puede llevar argumentos** — `NativeMessaging.sys.mjs`: `command = manifest.path`, y los únicos argumentos son `[ruta-del-manifiesto, id]` | `puriy-costura` sin `--state` corre en memoria ⇒ cada arranque vuelve a «aprendiendo» y **nada alerta nunca**: la función existe, no falla, y no protege |
|
||
| `filterResponseData` + el permiso `webRequestFilterResponse` **están** en nuestro `omni.ja` | se habría escrito la extensión contra una API que el build no tiene |
|
||
| Los `inline` no se ven por esta vía | se habría prometido cobertura total teniendo la mitad |
|
||
| **Una carga puede producir dos peticiones del mismo documento, y una llega con `tabId = -1`** | agrupando por `(tabId, documento)` esa carga contaba **dos visitas** |
|
||
|
||
La última es la que más importa y la que menos se veía. Con las visitas infladas, un origen **se
|
||
estabiliza antes de conocer su código real**, y entonces alerta por churn legítimo: el falso positivo
|
||
que la spec de `puriy-sct` pide evitar por encima de todo. Se agrupa por documento —lo que hace que dos
|
||
pestañas con la misma url cuenten UNA, que es el error seguro: tarda más en proteger, no alerta de
|
||
más— y el guardián vigila el invariante «una carga, una visita», así que si vuelve, falla ruidoso.
|
||
|
||
**Y el aviso se mide, no se supone.** La extensión relee la insignia con `getBadgeText` después de
|
||
ponerla y lo dice, así que el guardián exige insignia vacía en las cinco cargas sin novedad y `"1"` en
|
||
la del script cambiado. Es la única parte de toda la cadena que el usuario llega a ver: dejarla como
|
||
«se llamó a la API» sería dejar sin medir justo el final.
|
||
|
||
**Dos límites escritos donde no se pueden no leer** (el propio `fondo.js`, el `lib.rs` del host y los
|
||
dos LEEME): se hashea el TEXTO ya decodificado, que vale para comparar dos cargas nuestras y **no** es
|
||
comparable con el hash de los bytes servidos que publique un tercero; y el aviso es pasivo (insignia,
|
||
no modal) porque un modal por despliegue entrena a cerrarlo sin leer.
|
||
|
||
### 6.2 Una descarga con identidad
|
||
|
||
Lo bajado no es un archivo en `~/Downloads`: es un objeto BLAKE3 en el store — deduplicado,
|
||
verificable, y re-compartible por `tejido`. El torrent (6.9) alimenta lo mismo. **Ningún navegador
|
||
trata una descarga como algo con identidad**; para todos es un blob con nombre, y por eso lo bajás
|
||
dos veces y no lo sabés.
|
||
|
||
Ésa, y no «tener torrent», es la razón por la que 6.9 vale: Vivaldi ya tiene torrent, pero termina en
|
||
una carpeta.
|
||
|
||
#### 6.2.bis Hecha (2026-09-10) — y el almacén NO es el del sistema, por una pérdida de datos medida
|
||
|
||
`cas.ingest` en el host + la extensión `descargas@atuq.tawasuyu`, que observa las descargas
|
||
TERMINADAS y le pasa la ruta. El objeto entra a un CAS BLAKE3 y el navegador contesta el hash, el
|
||
tamaño, con qué nombre apareció la primera vez y **cuántas veces se bajó ese mismo contenido**.
|
||
|
||
1ª descarga contenido A como `uno.bin` ⇒ dedup=false, veces=1
|
||
2ª descarga contenido A como `dos.bin` ⇒ dedup=TRUE, veces=2, UN solo blob en disco
|
||
|
||
**No es un almacén nuevo**: es `arje-cas`, el mismo formato y el mismo hash que usan arje, takana y
|
||
`tejido` — lo dice el propio código de tejido («esto NO es un almacén nuevo»). Un objeto que entra por
|
||
acá queda donde los otros lo van a buscar, y el `<hex>` del CAS **es** el `expected_hash` de un `.swm`.
|
||
|
||
**Tres cosas que este trabajo dejó, y ninguna es la feature:**
|
||
|
||
1. **`arje-cas` gana ingesta en streaming.** `store` toma `&[u8]`, así que ingerir un fichero obliga a
|
||
leerlo entero: para los binarios de arje da igual, para lo que baja un navegador no —una ISO de
|
||
4 GiB serían 4 GiB de `Vec`—. `almacenar_fichero_en` hace UNA pasada (lee, hashea y escribe a la
|
||
vez, a un temporal con el PID, y recién entonces renombra al `<hex>`) y devuelve
|
||
`(hash, bytes, ya_estaba)`: ese `ya_estaba` **es** la deduplicación vista desde afuera.
|
||
2. ⚠ **La raíz del CAS de descargas no es la del sistema, y es por una pérdida de datos medida.**
|
||
`arje_cas::gc` borra todo blob que no esté en el set `reachable` de su llamador, y el único
|
||
llamador que existe hoy —`arje-brain::introspect`, `IntrospectRequest::GcCas`— lo arma con la
|
||
cadena de audit + las raíces vivas del grafo + lo que le pase el caller. **Una descarga del usuario
|
||
no está en ninguno de esos conjuntos: el primer `GcCas` se la llevaría, en silencio.** Por eso van
|
||
a `<estado>/descargas-cas`: mismo formato (mover un objeto al CAS del sistema es un `rename`),
|
||
fuera del alcance del GC de otro. Lo que se pierde, dicho: `tejido` sirve el CAS por defecto, así
|
||
que compartir una descarga hoy exige apuntarlo a esta raíz (`ENTE_CAS_ROOT`, la perilla que su
|
||
propio código documenta) hasta que el índice de descargas sea una raíz que el GC respete.
|
||
3. **No se toca el fichero del usuario.** Se copia al CAS y se deja donde estaba. Borrar o mover lo
|
||
que alguien acaba de bajar —aunque «ya esté en el CAS»— no es una decisión que le toque a un
|
||
navegador tomar por su cuenta, y el guardián lo comprueba byte a byte.
|
||
|
||
El índice va en **JSON** y no en postcard a propósito: es la lista de lo que el usuario bajó y tiene
|
||
que poder leerse con `cat` el día que el binario no arranque. El estado de `sct` es interno; esto es
|
||
suyo.
|
||
|
||
**Y ahí apareció el bug que valía más que la función: hay UN HOST POR PUERTO, no uno por perfil.**
|
||
Con `sct` y `descargas` hablando hay dos procesos vivos a la vez, cada uno con su copia en memoria
|
||
del estado, y cada uno escribía el fichero ENTERO al guardar. El de descargas **borraba el registro
|
||
TOFU de `sct`** al salir; el de `sct` **revertía el índice de descargas** al valor que tenía cuando
|
||
arrancó. Los dos contestaban bien: el daño estaba sólo en el disco.
|
||
|
||
Se encontró porque el guardián lee el índice **en disco** en vez de creerle a la respuesta del host —
|
||
la respuesta decía `veces=2` y el fichero decía 1. Aseverar sobre lo que el proceso contestó habría
|
||
dejado pasar una pérdida de datos silenciosa. El arreglo es que cada proceso escriba **sólo la parte
|
||
que tocó**, y lo que eso NO cubre queda dicho: dos procesos que toquen la MISMA parte se seguirían
|
||
pisando, y ese día hace falta un dueño único del estado, no más banderas.
|
||
|
||
⚠ **Y el test de ese arreglo también costó, con una lección propia:** la primera versión abría los dos
|
||
hosts en SECUENCIA y **pasaba con el bug puesto**, porque el segundo cargaba de disco lo que el
|
||
primero acababa de escribir. El bug necesita que las vidas se SOLAPEN. Se comprobó rompiendo el
|
||
arreglo a propósito —con la rotura el test falla nombrando la causa, sin ella pasa—, que es la única
|
||
forma de saber si un guardián sirve.
|
||
|
||
`scripts/test-atuq-descargas.py` baja de verdad —`Content-Disposition: attachment`, no una llamada a
|
||
la API— dos veces, y trae control negativo: con OTRO contenido la segunda vez, exige `dedup=false` y
|
||
dos objetos. Sin él, una sonda que dijera «ya lo tenías» siempre se vería idéntica a una que funciona.
|
||
|
||
⚠ Y corre sobre **sway headless**, no sobre `--headless`, porque en `--headless` una descarga
|
||
terminada mata al navegador con SIGSEGV — con y sin nuestra extensión. Está medido y atribuido en
|
||
el §6.10.bis.
|
||
|
||
### 6.3 El archivo personal es la razón por la que alguien usaría `atuq`
|
||
|
||
Cada página visitada se congela en el CAS y entra al DAG de `khipu`; después le preguntás a tu
|
||
propio historial en lenguaje natural, **todo local**. Es la feature que más justifica el proyecto
|
||
entero, y sale de piezas que ya están escritas.
|
||
|
||
#### 6.3.bis La MITAD que se puede pagar hoy, y la que no (2026-09-10)
|
||
|
||
El §6.3 promete dos cosas y conviene separarlas, porque una está hecha y la otra depende de
|
||
infraestructura que la imagen todavía no lleva:
|
||
|
||
| mitad | estado |
|
||
|---|---|
|
||
| **congelar cada página leída y poder encontrarla** | ✅ HECHA — `archive.add` / `archive.search` + la extensión `archivo@atuq.tawasuyu` |
|
||
| **preguntarle al historial en lenguaje natural** | ❌ falta, pero **el muro cayó el 2026-09-11**: el motor de embeddings ya está en el corpus (§6.7, `llama-cpp` sirve `/v1/embeddings`, medido). Queda pinear el modelo y escribir el `rag-motor::RagMotor`, como `willay-rag` |
|
||
|
||
Lo segundo no se «aproxima»: `willay-rag` —el motor del centro de eventos, que es el ejemplo a
|
||
copiar— necesita un **daemon de embeddings** (`rimay-verbo`) y un **backend LLM real** (`pluma-llm`),
|
||
y su `try_build()` devuelve `None` cuando falta cualquiera de los dos, dejando el panel «no
|
||
disponible». Enseñar una búsqueda literal diciendo que es la semántica sería el peor cambio posible,
|
||
así que el verbo se llama `search`, el aviso está arriba de la función en el código, y acá queda
|
||
escrito qué falta exactamente.
|
||
|
||
**Lo que sí quedó, y por qué vale:** la identidad de una página archivada es el **BLAKE3 de su HTML
|
||
ya ejecutado** —lo que la persona vio, no lo que el servidor mandó—, no su URL. De ahí sale todo:
|
||
volver a la misma página no la duplica (suma una visita), y **la misma URL con otro contenido es otra
|
||
página**, o sea que el archivo tiene versiones. Un historial de direcciones guarda punteros, y los
|
||
punteros se pudren; esto guarda lo leído.
|
||
|
||
**Lo que NO se archiva es la parte que hay que mirar:** nada de una ventana privada, nada fuera del
|
||
marco principal (un `<iframe>` de publicidad no es una página que alguien leyó) y nada sin texto
|
||
visible (guardar el HTML de un visor de PDF llena el archivo de cosas que después no se encuentran).
|
||
El descarte de lo privado se comprueba en el fondo y no en el guión de contenido porque `tabs.get()`
|
||
es lo único que sabe si la pestaña lo es; y si no se puede saber, **no se archiva**.
|
||
|
||
⚠ **El control negativo distingue quién protege, y la respuesta medida NO es la que uno supondría.**
|
||
`scripts/test-atuq-archivo.py --negative-control` abre la página en una ventana privada, exige que no
|
||
se archive nada, y dice por qué. Medido sobre `atuq b3:5cfe3221`:
|
||
|
||
✓ en ventana privada no se archivó nada — lo impidió el NAVEGADOR
|
||
(la extensión no llegó a correr en la ventana privada)
|
||
|
||
O sea que hoy **el que protege es Gecko**: las extensiones no corren en ventanas privadas salvo que se
|
||
las habilite. Nuestro `if (emisor.tab.incognito) return` **nunca se ejecuta** con la configuración
|
||
actual. Eso no lo vuelve código muerto: es exactamente lo que haría seguro habilitar
|
||
`private_browsing` en `ExtensionSettings` el día que haga falta —y sin él, habilitarlo archivaría en
|
||
silencio lo que alguien abrió en privado—. Lo que sí sería un error es haber escrito «la extensión no
|
||
archiva lo privado» sin saber cuál de las dos cosas estaba pasando.
|
||
|
||
**Dos techos con nombre**, no escondidos en un `truncate`: el índice guarda 4 KiB de texto por página
|
||
—es JSON para poder leerse con `cat`, y uno sin techo deja de poder— y el corte va por CARÁCTER, no
|
||
por byte, porque `truncate` sobre medio multibyte entra en pánico y en un archivo personal el texto
|
||
con acentos es el caso normal.
|
||
|
||
#### 6.3.ter La otra mitad: preguntarle al archivo por lo que DECÍA (2026-09-12)
|
||
|
||
Hasta acá el archivo se buscaba **literal**: todas las palabras tienen que aparecer. Ahora también
|
||
se le puede preguntar por el significado (`archive.ask`), y las dos conviven porque son dos
|
||
preguntas: «la página que decía `EADDRINUSE`» es literal y no necesita ningún modelo.
|
||
|
||
**El muro no era un daemon ni un LLM, y el propio código lo decía mal.** El comentario de la
|
||
extensión afirmaba que el semántico «necesita un daemon de embeddings y un LLM de verdad», copiando
|
||
lo que hace `willay-rag`. Medido: para ordenar por parecido **no hace falta ningún LLM** —eso es un
|
||
coseno— y el «daemon de embeddings» resultó ser el mismo `llama-server` que ya levanta el chat, con
|
||
otro modelo. La corrección quedó escrita donde estaba la afirmación.
|
||
|
||
**Lo que se construyó, y dónde vive cada cosa** — que es la parte que las reglas del repo deciden,
|
||
no el gusto:
|
||
|
||
| pieza | dónde | por qué ahí |
|
||
|---|---|---|
|
||
| el protocolo de `llama-server` | `shared/foreign-llama` | **regla 4**: un dialecto ajeno entra por un puente. La primera versión vivía DENTRO de `puriy-costura::ia`, y cuando apareció el segundo consumidor —dos horas después— había dos clientes del mismo protocolo |
|
||
| el `Provider` de embeddings | `rimay-verbo-llama` | **regla 10**: la familia `rimay-verbo-*` ya es la abstracción de embeddings de la suite. Es el primer backend suyo que no pide nube ni descarga nada al arrancar |
|
||
| el índice y el coseno | `rimay-verbo-index::VectorIndex` | ya existían, con postcard y top-K. Escribir otro sería un segundo espacio vectorial sin dueño |
|
||
| cuándo se embebe y qué se pregunta | `puriy-costura` | es lo nuestro |
|
||
|
||
**Tres decisiones con su porqué:**
|
||
|
||
- **los vectores se calculan al PREGUNTAR, no al archivar.** Archivar una página no puede quedarse
|
||
esperando a que cargue un modelo, ni fallar porque la imagen no lo traiga. El precio es que la
|
||
primera pregunta paga el atraso, y por eso la respuesta dice cuántas quedaron indexadas;
|
||
- **es un SEGUNDO `llama-server`** (`--embedding --pooling mean`): llama.cpp carga un modelo por
|
||
proceso y el de chat no sabe embeber. Ese `--pooling` no es un gusto — sin él el endpoint contesta
|
||
un `400` que parece del cliente;
|
||
- **sin umbral de puntaje.** Los cosenos de e5 dan ~0,9 entre frases sin relación: sus vectores viven
|
||
en un cono estrecho, así que cualquier umbral «razonable» deja pasar todo o corta todo. Lo que
|
||
significa algo es el **orden**, y de eso se ocupa el top-K.
|
||
|
||
El guardián (`scripts/test-atuq-archivo-semantico.py`) archiva tres páginas con el navegador y
|
||
después le pregunta al host **sin navegador**, por el cable de 4 bytes y con los paths de producción
|
||
— así se comprueba de paso lo que el §6.3 promete: que el archivo quedó en disco. Pregunta **dos**
|
||
cosas con ganadores distintos, a propósito: con una sola, un ranking constante —o uno que devuelva
|
||
siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas **no comparten palabras**
|
||
con el texto de su página; si las compartieran, la búsqueda literal también acertaría y esto no
|
||
probaría nada semántico.
|
||
|
||
**MEDIDO en la jaula, sobre los artefactos sellados** (`atuq bfc14c92`, `puriy-costura 3f31233e`,
|
||
`ia-modelo-embeddings 2c0c4258`):
|
||
|
||
```
|
||
ARCHIVADA … /gato.html ARCHIVADA … /red.html ARCHIVADA … /pan.html
|
||
«¿dónde se echó a dormir el gato?» 0.6252 gato · 0.2471 red · 0.0973 pan
|
||
«cómo bloqueo internet a un programa» 0.4039 red · 0.1398 pan · 0.1066 gato
|
||
por la barra lateral 0.6252 /gato.html …
|
||
```
|
||
|
||
Y el control negativo: sin modelo, `no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf:
|
||
esta imagen no trae ninguno pineado` — **y ningún orden inventado**, que es el único fallo inaceptable
|
||
en una búsqueda.
|
||
|
||
⚠ **El guardián nació midiendo nada, y lo dijo el propio arnés.** La primera versión metía las tres
|
||
páginas en `<iframe>` para archivarlas en una sola carga, y archivó **cero**: el §6.3 ignora a
|
||
propósito lo que no es el marco principal —un iframe de publicidad no es una página que alguien
|
||
leyó—. Encadenadas como navegación de verdad (cada página lleva a la siguiente con un `meta
|
||
refresh`), las tres entran. De paso: las dos páginas de tránsito van **sin texto visible**, así el
|
||
archivo las descarta y la evidencia no lista dos coincidencias sin título.
|
||
|
||
#### 6.3.quinquies El modelo de embeddings es una DESCARGA OPCIONAL, no parte de la imagen (2026-09-12)
|
||
|
||
Decisión del usuario, y la que corresponde a los números: el motor (199 M) y el modelo de chat
|
||
(1,04 GiB) van en las cuatro imágenes de escritorio porque la barra lateral está en las cuatro; el
|
||
modelo de embeddings **no**, porque son 610 MiB más para una función —preguntarle al archivo por
|
||
significado— que no todo el mundo usa.
|
||
|
||
**Y «opcional» no significa «lo armás vos».** El modelo ya es una receta del corpus, así que la
|
||
maquinaria de la Etapa F lo convierte en paquete sin trabajo extra: `scripts/build-repo.sh` empaqueta
|
||
**todas** las recetas, y el catálogo queda con su `expected_hash` anclado y sus deps declaradas
|
||
(`llama-cpp`, `curl`, `busybox` — publicadas también, que si no `install` no puede reproducirlo).
|
||
|
||
```
|
||
takana install ia-modelo-embeddings --repo <dir|url>
|
||
```
|
||
|
||
Lo que hace que esto sea opcional **de verdad** es una sola línea: la receta **no está declarada en
|
||
ningún perfil**. Eso es una decisión, no un olvido, y está escrito acá para que nadie la «arregle»
|
||
agregándola.
|
||
|
||
⚠ **Y la mitad que separa «opcional» de «invisible»**: cuando el modelo falta, el host ya no dice
|
||
sólo «no está» — dice **cómo conseguirlo**. Con el modelo ausente, la ausencia es el caso NORMAL, no
|
||
un error de instalación, y un mensaje sin siguiente paso deja al usuario mirando una función que no
|
||
sabe que existe:
|
||
|
||
```
|
||
no hay modelo de embeddings en /usr/share/takana/ia/embeddings.gguf: es una descarga opcional
|
||
— instalalo con `takana install ia-modelo-embeddings`
|
||
```
|
||
|
||
**El catálogo, firmado y verificado en los dos sentidos.** Publicar el paquete no alcanza: un
|
||
catálogo sin firma deja a `install --require-signed` sin nada que comprobar, y una firma con clave
|
||
efímera es decoración (SDD 19 §3.2). Se firmó con la **clave estable** del SDD 28 §5.1 —pública en
|
||
`trust/`, privada fuera de git— y se comprobó como corresponde:
|
||
|
||
| `--trust` | resultado |
|
||
|---|---|
|
||
| `./trust` | `release: trusted (by release) — 94 paquete(s)` |
|
||
| un directorio vacío | `release: unknown-key … añadí su clave para confiar` |
|
||
|
||
⚠ Esto es un **ensayo local**: `dist/repo` no está en git y la publicación de verdad es la caja del
|
||
SDD 28 §5. Lo que queda demostrado acá es que el paquete y su cierre entran al catálogo firmado sin
|
||
inventar nada nuevo.
|
||
|
||
⚠ **Y tres cosas que sólo aparecieron al instalarlo de verdad, no al empaquetarlo:**
|
||
|
||
1. **el cierre no estaba publicado**: faltaban `llama-cpp` y `cmake`. Un paquete cuyo cierre no está
|
||
en el catálogo existe y **no se instala**;
|
||
2. **tres paquetes llevaban el ancla de sanidad por default** (`/usr/bin/<nombre>`), que en una
|
||
librería o en un modelo no existe — `install` habría fallado al verificar algo que nunca iba a
|
||
estar;
|
||
3. **el instalador no podía instalar a otro filesystem**: `Invalid cross-device link`, justo en el
|
||
caso que su propia ayuda promete. Y no hace falta tener dos discos: `linkat` da EXDEV **entre dos
|
||
mounts del mismo dispositivo**, y el store de esta máquina es un bind-mount. Arreglado con una
|
||
copia ante EXDEV —y sólo ante EXDEV— en `takana-build::hydrate`, con su test cruzando a `/dev/shm`.
|
||
|
||
#### 6.3.quater El modelo que elegimos NO SERVÍA, y el síntoma no se parecía a la causa (2026-09-12)
|
||
|
||
Esto es la unidad de trabajo más caras de esta tanda y vale escribirla entera, porque la clase de
|
||
fallo se repite: **un modelo de embeddings roto no falla — contesta vectores plausibles y ordena al
|
||
azar.**
|
||
|
||
La primera elección fue **multilingual-e5-small** (MIT, 118 M, el tamaño ideal, y lo que pedía el
|
||
caso: español). Sellaba, cargaba, contestaba 384 dimensiones, el endpoint funcionaba y los 45 tests
|
||
de la suite estaban en verde. Y la primera medición con el modelo DE VERDAD, de punta a punta, dio
|
||
esto:
|
||
|
||
```
|
||
«¿dónde se echó a dormir el gato?» 0.9177 gato · 0.8986 red · 0.8965 pan ✓
|
||
«cómo bloqueo internet a un programa» 0.9337 gato · 0.9112 red · 0.9067 pan ✗
|
||
«qué lleva el pan» 0.9233 gato · 0.9066 red · 0.9063 pan ✗
|
||
```
|
||
|
||
Gana siempre la misma página. La caza, en el orden en que se hizo —cada paso descartando una
|
||
hipótesis, no confirmándola—:
|
||
|
||
1. **¿el batching?** No: los puntajes son idénticos pidiendo los pasajes juntos o de a uno;
|
||
2. **¿la posición?** No: cambiando el orden de los pasajes los puntajes no se mueven. (Y esto mató
|
||
además una sospecha razonable: mi medición «buena» anterior tenía el pasaje correcto en la
|
||
posición 0, así que podía haber sido un falso positivo. No lo era, pero no se sabía);
|
||
3. **¿nuestro código?** No: los mismos textos a mano contra el servidor dan lo mismo;
|
||
4. **¿la cuantización nuestra?** No: el fp32 sin tocar falla igual;
|
||
5. **¿la conversión de terceros?** No: dos conversos independientes fallan **idéntico** — y ese
|
||
«idéntico» era la pista que estaba a la vista desde el principio: `gato~perro` y
|
||
`gato~cortafuegos` daban **el mismo número a cuatro decimales**;
|
||
6. **es el TOKENIZADOR**, y `/tokenize` lo dijo en una línea: «cortafuegos», «minino», «duerme» y
|
||
«tejado» van todos al **id 100 = `<unk>`**. Un vocabulario XLM-RoBERTa por esta ruta deja casi
|
||
todo en desconocido, y un texto que es todo `<unk>` embebe igual que cualquier otro.
|
||
|
||
**Lo que se pineó en su lugar: Qwen3-Embedding-0.6B Q8_0**, el GGUF **oficial** de Qwen
|
||
(Apache-2.0). Tokeniza español de verdad —`cortafuegos` → `cort|af|uegos`—, acierta **3/3** con
|
||
márgenes anchos (0,649 contra 0,237 el segundo) y es de la misma familia que el modelo de chat, que
|
||
ya estaba medido contestando en español. Cuesta **610 MiB**, cinco veces el e5 que no servía: es el
|
||
precio de que funcione, y cambia la cuenta de la imagen (motor + chat + embeddings ≈ 1,85 GiB).
|
||
|
||
Dos configuraciones que el número decidió, no el gusto: **`--pooling last`** (con `mean` también
|
||
acierta 3/3, pero pegado: 0,574 contra 0,557) y **la instrucción delante de la consulta** (sin ella,
|
||
2/3 — no es decorado).
|
||
|
||
⚠ **Y la regla que queda, que ahora VIVE en la receta**: el guardián del `install` ya no mira sólo el
|
||
mágico y el tamaño. Tokeniza dos frases en español sin palabras en común y exige que **no compartan
|
||
tokens** (con el e5 roto compartían la mitad, todos `<unk>`), y después levanta el servidor de verdad
|
||
y exige que **un gato se parezca más a un perro que a un cortafuegos**. Con esos dos chequeos, el e5
|
||
no habría sellado nunca — y la caza de seis pasos no habría hecho falta.
|
||
|
||
|
||
### 6.4 `qullqa` con su advertencia puesta — y los TRES bloqueos, medidos (2026-09-12)
|
||
|
||
Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene
|
||
eso; el modo incógnito es teatro. ⚠ Con la advertencia que el propio SDD de `qullqa` ya anotó y que
|
||
**hay que repetir acá, no esconder**: en una máquina con swap sin cifrar, el documento no aplica.
|
||
Se promete lo que se puede probar.
|
||
|
||
**Qué sería, concretamente:** el perfil de `atuq` —historial, cookies, caché, y el archivo personal
|
||
del §6.3— vive DENTRO de un pozo de `qullqa`, montado mientras el navegador corre y desmontado al
|
||
cerrar. Un registro de la máquina no encuentra un perfil de navegador que negar: no hay forma de la
|
||
que sospechar. Es la promesa del dominio (`A2`: un control, una requisa, un funcionario mirando la
|
||
laptop), no una nuestra.
|
||
|
||
**Lo que se midió antes de escribir una línea de código, y bloquea:**
|
||
|
||
1. **El kernel de la distro no trae FUSE.** `# CONFIG_FUSE_FS is not set` en el `.config` del
|
||
artefacto sellado (`linux-metal`, 6.16.12). Y `qullqa-vfs` proyecta el pozo **por FUSE**
|
||
(`fuser`, con `montar`/`montar_de_fondo`). Sin ese símbolo no hay pozo montable: es una línea en
|
||
`recipes/linux*.toml` y **un rebuild de kernel de ~35 min que mueve su ArtifactHash y con él las
|
||
imágenes** — o sea, una decisión, no un ajuste al pasar.
|
||
2. **No hay binario que lo monte.** Los cuatro crates de `qullqa` son librerías y lo dicen («No
|
||
binary»). Hace falta algo que abra el pozo, lo monte, lance el navegador y lo desmonte al salir.
|
||
Eso es superficie nueva con nombre nuevo — la misma clase de decisión que fue `puriy-costura`.
|
||
3. **La contraseña es UX, no criptografía.** El pozo se abre con una frase estirada con Argon2id.
|
||
Quién la pide, cuándo, y qué pasa si se cierra la sesión a la mitad es lo que decide si esto se
|
||
usa o se apaga al tercer día. `qullqa` no la contesta porque no es su pregunta.
|
||
|
||
**Y la incógnita del helper ya está contestada, leyendo la fuente** (`fuser` 0.15.1,
|
||
`src/mnt/fuse_pure.rs`; el workspace lo declara con `default-features = false`, así que no hay
|
||
`libfuse` en tiempo de build):
|
||
|
||
- `fuse_mount_pure` intenta **primero `fuse_mount_sys`** —el `mount(2)` directo sobre `/dev/fuse`—,
|
||
que **funciona como root y no necesita ningún helper**. Sólo si eso no se permite cae a
|
||
`fusermount3`;
|
||
- desmontar hace `umount2(MNT_DETACH)` directo, con el helper también como respaldo;
|
||
- **la excepción es `MountOption::AutoUnmount`**: esa opción fuerza el camino del helper, siempre.
|
||
|
||
⇒ La imagen **no necesita una receta de `fuse3`** mientras quien monte corra como root y no pida
|
||
`AutoUnmount`. Si algún día se pide, hace falta el helper — y conviene que esté escrito antes de que
|
||
alguien lo agregue «porque es más prolijo desmontar así».
|
||
|
||
⇒ Estado: **el §6.4 no está empezado, y ahora se sabe por qué**. Los tres bloqueos son nombrables y
|
||
ninguno es del navegador.
|
||
|
||
### 6.5 Foco por `cortafuegos` — la mitad del sistema, MEDIDA (2026-09-10)
|
||
|
||
**La primera corrección es a este propio documento.** La fila de la tabla dice «foco por
|
||
`cortafuegos`» y es fácil leerla como «bloquear sitios que distraen». **El cortafuegos de tawasuyu no
|
||
sabe de sitios**: su política de egress (`cortafuegos-core::Politica`) tiene exactamente una
|
||
dimensión —**qué cgroup puede salir**— y ninguna de destino; ni dominio, ni IP, ni puerto. Leído en
|
||
la fuente (`UnidadRed { nombre, cgroup_path, concesion }`), no supuesto. Así que el foco que estas
|
||
piezas permiten no es «Twitter no carga»: es **«el navegador no sale»**, con todo lo local
|
||
—incluido el archivo personal del §6.3 y el CAS del §6.2— funcionando igual. Que es, además, una
|
||
promesa más honesta: una lista de dominios se esquiva con un espejo, y un cgroup sin egress no.
|
||
|
||
**Y la diferencia con una extensión no es de grado.** Un bloqueador de extensión lo apaga en dos
|
||
clics la misma persona que quería no distraerse; un reglaset `nft` que aplicó root no lo toca el
|
||
navegador ni sus extensiones. Por eso la fila decía «nadie lo tiene»: nadie es dueño del navegador
|
||
*y* del sistema.
|
||
|
||
**Lo que hizo falta traer.** `nftables` estaba en el grafo como `wanted` y nadie había puesto la
|
||
cadena: entran `recipes/libmnl.toml`, `recipes/libnftnl.toml` y `recipes/nftables.toml` (1.1.6, la
|
||
versión contra la que el SDD del cortafuegos validó `socket cgroupv2` en metal). Dos cosas venían de
|
||
fábrica y están en los comentarios de la receta: nftables **no reproduce** —`MAKE_STAMP` es
|
||
`$(shell date +%s)` horneado en el binario, la familia del `BuildID` de waterfox— y su
|
||
`config.status` trae un bashismo que busybox rechaza. Las dos se arreglan en el parche, y el control
|
||
existe: con el sello vivo, dos builds de la misma fuente **divergen** en `libnftables.so`
|
||
(`why-differs`: `.data`, `.text`); con el parche, `verificar-repro.sh` da REPRODUCE.
|
||
|
||
**La medición, que es la unidad de trabajo de verdad** (`scripts/test-foco-egress.sh`, lanzado con
|
||
`scripts/foco-egress-remoto.sh`):
|
||
|
||
```
|
||
nft: nftables v1.1.6 ← el NUESTRO, musl, desde el artefacto sellado
|
||
CONEXION linea-base exit=0 ← sin reglas: el harness mide algo
|
||
CONEXION control exit=0 ← con reglas: el cgroup permitido SÍ conecta
|
||
CONEXION foco exit=1 ← el cgroup del navegador NO
|
||
```
|
||
|
||
Tres medidas y no una: la **línea base** existe porque un harness roto («no conectó») se lee
|
||
exactamente igual que un foco que funciona, y el **control** porque un reglaset que niega de más no
|
||
se distingue de uno que niega bien. Con `--broken-rules` —que le abre egress también al cgroup en
|
||
foco— el guardián falla diciendo `el cgroup EN FOCO conectó igual`, y sale 1.
|
||
|
||
**Por qué no corre en el hub, y es una restricción real, no comodidad.** Crear un cgroup pide root:
|
||
medido, `mkdir /sys/fs/cgroup/...` da EPERM **incluso dentro de un userns con `--cap-add ALL`**,
|
||
porque el permiso se chequea contra la jerarquía real. Y `nft -c` **no es un chequeo de sintaxis**:
|
||
resuelve el path del cgroup contra la máquina viva y falla con `cgroupv2 path fails` si no existe. O
|
||
sea que ni siquiera *verificar* una política del cortafuegos se puede sin los cgroups puestos. Corre
|
||
en el LXC prestado, y todo va dentro de `unshare -m --propagation private -n`.
|
||
|
||
⚠ **Y ese `--propagation private` se pagó caro.** `/sys/fs/cgroup` está montado **shared**, así que
|
||
desmontar la COPIA de un `--rbind` **se propaga al montaje real**: dos corridas dejaron al worker
|
||
**sin `cgroup2` montado**, en silencio —los builds seguían— hasta que el guardián siguiente dijo «no
|
||
hay cgroup2 montado». Se remontó y la jerarquía volvió intacta (los cgroups viven en el kernel, no en
|
||
el montaje). Tres lecciones que quedan en el guión: los montajes van en un **mount namespace propio**
|
||
y se van solos; **nunca `umount -l`** antes de un `rm -rf` (un desmontaje perezoso desaparece de
|
||
`/proc/mounts` mientras el árbol sigue ahí, así que la guarda pasa y el borrado entra en el `/sys` de
|
||
la máquina); y antes de borrar, **preguntarle a `/proc/mounts`**.
|
||
|
||
⚠ **Tres falsos positivos más que cazó el propio harness, y todos se veían como éxito:**
|
||
|
||
1. un `gmp` **viejo** empaquetado por `ls store/*-gmp` (sólo `libgmp.a`) ⇒ `libnftables.so` no
|
||
relocaba y **ninguna** conexión salía. En el hub no se había notado porque ahí el lab tiene su
|
||
propia libgmp y la tapaba: el artefacto parecía autosuficiente y no lo era. Ahora los artefactos
|
||
se resuelven por `takana hash` (vigente), y el guardián corre `nft --version` **antes** de todo;
|
||
2. `/sbin` fuera del `PATH` del chroot ⇒ no había `ip`, el loopback quedaba caído y todo fallaba;
|
||
3. y el mejor: **un comentario que se ejecutó**. El guión interior se escribía con un heredoc sin
|
||
comillas, y un comentario que mencionaba `` `ip` `` y `` `ifconfig` `` entre backticks los corrió
|
||
**en el host** y pegó la salida —con las IPs de la máquina— dentro del guión generado. Ahora el
|
||
heredoc va entrecomillado y los nombres entran por entorno.
|
||
|
||
**Lo que falta para que esto sea un botón, y por qué no se inventó de paso.** Dos piezas, ninguna del
|
||
navegador:
|
||
|
||
- **quién pone a `atuq` en un cgroup.** Hoy nadie: los cgroups de la distro los crea arje para sus
|
||
servicios, y una app de escritorio la lanza el usuario. Hace falta delegación de un subárbol a la
|
||
sesión, que es decisión de arje (SDD 10), no de este documento;
|
||
- **quién aplica la política.** `cortafuegos apply` pide root. Un demonio que lo haga a pedido del
|
||
navegador es superficie nueva: se decide, no se agrega de paso.
|
||
|
||
Mientras eso no exista, lo que `atuq` puede tener —y tiene, §6.5.bis— es la mitad honesta: **ver** el
|
||
estado del foco y **no poder apagarlo**.
|
||
|
||
#### 6.5.bis La mitad del navegador: `focus.state`, y la asimetría a propósito (2026-09-10)
|
||
|
||
La extensión `foco` **sólo lee**. Pregunta `focus.state` al host cada minuto —el estado lo cambia root
|
||
por fuera, no hay evento al que suscribirse— y pinta tres cosas: `foco`, nada, o **`?`**. El tercer
|
||
caso es el que importa: el host contesta `unknown` cuando nadie escribió el estado o cuando el
|
||
contenido no se entiende, y **eso no se redondea a `off`**; una insignia que dijera «sin foco» sin
|
||
haber mirado afirmaría un hecho que no tiene.
|
||
|
||
**Y no hay verbo para apagarlo.** `focus.stop` cae en «verbo desconocido», igual que `focus.off` y
|
||
`focus.start`. No es una omisión pendiente: es la propiedad entera. Si el navegador pudiera levantar
|
||
el foco, esto valdría exactamente lo que vale un bloqueador de extensión —dos clics— que es el
|
||
problema que el §6.5 venía a resolver. Encenderlo tampoco está: aplicar un reglaset pide root y este
|
||
proceso corre como el usuario. Hay un test en tawasuyu que lo fija por nombre, y se puso rojo a
|
||
propósito agregando un `focus.stop` «por comodidad».
|
||
|
||
El estado vive en `/etc/takana/focus` (lo escribe root junto con el reglaset). `scripts/test-atuq-foco.py`
|
||
corre **el path de producción, no la escotilla de pruebas** —una escotilla mide el código, no el
|
||
contrato con la imagen— en tres sesiones del navegador, y en cada una comprueba tres cosas: qué dijo
|
||
la extensión, qué pintó, y que el fichero **quedó igual** después de la sesión. Eso último es la
|
||
promesa del §6.5 medida del lado del navegador. Verificado rompiéndolo: con una extensión parcheada
|
||
que pinta «foco» pase lo que pase (dentro de una COPIA del artefacto, vía `ATUQ_DIR`), el guardián
|
||
falla nombrando la insignia.
|
||
|
||
```
|
||
-- fichero de foco: on → ESTADO on · INSIGNIA "foco" · quedó on
|
||
-- fichero de foco: off → ESTADO off · INSIGNIA "" · quedó off
|
||
-- fichero de foco: (no existe) → ESTADO unknown · INSIGNIA "?" · siguió ausente
|
||
```
|
||
|
||
### 6.6 Medios fuera del navegador — HECHO (2026-09-10)
|
||
|
||
Una navegación de PRIMER NIVEL a un medio (`Content-Type: video/*` o `audio/*`) se cancela y la URL se
|
||
la lleva el reproductor de la distro. `mpv` ya viaja en las cuatro imágenes de escritorio, así que
|
||
esto no agrega ni una receta: es cablear lo que ya estaba.
|
||
|
||
**La regla es estrecha a propósito.** Un `<video>` embebido es parte de la página y sacarlo de ahí
|
||
rompería el sitio que lo puso; lo que se cambia es el caso en que el navegador no estaba pintando una
|
||
página sino haciendo de reproductor —seguiste un enlace a un `.mp4`—. Las reglas anchas en el camino
|
||
de cada petición son las que terminan rompiendo la web de alguien.
|
||
|
||
**La seguridad del verbo es que el binario NO viene del cable**: es una constante del host
|
||
(`/usr/bin/mpv`). Si el programa viajara en el mensaje, esto dejaría de ser «abrir un vídeo» y sería
|
||
un lanzador de procesos arbitrarios manejado, al final de la cadena, por una página web. Sólo viaja la
|
||
URL, sólo `http(s)`, y detrás de un `--`.
|
||
|
||
**Fail-open, y es la promesa que el control negativo prueba.** Cancelar es SÍNCRONO y la respuesta del
|
||
host llega después, así que no se puede saber en el momento si el reproductor arrancó. Se cancela sólo
|
||
si el puerto está vivo, y si el host contesta que no pudo, la URL **vuelve al navegador** (con una
|
||
marca para no entrar en bucle). Medido:
|
||
|
||
SIN REPRODUCTOR no hay reproductor en /usr/bin/mpv: esta imagen no lo trae
|
||
VUELVE AL NAVEGADOR http://…/audio.wav?atuq-medios=no
|
||
|
||
Quedarse sin vídeo Y sin pestaña es el único resultado inaceptable, y es el que se consigue si uno
|
||
confía en que salió bien.
|
||
|
||
**⚠ Y el bug que esto destapó vale más que la función.** La primera corrida hizo todo bien —detectó el
|
||
medio, canceló, el host contestó con el pid— y después:
|
||
|
||
DESCONECTADO Native application tried to send a message of 546281442 bytes,
|
||
which exceeds the limit of 1048576 bytes.
|
||
|
||
**Un hijo hereda los descriptores del padre, y los del padre SON la tubería de native messaging.** El
|
||
reproductor escribía su salida ahí y el navegador la leía como un marco. Tres redirecciones, cada una
|
||
contra un fallo distinto: `stdin` a null (`mpv` lee stdin para sus comandos: heredándolo **se come los
|
||
mensajes del navegador**), `stdout` al **`stderr` del host** —no a `/dev/null`: apagarlo arreglaría el
|
||
bug y se llevaría puesto el diagnóstico, y Gecko manda ese `stderr` al log del navegador, que es donde
|
||
uno mira cuando «el vídeo no abrió»— y `stderr` heredado por lo mismo.
|
||
|
||
El test que lo fija **arranca el binario, no la lib**, y ésa es la parte reutilizable: en proceso, el
|
||
`stdout` del host es el del arnés y la contaminación no se ve — el test habría pasado con el bug
|
||
puesto. Se comprobó rompiéndolo a propósito.
|
||
|
||
⚠ Y una nota del arnés que ya apareció dos veces: la URL no se pasa por la línea de comandos, porque
|
||
una petición del ARRANQUE compite con la inicialización de la extensión. El guardián navega a una
|
||
página que redirige a los tres segundos — que además es lo que hace una persona: seguir un enlace.
|
||
|
||
### 6.7 El motor de inferencia local — ENTRA AL CORPUS (2026-09-11)
|
||
|
||
**La primera corrección es al propio §6.** La tabla de arriba le pone costo «bajo» a la IA local
|
||
porque las piezas parecían estar escritas en tawasuyu (`rimay`, `iniy`). Se fue a mirar antes de
|
||
empezar y **no alcanzan**, por dos razones independientes:
|
||
|
||
| pieza | por qué no corre un modelo acá |
|
||
|---|---|
|
||
| `pluma-llm` | sus backends son `anthropic`, `cohere`, `claude-cli` y `mock`. **Todos de nube o de juguete** |
|
||
| `rimay-verbo-fastembed` | trae `fastembed` con la feature `ort-download-binaries`: **baja onnxruntime precompilado (glibc) y el modelo de HuggingFace en el primer arranque**. En un build hermético y en una distro musl eso no es lento, es imposible |
|
||
|
||
Y eso además **junta dos pendientes que el plan llevaba separados**: el §6.7 (la barra lateral) y la
|
||
mitad semántica del §6.3 (preguntarle al archivo personal en lenguaje natural) no eran dos problemas.
|
||
Eran uno: **el corpus no tenía con qué correr un modelo.** El muro tiene forma de receta, y una sola
|
||
lo derriba entero — el mismo binario sirve `/v1/chat/completions` (el §6.7) y `/v1/embeddings` (lo
|
||
que `rag-motor` exige para el §6.3).
|
||
|
||
`recipes/llama-cpp.toml` ⇒ **b10901, estática, 199 M**, 16 binarios sin un solo `NEEDED`.
|
||
`llama-server`, `llama-cli`, y de yapa `llama-quantize`/`llama-bench`/`llama-perplexity`, que son las
|
||
tres herramientas con las que se mide un modelo **antes** de pinearlo. **REPRODUCE** bit a bit
|
||
(`verificar-repro.sh`: 0 no-determinismos).
|
||
|
||
#### ⚠ La variable que nos da reproducibilidad le apagó el AVX2 al motor, y el build salió 0
|
||
|
||
Es lo único de esta unidad que no se podía deducir, y por eso va escrito acá con la línea leída.
|
||
|
||
La receta nació con `-DGGML_NATIVE=OFF`, apoyada en `ggml/CMakeLists.txt:141`:
|
||
|
||
```cmake
|
||
if (GGML_NATIVE OR NOT GGML_NATIVE_DEFAULT)
|
||
set(INS_ENB OFF) # ← con NATIVE=ON, ggml se apoya en -march=native y apaga las perillas
|
||
else()
|
||
set(INS_ENB ON) # ← con NATIVE=OFF *debería* encender SSE4.2/AVX/AVX2/BMI2/FMA/F16C
|
||
endif()
|
||
```
|
||
|
||
Leído así, `GGML_NATIVE=OFF` era exactamente lo que queríamos: nada de `-march=native` (que sería un
|
||
artefacto distinto por máquina — la familia del `MAKE_STAMP` de nftables) y aun así un binario
|
||
vectorizado. **Y es falso en este sandbox**, por una línea 36 renglones más arriba:
|
||
|
||
```cmake
|
||
if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})
|
||
set(GGML_NATIVE_DEFAULT OFF)
|
||
```
|
||
|
||
El sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`) **justamente para que los
|
||
builds reproduzcan**. Con eso `GGML_NATIVE_DEFAULT` queda OFF, `NOT GGML_NATIVE_DEFAULT` es
|
||
verdadero, e `INS_ENB` termina en OFF: las seis perillas apagadas. Dicho corto: **la variable que
|
||
existe para que el build reproduzca apagó todas las instrucciones vectoriales del motor de
|
||
inferencia.**
|
||
|
||
⚠ **Y no falló nada.** El artefacto selló, `llama-cli --version` contestaba, el binario corría. Medido
|
||
sobre el artefacto sellado `b3:e3c62185`, no sobre el log:
|
||
|
||
| | primer sello (`-DGGML_NATIVE=OFF` a secas) | con las seis declaradas |
|
||
|---|---|---|
|
||
| `%ymm` (AVX2) | **0** | 42.356 |
|
||
| `vfmadd` (FMA) | **0** | 1.298 |
|
||
| `roundps` (SSE4.2) | **0** | 86 |
|
||
|
||
Un motor de inferencia sin AVX2 no está roto: está varias veces más lento, y **ninguna métrica del
|
||
repo lo dice**. Es la figura de siempre — un ausente falla ruidosamente; un desviado llega hasta el
|
||
final diciendo que todo fue bien. Por eso las seis van declaradas una por una, no se confía en el
|
||
default de nadie, y **el `install` las comprueba en el binario**: es el único punto donde este modo
|
||
de fallar se puede ver.
|
||
|
||
⚠ Eso fija un **piso declarado: x86-64-v3**. Un CPU sin AVX2 (pre-2013) muere con SIGILL, no con un
|
||
mensaje. Medido antes de elegirlo: `momento`/gioser, el worker `dev.gioser.net` y el objetivo de
|
||
metal (TigerLake) traen los seis flags. Bajar el piso o publicar variantes es
|
||
`docs/plan-variantes-cpu.md`, no esta receta.
|
||
|
||
De paso, `strip_debug = true`: zig cc emite `debug_info` por defecto y acá son 16 binarios
|
||
**estáticos**, o sea 16 copias de los símbolos de `libllama`+`libggml`+`libc++`. **1,8 G → 199 M**.
|
||
|
||
#### El guardián, y lo que cada sonda afirma
|
||
|
||
`scripts/test-llama-cpp.py`, sobre el artefacto **vigente** (resuelto por `takana hash`, nunca por
|
||
`ls store/*` — es la trampa del `gmp` viejo del §6.5), con escotilla `LLAMA_DIR` que grita al usarse:
|
||
|
||
```
|
||
1. ISA 42.356 %ymm · 1.298 vfmadd · 86 roundps ← control negativo MEDIDO: 0/0/0
|
||
2. hermético llama-server y llama-cli: sin NEEDED
|
||
3. inferencia una pasada REAL con stories260K (1,1 MB, pineado por sha256) ⇒ 51 caracteres
|
||
4. embeddings vector de 64 dimensiones · mismo texto = mismo vector ·
|
||
textos distintos = vectores distintos · no son ceros
|
||
```
|
||
|
||
La sonda 4 es la que le importa al §6.3, y sus tres comprobaciones no son ceremonia: **«¿devuelve un
|
||
vector?» lo pasa un stub de ceros**, y «¿devuelve algo distinto?» lo pasa un generador de ruido. Hace
|
||
falta que el mismo texto dé lo mismo *y* que textos distintos den cosas distintas.
|
||
|
||
El control negativo es vivo: `--sin-modelo` corre las mismas sondas contra un modelo inexistente y
|
||
**tiene que fallar** — sin eso, un arnés roto («no contestó») se lee igual que un motor que anda.
|
||
Y el censo después de correr: cero `llama-server` sueltos.
|
||
|
||
⚠ **Dos trampas del arnés que van a volver cuando se pinee el modelo de producción:**
|
||
|
||
1. **`/health` contesta MIENTRAS carga**, con 503 y `{"status":"loading model"}`. Tomar «contestó»
|
||
por «listo» manda la primera consulta contra un servidor a medio levantar, y el 503 se lee como
|
||
«el endpoint no existe». Se espera el `ok`, no el 200.
|
||
2. **`--pooling mean` no es un gusto.** Un GGUF declara su tipo de pooling en los metadatos y los
|
||
modelos de embedding de verdad (e5, bge, nomic) lo traen; `stories260K` es un LM causal de juguete
|
||
y no lo trae, así que llama.cpp cae en `none` y el endpoint compatible con OpenAI contesta
|
||
**`400: Pooling type 'none' is not OAI compatible`** — no un vector vacío, no un 500: un 400 que
|
||
parece un error del cliente.
|
||
|
||
#### ⚠ Lo que esto NO es
|
||
|
||
**Esto es el motor, no la función.** Un motor sin modelo no contesta nada, y `atuq` todavía no tiene
|
||
barra lateral de IA. Falta, y tiene forma:
|
||
|
||
- **el modelo**, que es **fuente pineada por sha256**, no receta — el mismo patrón que el perfil de
|
||
PGO de firefox. Son dos decisiones distintas y las dos cuestan disco en la imagen: uno de
|
||
embeddings para el §6.3 (chico, ~100 MB) y uno de chat para el §6.7. `stories260K` es un modelo
|
||
REAL pero de juguete: sirve para ejercitar la cadena, no para contestar;
|
||
- **los verbos en el host** (`puriy-costura`): un `ai.ask` y un `archive.search` semántico que hablen
|
||
con `llama-server`. Hoy `archive.search` es LITERAL y por eso se llama `search`;
|
||
- **quién levanta el servidor.** Misma pregunta que dejó abierta el torrent del §6.9, y ahí ya hay
|
||
respuesta escrita: un daemon propio, perezoso, con `setsid` para que sobreviva al navegador.
|
||
|
||
`llama-cpp` **no está declarada en ningún perfil todavía**, y es a propósito: una imagen no debería
|
||
crecer 199 M por una función que aún no existe. Se declara cuando el §6.7 se pague entero.
|
||
|
||
#### 6.7.bis La barra lateral YA PREGUNTA — y el modelo sigue siendo una decisión (2026-09-11)
|
||
|
||
Lo que faltaba eran tres cosas; dos están hechas y la tercera es una decisión, no trabajo.
|
||
|
||
**El verbo (`ai.ask`) y quién levanta el servidor.** Se resolvió **al revés que el torrent del §6.9**,
|
||
y la diferencia importa: allá el daemon sobrevive al navegador porque un torrent tiene trabajo que
|
||
sigue; acá el servidor **se muere con el navegador**, porque medio giga de modelo en RAM no es trabajo
|
||
pendiente, es peso. No hay servicio de IA en la imagen: el primer `ai.ask` levanta `llama-server` y el
|
||
host lo mata al salir.
|
||
|
||
**El transporte es un socket UNIX**, no un puerto: `llama-server` acepta `--host <ruta>.sock`
|
||
(medido). No colisiona, no queda expuesto a la red y no hay que buscar puerto libre. El cliente HTTP
|
||
son treinta líneas porque el servidor contesta con `Content-Length` — medido, y si algún día
|
||
contestara `chunked` **falla diciéndolo** en vez de entregar medio cuerpo.
|
||
|
||
**Lo que pasa si a `puriy-costura` lo matan con `SIGKILL`**: el servidor queda. La red natural sería
|
||
`PR_SET_PDEATHSIG`, pero pedirlo exige `Command::pre_exec`, que es `unsafe`, y la caja lleva
|
||
`#![forbid(unsafe_code)]` — **una propiedad de la caja no se negocia por una comodidad de ciclo de
|
||
vida**. El residuo se ACOTA en su lugar: si al preguntar ya hay un servidor vivo **con el mismo
|
||
modelo**, se adopta; así lo peor que queda es uno, y la sesión siguiente lo hereda. Si el que está
|
||
vivo tiene OTRO modelo, se niega nombrándolos: contestar con un modelo que no es el de la imagen sería
|
||
mentir sobre quién contestó.
|
||
|
||
**La barra lateral** (`extensions/ia/`) es una caja de texto y el hilo de respuestas. La página no
|
||
habla native messaging: habla con el fondo, y el fondo con el host — si el puerto nativo viviera en la
|
||
página, cerrar la barra lateral se llevaría el proceso del host **y el modelo cargado** con ella.
|
||
`panel.html?q=…` abre el panel con la pregunta ya hecha, que es la puerta por la que entrará
|
||
«preguntar sobre la selección» y la que usa el guardián.
|
||
|
||
`scripts/test-atuq-ia.py` mide la cadena entera con **inferencia de verdad** —el `llama-cpp` del
|
||
corpus contra el modelo pineado del guardián del motor, importado de allá para que el sha256 viva en
|
||
un solo sitio— y dentro de una jaula **`--unshare-net`**: no hubo red para consultar a nadie más. Tres
|
||
aserciones: que la respuesta venga del modelo de la imagen, que **no venga vacía**, y que al cerrarse
|
||
el navegador queden **cero `llama-server`**.
|
||
|
||
⚠ Ese censo nació roto y el fallo es del género que este documento colecciona: `pgrep -c` **no existe
|
||
en busybox**, y el `|| echo 0` que parecía prudente convertía el error en un **cero**, o sea en un
|
||
verde. Con un motor vivo a propósito, el guardián pasaba igual. Ahora se cuenta leyendo `/proc` a
|
||
mano, y la rotura a propósito falla como debe (`quedaron 1 llama-server vivos`). De paso, el primer
|
||
intento de romperlo **tampoco rompía nada**: el motor de mentira moría al hacer `bind` porque el
|
||
socket estaba en `/salida`, que se comparte entre las dos corridas; con el socket en `/tmp` —tmpfs
|
||
nuevo por corrida— la rotura rompe.
|
||
|
||
**El modelo de chat, PINEADO (2026-09-11).** `recipes/ia-modelo-chat.toml` ⇒ **Qwen2.5-1.5B-Instruct
|
||
Q4_K_M**, 1.117.320.736 bytes. Elegido por tres cosas y las tres discutibles cambiando un sha256:
|
||
**Apache-2.0** (lo que una distro puede shipear sin letra chica, al revés que Llama-3.2 o Gemma),
|
||
**habla español** —medido antes de pinearlo: se le preguntó qué es una distribución de GNU/Linux y
|
||
contestó dos frases correctas— y **corre en CPU**: 20,1 tokens/s en el hub sin GPU.
|
||
|
||
El objeto pineado es un **tar que envuelve el `.gguf`**, publicado en el mirror de fuentes y servido
|
||
por sha256, porque takana extrae toda fuente con `tar` y un GGUF pelado no es un tar. Es el camino de
|
||
`firefox-pgo-profile`. La procedencia queda escrita en la receta con **el sha256 del GGUF de
|
||
upstream** (el `lfs.oid` que publica HuggingFace, verificado al bajarlo), no sólo el del tar nuestro.
|
||
Round-trip verificado como manda el ADR 0013: apartados el caché local y el artefacto, `takana build`
|
||
lo **bajó del mirror** y selló el mismo `ArtifactHash`.
|
||
|
||
La receta trae guardián propio, porque el modo de fallo es callado: un fichero truncado o un HTML de
|
||
error renombrado a `.gguf` se instala igual y sella en verde. Se comprueban el mágico `GGUF` y el
|
||
tamaño.
|
||
|
||
⚠ **Y el modelo de verdad destapó una carrera en el guardián del §6.7.bis.** El censo de motores
|
||
contaba **en el instante** del cierre: con el modelo de juguete daba 0 y con el de la imagen daba 1 —
|
||
y eso se lee como fuga cuando lo que pasa es que matar un proceso con un giga mapeado tarda ~1 s.
|
||
Ahora el censo **espera hasta 15 s** y anota cuánto tardó: `0 llama-server vivos (1s después)`. La
|
||
rotura a propósito sigue fallando, ahora con el tiempo a la vista.
|
||
|
||
`scripts/test-atuq-ia.py --image-model` corre la cadena entera con el modelo de la imagen (llega como
|
||
**capa**, no copiado: mover 1 GiB a un tmpfs llenó `/tmp` y mató al guardián con un ENOSPC que no
|
||
tenía nada que ver con lo que mide). El default sigue siendo el modelo de juguete, que certifica la
|
||
misma cadena en un segundo.
|
||
|
||
**Lo que queda decidir: qué imágenes lo llevan.** `llama-cpp` (199 M) y el modelo (1,04 GiB) suman
|
||
~1,25 GiB a cada perfil donde se declaren, y `atuq` está en cuatro escritorios. Sigue sin declararse
|
||
en ninguno: una imagen no crece 1,25 G por decisión de un commit.
|
||
|
||
**Y falta el de embeddings** para la mitad semántica del §6.3 (multilingual-e5-small, decidido pero
|
||
no pineado todavía).
|
||
|
||
#### 6.7.ter Una CAPTURA, y lo que encontró que ningún `dump` podía (2026-09-13)
|
||
|
||
Los guardianes miden por el `dump`: qué dijo la extensión, qué contestó el host, qué quedó en disco.
|
||
Es lo correcto para un veredicto automático y **no puede ver lo único que el usuario ve**. Por eso
|
||
hay ahora `scripts/atuq-captura-ia.py`: el mismo arnés de sway headless de los otros guardianes, más
|
||
`grim` —que el cierre de `escritorio-sway` ya trae— y un PNG al final. No es un guardián: es
|
||
evidencia para una persona.
|
||
|
||
La primera captura útil mostró el panel andando —título «IA local — atuq», el aviso «El modelo corre
|
||
en esta máquina. No sale nada a la red.», la pregunta y la respuesta del modelo— **y tres cosas
|
||
más**, ninguna visible en un log:
|
||
|
||
1. **La barra lateral se abría SOLA** en el primer arranque y se quedaba ocupando un tercio de la
|
||
ventana. Gecko lo hace por defecto al instalar una extensión con `sidebar_action`, y para un
|
||
navegador que la trae de fábrica eso significa **imponerle un panel a todo el mundo en cada
|
||
instalación**. Un `"open_at_install": false` lo arregla; la captura del después lo confirma;
|
||
2. **el diálogo «Close Firefox»** en la segunda corrida: el arnés mataba el navegador sin despedirse
|
||
y el `.parentlock` quedaba. No es del producto, pero costaba una captura inútil por corrida;
|
||
3. **dos barras de notificación vacías** en el arranque. Acá el atajo habría sido reportarlas como un
|
||
fallo de marca —«las cadenas se perdieron al rebrandear»—, y era **falso**: instrumentando una
|
||
COPIA del artefacto para volcar el DOM, resultaron ser `sandbox-content-disabled` («the security
|
||
sandbox is disabled», que apaga **el arnés**) y `startup-restore-session-suggestion` (por el
|
||
cierre abrupto de la corrida anterior). El texto está en el DOM y no en los píxeles: se comprobó
|
||
además **quitando nuestro CSS**, con el mismo resultado ⇒ es el render por software de la jaula,
|
||
no el producto.
|
||
|
||
⇒ Y ésa es la lección de la unidad: **una captura muestra síntomas, el DOM dice de quién son**. Sin
|
||
el segundo paso, dos de los tres hallazgos habrían entrado al documento como bugs nuestros.
|
||
|
||
### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06)
|
||
|
||
**Es el primer diferenciador del §6 que se paga entero**, y se pudo pagar ahora porque es el único
|
||
de la lista que no pasa por el host del §7: es API de Firefox y nada más. Cada contenedor
|
||
—Personal, Trabajo, Banco, Compras— puede salir por su propio proxy.
|
||
|
||
Tres piezas, en tres ficheros, y **ninguna alcanza sola**:
|
||
|
||
| Pieza | Dónde | Qué aporta |
|
||
|---|---|---|
|
||
| `privacy.userContext.enabled` | `atuq.cfg` | prende los contenedores, que Firefox trae apagados |
|
||
| `Containers.Default` | `distribution/policies.json` | los CREA — es el único mecanismo que los pone en un perfil NUEVO |
|
||
| `3rdparty.Extensions` | `distribution/policies.json` | la config de fábrica, que la extensión lee como `storage.managed` |
|
||
| `proxy.onRequest` | `extensions/proxy/fondo.js` | lo único que ve el `cookieStoreId` de cada petición |
|
||
|
||
**Las cuatro se comprobaron DENTRO del artefacto antes de escribir una línea**, que es la regla que
|
||
dejó el §2.sexies: `Containers` y `3rdparty` están en el `policies-schema.json` de `browser/omni.ja`,
|
||
`cookieStoreId` en el `schemas/proxy.json` de `omni.ja`, y `storage.managed` lee
|
||
`Services.policies.getExtensionPolicy(id)` en `ext-storage.js`. Ninguna de las cuatro se leyó de la
|
||
documentación de Mozilla: se leyeron de **nuestro** build, que es el que las tiene que traer.
|
||
|
||
**Tres decisiones de diseño que valen más que el código:**
|
||
|
||
1. **La configuración se indexa por NOMBRE de contenedor, no por `cookieStoreId`.** El id
|
||
(`firefox-container-3`) depende del ORDEN en que se crearon, así que la misma configuración
|
||
aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de significado
|
||
entre máquinas no sirve para configurar una distro.
|
||
2. **Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar —entrada
|
||
inválida, API que falla, mapa a medio construir— **no sale directo**: va a un destino cerrado y
|
||
el navegador muestra el error. Salir directo sería una fuga silenciosa, y es la misma familia
|
||
que el artefacto vacío de la regla 3: el fallo que llega hasta el final diciendo que todo fue
|
||
bien. Un usuario que pidió que «Banco» no salga por su línea tiene que ver un error, no navegar.
|
||
3. **`proxyDNS` viene PRENDIDO.** Sin él, Gecko resuelve el nombre por su cuenta antes de hablar
|
||
con el proxy: la consulta DNS sale justo por la línea que se quería evitar. Es la fuga clásica de
|
||
esta configuración, así que el default es el seguro y hay que apagarlo a mano.
|
||
|
||
**Y lo que NO promete está en la propia página de opciones, arriba de todo y no en un pie**:
|
||
separación de tráfico, no anonimato; no toca la huella del navegador; para anonimato, Tor Browser.
|
||
Es el §4 cumplido en el sitio donde el usuario lo va a leer.
|
||
|
||
#### Lo que quedó PROBADO, y con qué
|
||
|
||
Corriendo el árbol de atuq en la misma jaula que usa `scripts/atuq-nested.sh`:
|
||
|
||
| Afirmación | Prueba |
|
||
|---|---|
|
||
| La política CREA los cuatro contenedores | captura de `about:preferences#containers` con Personal/Trabajo/Banco/Compras y sus iconos, más el `containers.json` del perfil |
|
||
| Las dos extensiones se INSTALAN | `extensions.json` del perfil nombra `inicio@atuq.tawasuyu` y `proxy@atuq.tawasuyu` |
|
||
| El ruteo se arma contra los contenedores reales | `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — o sea que leyó la config de fábrica por `storage.managed` Y la casó con los contenedores que creó la política |
|
||
| **Una petición hecha en «Banco» SALE por el proxy de «Banco»** | `scripts/test-atuq-ruteo.py`: dos pestañas piden la misma url; al destino llega `GET /directo` y al puerto del proxy un saludo **SOCKS5 (`\x05\x01\x00`)**, y la de «Banco» no aparece nunca en el destino. Con `--negative-control` (sin proxy configurado) las dos van directas y al proxy no llama nadie |
|
||
| Los guardianes del cruce política↔XPI FALLAN cuando deben | `scripts/test-atuq-politica.py`: 5 formas de romperlo, las 5 matan el build, y el control con la política intacta pasa |
|
||
| La capa sobrevive al cambio de BASE | rehecho sobre el `firefox` con RLBox (`b3:8116bdec`, 2026-09-06): `atuq` sella en 2 s como `b3:f2960991`, su `libxul.so` trae **634 símbolos que se LLAMAN `w2c_*`** donde la base anterior traía 0, y los cuatro contenedores y las dos extensiones siguen apareciendo en un arranque real |
|
||
|
||
⚠ **Ese 634 hay que contarlo anclado, y la primera vez se contó mal.** `llvm-nm --defined-only \| grep -c w2c_` da **1547** sobre el mismo fichero, y los 913 de más son nombres C++ mangleados que contienen `w2c_` en el MEDIO (`_ZN5rlbox...PK16w2c_mem_capacity...`), no símbolos de la jaula. El patrón que cuenta lo que dice contar es `grep -c ' w2c_'` —o el equivalente `awk '{print $3}' \| grep -c '^w2c_'`—, y da 634, que es lo que mide el guardián de `recipes/firefox.toml`. La conclusión no cambia (la base anterior daba **0** con cualquiera de los dos patrones); lo que cambia es que un número sin su patrón no es una medición. Muestra de lo que sí es un símbolo de la jaula: `w2c_rlbox_0x5F_cxa_atexit_0`.
|
||
|
||
**Y de paso quedó comprobado que la dep SÍ se sigue**, que era la promesa del §2 y no una que convenga
|
||
creer sin medir: en un catálogo de sonda, cambiar una bandera del `firefox` copiado mueve el hash de
|
||
`atuq` (`f2960991` → `60ade76a`), mientras que con el `firefox` idéntico al del corpus da el mismo
|
||
hash. O sea que un `atuq` viejo **no** puede quedarse tapando un motor nuevo — que es justo la forma
|
||
que tendría acá el cache-hit que congela regresiones.
|
||
|
||
**Cómo se cerró lo que faltaba (2026-09-06).** Durante unas horas esta sección decía que probar el
|
||
paquete quedaba pendiente, con tres puertas cerradas. Las tres siguen cerradas y la prueba existe
|
||
igual, porque el camino era otro.
|
||
|
||
Las tres puertas, que conviene NO volver a golpear:
|
||
|
||
1. **Desde la línea de comandos no hay flag** para el `userContextId` de la pestaña inicial. Y un
|
||
`moz-extension://` tampoco se abre así: el manejador intenta resolverlo como fichero y muere con
|
||
`NS_NOINTERFACE [nsIFileURL.file]`, abriendo la home en su lugar.
|
||
2. **Marionette está en el artefacto y responde** —se le habló con un cliente propio de 60 líneas,
|
||
el protocolo es `<longitud>:<json>`—, pero abrir una pestaña de contenedor pide contexto chrome,
|
||
y eso exige `-remote-allow-system-access`; en la jaula no lo tomó ni por bandera, ni con
|
||
`--remote-debugging-port`, ni por `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`, que es lo que
|
||
`RemoteAgent.sys.mjs` lee en su constructor. Cabo suelto medido, no suposición.
|
||
3. **Desde la extensión, `tabs.create({cookieStoreId})` exige el permiso `cookies`**
|
||
(`ext-tabs-base.js:getUserContextIdForCookieStoreId`). Dárselo a la extensión del proxy para que
|
||
pueda probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del
|
||
test. Sigue sin dárselo.
|
||
|
||
**La cuarta puerta estaba abierta: el fichero de sesión.** `sessionstore` guarda el `userContextId`
|
||
de cada pestaña y Gecko lo restaura, así que se fabrica a mano. El formato es `mozLz40\0` + tamaño +
|
||
un bloque LZ4, y **un bloque de sólo literales es LZ4 válido**: veinte líneas, sin depender de
|
||
ninguna librería. Con eso se abren dos pestañas —misma url, distinto contenedor— sin pedirle nada al
|
||
navegador.
|
||
|
||
Y la medición no le pregunta nada tampoco: **se le ponen dos oídos en la red y se mira a cuál llama.**
|
||
El destino contesta un HTTP 200; el «proxy» no habla SOCKS, sólo anota quién lo saludó. Que al
|
||
puerto del proxy llegue un `\x05\x01\x00` es la prueba: ese saludo sólo aparece si Gecko decidió
|
||
hablar con un proxy para esa petición, y el único que se lo pudo indicar es nuestro
|
||
`proxy.onRequest` mirando el `cookieStoreId`.
|
||
|
||
Tres detalles sin los cuales la prueba mide un silencio y se lee como un fallo:
|
||
`browser.sessionstore.restore_on_demand=false` (si no, las pestañas restauradas no piden nada hasta
|
||
que alguien las mira), `network.proxy.allow_hijacking_localhost=true` (Gecko saltea el proxy para
|
||
localhost, y la petición de «Banco» iría directa haciendo parecer culpable a la extensión), y
|
||
`triggeringPrincipal_base64: vQ==` en cada entrada de sesión (sin principal la pestaña se restaura
|
||
pero no navega).
|
||
|
||
**Y la prueba trae su propio control negativo**, porque es lo que este repo se exige desde hoy: con
|
||
`--negative-control` no se configura el proxy y se exige el resultado CONTRARIO —las dos pestañas
|
||
directas, nadie llamando al proxy—. La única diferencia entre las dos corridas es una línea de
|
||
configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto
|
||
ciega se vería idéntica a una que funciona.
|
||
|
||
### 6.9 Torrent adentro — HECHO (2026-09-10), con un daemon propio, configurable y perezoso
|
||
|
||
Vivaldi ya trae torrent, y termina en una carpeta. Lo que cambia acá es que **lo que baja entra al
|
||
CAS**: un objeto BLAKE3 deduplicado y verificable, con la misma identidad que una descarga del
|
||
navegador (§6.2) o un `.swm` de takana. Ésa, y no «tener torrent», es la razón por la que esto vale.
|
||
|
||
página de la extensión → fondo → connectNative → puriy-costura
|
||
→ /usr/bin/puriy-costura-torrent add (que LEVANTA el daemon si no está)
|
||
→ daemon: librqbit, y al completarse, el CAS
|
||
|
||
**Por qué un proceso aparte y no un verbo más del host — dos razones, y la primera decide:**
|
||
|
||
1. **Una descarga tiene que sobrevivir al navegador.** Gecko lanza un host de native messaging **por
|
||
puerto** y lo mata cuando el puerto se cierra (medido este mismo día, §6.2.bis). Un cliente
|
||
BitTorrent dentro de `puriy-costura` dejaría de bajar al cerrar la pestaña.
|
||
2. **El precio.** `librqbit` + tokio + rustls son **226 crates**: ese host —952 K, compartido por las
|
||
cinco extensiones— pasaría a ~20 MB, y cada iteración de cualquier función del navegador a un
|
||
cuarto de hora.
|
||
|
||
⚠ **Y que esa pila COMPILA para musl con zig-cc se midió antes de decidir**, con una receta
|
||
desechable: 226 crates y sella. Era una incógnita real —ninguna receta del corpus había construido
|
||
tokio+rustls desde tawasuyu; las que hay dicen «deps livianas (sin tokio/libp2p/axum)»— así que la
|
||
decisión no fue «no se puede», fue **«no ahí»**. Medir primero es lo que separa una decisión de
|
||
arquitectura de una excusa.
|
||
|
||
**Perezoso en los dos sentidos**, que es lo que pidió el operador:
|
||
|
||
| | qué significa |
|
||
|---|---|
|
||
| nadie lo arranca | **no hay servicio en la imagen**: el binario está y no corre. Lo levanta su propio cliente la primera vez que hay un torrent que tomar |
|
||
| se va solo | cuando no queda nada activo por `inactividad_seg` (300 s por defecto; `0` = nunca), sale. Sembrar cuenta como actividad: no se va mientras le devuelva piezas al enjambre |
|
||
|
||
⚠ Se levanta con **`setsid`**: sin eso queda en el grupo de procesos del navegador y **se muere con
|
||
él**, que es exactamente lo que este proceso existe para no hacer. Si `setsid` faltara se arranca
|
||
igual —mejor bajar mientras el navegador esté abierto que no bajar— pero **avisando**, porque es una
|
||
degradación silenciosa de su única promesa.
|
||
|
||
**Configurable**: un TOML opcional (`descargas`, `cas`, `indice`, `sembrar_tras_bajar`,
|
||
`inactividad_seg`, techos de velocidad). Sin fichero los defaults andan; un fichero **roto** sí es un
|
||
error, porque si un typo se comportara como «sin configuración» el usuario buscaría el problema en
|
||
cualquier otra parte. **El CAS por defecto es el del host**, y hay un test que falla si esas dos
|
||
raíces se separan: es lo que hace que un torrent y una descarga del mismo contenido sean **un objeto**.
|
||
|
||
**Lo que el guardián NO hace, y por qué** (`scripts/test-atuq-torrent.py`):
|
||
|
||
1. **no usa un `magnet:` sino un `.torrent` servido por HTTP** — dar de alta un magnet BLOQUEA
|
||
esperando la metadata del enjambre, y sin peers eso no llega nunca: se mediría un timeout y no la
|
||
cadena. Con un `.torrent` la metadata viene en el fichero y el alta termina. El `.torrent` se arma
|
||
en el propio guardián, en bencode a mano: un binario de prueba en el repo es una dependencia que
|
||
nadie revisa;
|
||
2. **no pasa por el despacho de protocolos de Gecko** — un clic en un `magnet:` abre el diálogo de
|
||
«¿con qué lo abro?», que en headless no contesta nadie, y eso es una elección del usuario y no
|
||
código nuestro. Se abre la página de la extensión, que es la que el handler abriría, descubriendo
|
||
su URL base del `dump` de una primera corrida (el UUID lo asigna Gecko por perfil).
|
||
|
||
Y el control negativo prueba lo que importa: **sin el cliente del daemon, el host falla diciéndolo y
|
||
nombrando lo que falta** — no finge haberlo tomado.
|
||
|
||
⚠ **Y el bug que encontró MIRAR LA MÁQUINA, no un test.** Después de las pruebas, `ps` mostraba un
|
||
daemon con **23 minutos de vida** y `idle_seconds = 300`. El bucle hacía
|
||
`let Ok(g) = gestor() else { continue }`: si la sesión de torrent no se podía crear, el `continue`
|
||
saltaba la evaluación de «¿sobro?» y el daemon quedaba **vivo para siempre** — lo contrario de
|
||
perezoso, que es su única promesa. Los tests cubrían la decisión como función PURA, y esa función
|
||
estaba bien: **el que no llegaba a llamarla era el bucle**. Una decisión correcta que nadie toma se ve
|
||
igual que una que no existe. Arreglado (sin sesión = nada activo) y fijado con dos tests que corren el
|
||
PROCESO y el reloj de verdad, el segundo comprobado volviendo a poner el `continue`.
|
||
|
||
⚠ **La pereza tiene un residuo, y el guardián lo daba por bueno (2026-09-10).** El daemon se va
|
||
«tras 300 s **sin nada**», y un torrent sin enjambre nunca termina ⇒ nunca está sin nada: se queda,
|
||
que es lo correcto para un cliente de torrent. Pero el guardián cerraba el sandbox y se iba, y
|
||
**quedaba vivo el daemon Y el `bwrap` interno**, sosteniendo abiertos overlays ya borrados —
|
||
encontrado con `ps` sobre la máquina, no por un test. La corrección tiene dos mitades y ninguna
|
||
sobra:
|
||
|
||
1. el guión de adentro **despide al daemon** con `puriy-costura-torrent stop` — el propio verbo del
|
||
producto, así que de paso se ejerce;
|
||
2. el guardián **cuenta los daemons antes y después** (`ps -C`, nunca `pkill -f`, que empareja la
|
||
línea de comandos y se lleva la shell del que corre el test) y **falla nombrando el PID** si quedó
|
||
alguno; y barre lo propio en el `finally`, para que un test rojo tampoco deje basura.
|
||
|
||
Y el orden importa: la huella del socket se anota **adentro y antes** del `stop`, porque al irse el
|
||
daemon lo borra — mirarlo desde el hub después mediría el reloj, no el hecho. Comprobado en los dos
|
||
sentidos, quitando el `stop` en una copia fuera del repo: sin él el guardián decía ✓ igual y dejaba
|
||
un daemon suelto; con la aserción nueva sale
|
||
`✗ quedó un daemon vivo tras cerrar el sandbox: PID(s) [20157]`. La primera hipótesis —que sin `stop`
|
||
el guardián se COLGARÍA, porque `bwrap --unshare-pid` espera a su namespace— **era falsa y la
|
||
medición la descartó**: el `bwrap` externo vuelve, y el que se queda esperando es el interno. El tope
|
||
de 180 s quedó igual, pero por lo que es: seguro contra un cuelgue, no el que detecta la fuga.
|
||
|
||
⚠ **Lo que sigue sin estar en ningún test automático: un transfer real entre peers.** Haría falta un
|
||
sembrador y una espera que volverían la suite una que nadie corre. Está dicho también en el test del
|
||
daemon, al pie, para que nadie lo lea como «probado de punta a punta».
|
||
|
||
### 6.10 Lo que `atuq` NO PUEDE hacer hoy, y por qué (2026-09-07)
|
||
|
||
Sale de un punto ciego que nombramos entre los dos frentes y que **ningún auditor de `readelf`
|
||
puede cubrir**. La jerarquía de lo que se ve:
|
||
|
||
NEEDED del ejecutable → lo ve `test-atuq-rootfs.py` y lo ve `vigia-sonames.py`
|
||
NEEDED de un .so dlopeado → lo ven los dos, si el fichero está en el artefacto
|
||
dlopen("libfoo.so.1") LITERAL → NO LO VE NINGUNO
|
||
|
||
Y un navegador vive del tercer escalón: Firefox sondea ffmpeg, VA-API, vulkan, libnotify y una
|
||
docena más **por nombre**, y cuando no están **no falla: apaga la función y sigue**. No hay línea
|
||
roja; hay una función que nadie ofrece y nadie reclama. Es la misma forma que ya nos costó caro con
|
||
OBS (`dlopen("libGL.so.1")` desde su glad).
|
||
|
||
`scripts/test-atuq-rootfs.py --dlopen` busca esas cadenas y las cruza contra el rootfs. **Es un
|
||
heurístico y se declara como tal** —una cadena no prueba un `dlopen`, y su ausencia no prueba que no
|
||
lo haya—, así que no falla nunca: imprime un mapa triado. Lo que sí se puede afirmar es lo
|
||
contrario, que es lo útil: **si la cadena está y el fichero no, esa función no existe en esta
|
||
imagen.**
|
||
|
||
De 85 cadenas con forma de soname, **47 no tenían quién las provea**. Triadas (y con dos filas
|
||
corregidas el mismo día — ver §6.11, que las midió en vez de deducirlas; hoy el mapa da **7 huecos**):
|
||
|
||
| Qué se pierde | Estado |
|
||
|---|---|
|
||
| **Códecs del sistema (H.264/AAC)** | ❌ **ERA FALSO** — `recipes/ffmpeg.toml` existe, está sellada y ya viajaba en los cuatro escritorios por `mpv`. Corregido en §6.11: **reproduce** |
|
||
| **Notificaciones web** (`libnotify`) | ✅ **CERRADO el 2026-09-07**: `recipes/libnotify.toml`, 0.8.8, compartida. Es el primer hueco que este mapa encontró Y cerró |
|
||
| **Llavero** (`libsecret`) | hueco — pero **la receta ya existe** en `incoming-gnome` |
|
||
| **Sonidos del sistema** (`libcanberra`) | hueco — receta en `incoming-kde` |
|
||
| **WebGPU / vulkan** (`libvulkan`) | hueco — receta `vulkan-loader` en `incoming-kde` |
|
||
| **Vídeo por hardware** (VA-API) | hueco, pero **no por falta de receta**: `libva` está sellada y en las cuatro imágenes. Falta el driver — las tres mesa van con `-Dgallium-va=disabled` y `-Dvideo-codecs=` vacío ⇒ ni un `*_drv_video.so` |
|
||
| **Lectura en voz alta** (`libspeechd`) | hueco — sin receta |
|
||
| `libGL` | **decisión**: la distro es Wayland-only sin GLX y Firefox va por EGL |
|
||
| `libcurl` | **decisión**: sólo lo usa `pingsender`, que manda telemetría — apagada |
|
||
| Kerberos, menú global, ML | fuera de alcance |
|
||
| `libdl/libpthread/librt/libudev.so.0/libfreebl3` | ruido: musl los funde, o el soname es viejo |
|
||
|
||
**El primero ya está cerrado.** `libnotify` no tenía receta y ahora la tiene: es la prueba de que el
|
||
mapa sirve para algo más que mirarlo. ⚠ Con una advertencia que va escrita en la propia receta:
|
||
`libnotify` no trae un daemon, manda `org.freedesktop.Notifications` por D-Bus ⇒ tenerla resuelve la
|
||
mitad —que firefox la encuentre— y la otra mitad es que en la imagen haya alguien escuchando ese
|
||
nombre. Nadie debería leer «notificaciones arregladas» y esperar un globo en un QEMU pelado.
|
||
|
||
**⚠ CORRECCIÓN, mismo día: escribí que tres de los siete eran «baratos, promoción y no autoría»
|
||
—`libsecret`, `libcanberra`, `vulkan-loader`— y es FALSO.** Lo comprobé antes de declararlos en los
|
||
perfiles, y en los tres casos falta la otra mitad:
|
||
|
||
| «barata» | por qué NO alcanza con declararla |
|
||
|---|---|
|
||
| `vulkan-loader` | **las tres recetas de mesa construyen con `-Dvulkan-drivers=` VACÍO** ⇒ el loader no encontraría un solo dispositivo. Un cargador sin ICD no es WebGPU |
|
||
| `libsecret` | no hay receta de `gnome-keyring` ni de ningún servicio de secretos: firefox hablaría a `org.freedesktop.secrets` y no habría nadie |
|
||
| `libcanberra` | no hay receta de `sound-theme-freedesktop`: la librería carga y no suena nada |
|
||
|
||
Declararlas habría subido el número de paquetes de la imagen sin encender una sola función. **Una
|
||
librería es media función; la otra mitad es quien la atiende**, y eso vale para todas las de esta
|
||
tabla. Quedan como huecos, no como promociones pendientes.
|
||
|
||
⚠ **Y esta frase también se cayó el mismo día.** Decía que de los otros cuatro «el más caro para el
|
||
usuario es `ffmpeg`: sin él, un sitio que sirva H.264 no reproduce» — con lo cual el hueco más caro
|
||
del mapa **no existía**. Ver §6.11: la receta estaba, faltaba declararla en el runner, y H.264 (y
|
||
AAC, VP9, Opus, AV1, MP3, FLAC) reproducen. De los cuatro «sin receta» quedan tres, y ninguno es un
|
||
códec: lectura en voz alta (`libspeechd`), el driver de VA-API, y `libpci` —que sólo lo usa
|
||
`glxtest`, o sea irrelevante sin GLX.
|
||
|
||
**Y la misma vara aplicada a `libnotify`, que sí se declaró:** su otra mitad es un daemon que
|
||
escuche `org.freedesktop.Notifications`. Medido perfil por perfil:
|
||
|
||
escritorio-kde plasma-workspace ✓ completa
|
||
escritorio-gnome gnome-shell ✓ completa
|
||
escritorio-cosmic cosmic-notifications ✓ completa
|
||
escritorio-sway dunst 1.12.2 ✓ completa — CERRADA el 2026-09-07
|
||
|
||
La de sway se cerró el mismo día con `recipes/incoming-wlr/dunst.toml`, y la trampa del homónimo
|
||
—**el nombre `mako` YA ESTÁ OCUPADO en el corpus** por el motor de plantillas de Python de Mesa— se
|
||
esquivó eligiendo dunst, que además habla D-Bus por GDBus y no por `sd-bus`, o sea sin receta nueva
|
||
de `basu`. La prueba no es que selle: `scripts/wlr/dunst-headless.sh` levanta sway headless y un bus
|
||
de sesión, manda un `notify-send` y **deja que el bus active el daemon solo** —el mismo camino que
|
||
recorre una página web en `atuq`—, y mide un diff de píxeles antes/después: 14.832 cambiados con el
|
||
`.service` puesto, 0 sin él. Evidencia en `docs/evidencia/dunst-sway-notificacion-2026-09-07.png`.
|
||
|
||
Y la cadena se cerró hasta el final, que es donde vive la promesa: con `--via-atuq` el emisor no es
|
||
`notify-send` sino **una página web dentro del navegador**. Ahí se ejercitan las cinco piezas —
|
||
`new Notification(...)` → `libxul` haciendo `dlopen("libnotify.so.4")` (que no es NEEDED de ningún
|
||
ELF, o sea invisible para cualquier auditor) → D-Bus → activación → dunst dibujando—, y el veredicto
|
||
es el `onshow` del motor, no el diff de píxeles: con el `.service` puesto llega a `MOSTRADA #33`, sin
|
||
él el motor dice `ERROR al mostrar` y no se dibuja nada. Evidencia en
|
||
`docs/evidencia/atuq-notificacion-web-sway-2026-09-07.png`.
|
||
|
||
Vale la pena decir qué prueba eso de más: que en el proceso de `atuq` **la GLib no se duplica**. El
|
||
navegador usa la cadena `-shared` (la que arrastra su GTK3) y `libnotify.so.4` enlaza esa misma, así
|
||
que el `dlopen` no repite GObject. El que sí lo repetía era `dunstify`, y por eso no se shipea.
|
||
|
||
⚠ De paso apareció otra media función, y ésta toca a `libnotify` directamente: **`dunstify`
|
||
segfaultea hasta en `--help`** porque mezcla la GLib ESTÁTICA del corpus con la COMPARTIDA que
|
||
arrastra `libnotify.so.4` — dos copias de GObject en un proceso. No se shipea; el emisor es
|
||
`notify-send`, que viene dentro del artefacto de libnotify y enlaza toda la cadena compartida. Vale
|
||
como recordatorio de que en este corpus **quién enlaza qué GLib es parte del contrato**, no un
|
||
detalle del build.
|
||
|
||
**Lo que esta sección cambia de fondo:** hasta hoy la pregunta era «¿arranca?», y la respuesta era
|
||
sí. La pregunta que faltaba era «¿y qué NO puede hacer?», que ninguna métrica del repo respondía
|
||
porque todas miran presencia y ésta mira ausencia declarada por el propio binario.
|
||
|
||
#### 6.10.bis Una descarga terminada MATA a `atuq --headless` (2026-09-10)
|
||
|
||
Medido al escribir el guardián del §6.2, y vale la pena por cómo se atribuyó, no por el bug:
|
||
|
||
| corrida | qué pasa |
|
||
|---|---|
|
||
| `--headless`, con la extensión de descargas | baja el fichero, la extensión dice `TERMINADA`, y el proceso muere con **SIGSEGV (139)** |
|
||
| `--headless`, **sin** la extensión (control) | baja el fichero, y muere igual |
|
||
| `--headless`, con el panel de descargas apagado (`browser.download.alwaysOpenPanel=false`) | muere igual |
|
||
| **sway headless (compositor REAL, salida offscreen)** | baja, ingiere al CAS y **no se cae** |
|
||
|
||
Las dos primeras filas son las que importan: **el control sin la extensión descarta que sea nuestro**,
|
||
y la última descarta que sea del producto. Es el modo `--headless` de Gecko, en esta jaula, cuando una
|
||
descarga completa. El fichero se baja bien en los cuatro casos — lo que muere es el proceso después.
|
||
|
||
Sin el control, esto se lee de dos maneras y las dos son caras: «la extensión de descargas rompe el
|
||
navegador» (perseguir un bug que no existe) o «atuq no puede descargar» (reportar un bug de producto
|
||
que tampoco existe). Por eso `scripts/test-atuq-descargas.py` corre sobre sway y no sobre `--headless`,
|
||
y por eso dice en su cabecera POR QUÉ — un arnés distinto al de los demás guardianes, sin explicación,
|
||
es una invitación a «simplificarlo» de vuelta al que se cae.
|
||
|
||
⚠ Lo que NO se midió: si el crash existe también en el `--headless` de una máquina sin jaula. No hace
|
||
falta para lo que se decidió acá, y afirmarlo sin medirlo sería justo lo que este documento no hace.
|
||
|
||
#### 6.10.ter ⚠ En la IMAGEN BOOTEADA con COSMIC, `atuq` corre y NO PINTA VENTANA (2026-09-14)
|
||
|
||
Primera vez que el navegador se prueba **dentro de una imagen de disco arrancada**, y el resultado no
|
||
es el de la jaula. La cadena entera funcionó —y esa parte también es medición—:
|
||
|
||
1. `escritorio-cosmic` hidratado con el cierre de HOY: **275 nodos**, 8,4 G, y adentro están
|
||
`/usr/bin/atuq`, `/usr/bin/llama-server` y `/usr/share/takana/ia/modelo.gguf`. O sea que
|
||
**declarar en el perfil sí pone las cosas en la imagen** — la otra mitad de la lección de `foot`;
|
||
2. imagen EFI de 12 G construida y **arrancada en QEMU**: COSMIC pinta panel, dock y fondo en ~2 min
|
||
(TCG, sin KVM);
|
||
3. `atuq` **arranca**: sus extensiones inician y la del §6.5 sondea el foco cada minuto
|
||
(`FOCO ESTADO unknown`), WebRender inicializa («Software WebRender», GL 3.2);
|
||
4. **y la ventana nunca aparece.** Cinco minutos, dos capturas, y el escritorio sigue vacío.
|
||
⚠ **MATIZADO el mismo día, ver §6.10.sexies: es INTERMITENTE.** Repetido cinco veces sobre esta
|
||
misma imagen, el navegador pintó en TRES (a +197…+204 s) y en dos no pintó —una de ellas con 15
|
||
minutos de observación—. O sea que esto no era ni «nunca pinta» ni «sólo tardaba»: es un fallo que
|
||
aparece a veces, y una corrida sola no lo puede decidir en ningún sentido.
|
||
|
||
**Lo que ya se descartó**, para que nadie lo repita:
|
||
|
||
- **no es el sandbox de Gecko**: relanzado con los cinco `MOZ_DISABLE_*_SANDBOX` puestos, idéntico;
|
||
- **no es que el proceso muera**: sigue vivo y ejecutando el JS de las extensiones;
|
||
- **no es el modelo ni la IA**: pasa con `about:blank`.
|
||
|
||
**La pista que vale**: bajo **sway headless** el mismo artefacto pinta perfecto —hay capturas del
|
||
panel contestando (§6.7.ter)—. La diferencia entre los dos casos es el compositor (cosmic-comp con
|
||
llvmpipe contra sway con pixman) y el arranque real contra `bwrap`. Eso acota dónde mirar, y es
|
||
material para su propia unidad de trabajo, no para un parche apurado.
|
||
|
||
⚠ **Y la lección del método**: sin arrancar la imagen, esto no se veía. Los quince guardianes están
|
||
en verde, el `vigia-imagen.py` del perfil da ✓ en iconos, cursores, fuentes, terminal y QML, y el
|
||
navegador **igual no se puede usar** en la imagen. «Sella», «hidrata» y «los tests pasan» son tres
|
||
cosas distintas de «arranca y se ve».
|
||
|
||
Artefactos de la corrida, para quien siga: `/mnt/cosecha/takana-cosmic-qemu.img` (12 G) y las
|
||
capturas en `work/cosmic-{1..6}.png`.
|
||
|
||
#### 6.10.quater El COMPOSITOR no es la causa: con `cosmic-comp` nesteado el navegador SÍ pinta (2026-09-14)
|
||
|
||
El §6.10.ter dejó dos variables cambiando a la vez —el compositor (cosmic-comp+llvmpipe contra
|
||
sway+pixman) y el arranque real contra `bwrap`— y ninguna medición que las separe. `scripts/cosmic/
|
||
atuq-en-cosmic.sh` saca la primera de encima en ~2 min por vuelta, sin QEMU y sin imagen:
|
||
|
||
sway (headless, pixman) → cosmic-comp (winit, cliente de sway) → atuq
|
||
|
||
sway es andamio: no se prueba nada de él, sólo le da a cosmic-comp una pantalla donde vivir, y `grim`
|
||
captura de SU lado, o sea que fotografía la ventana de cosmic-comp con lo que haya dentro. El
|
||
`--control` corre **ese mismo binario de `atuq`** —el del cierre `escritorio-cosmic` hidratado para la
|
||
imagen, no otro— directo sobre sway, sin tocar nada más.
|
||
|
||
**El resultado, en píxeles:**
|
||
|
||
cosmic-comp magenta de la página: 586331 px bbox (205,169)-(1277,717)
|
||
control sway magenta de la página: 586331 px bbox (205,169)-(1277,717)
|
||
|
||
Las dos capturas coinciden **al píxel** en la región de la página, y no por casualidad ni porque el
|
||
navegador se haya escapado a sway: en la captura previa al lanzamiento, la ventana de cosmic-comp
|
||
ocupa exactamente `(2,81)-(1277,717)` = 1276×637 de su color de fondo, que es el mismo rectángulo que
|
||
sway le da a un cliente maximizado bajo su barra de título. Las dos corridas enteras difieren en 1130
|
||
píxeles, todos en la franja de las barras de título. Y el `MOZ_LOG` del widget lo confirma desde el
|
||
otro lado: en la corrida con cosmic el motor ve `mode output size 1276 x 637` (la ventana del
|
||
compositor nesteado) y en el control `1280 x 720` (la pantalla de sway).
|
||
|
||
⇒ **`cosmic-comp` no es el que impide la ventana.** Con GL por software, sin GPU y hasta con el
|
||
navegador entero pintando dentro, la cadena funciona.
|
||
|
||
**Y de paso, qué camino de buffers usa cuando funciona:** `WaylandBufferSHM::CreateWlBuffer()` en cada
|
||
cuadro — memoria compartida, no dmabuf. Es la línea de base contra la que comparar lo que haga en la
|
||
imagen: si allá el motor eligiera otro camino, se vería en la misma traza.
|
||
|
||
Lo que queda como variable —y es donde sigue la caza— es el arranque de verdad: `cosmic-comp` sobre
|
||
**kms/DRM de virtio-gpu** en vez de winit, el PID1 de arje, el seat. Para eso está
|
||
`scripts/cosmic/atuq-en-imagen.py`, que arranca la imagen, maneja el serial, lanza el navegador con el
|
||
mismo `MOZ_LOG` y pide capturas por QMP — la corrida del §6.10.ter fue a mano y no dejó un solo log
|
||
que se pueda releer, que es exactamente lo que hace falta para comparar.
|
||
|
||
#### 6.10.quinquies En la imagen, el navegador SÍ pinta — y la segunda pantalla no era (2026-09-14)
|
||
|
||
`scripts/cosmic/atuq-en-imagen.py` arranca la imagen del §6.10.ter, maneja el serial, lanza el
|
||
navegador con `MOZ_LOG` del widget de Wayland y pide capturas por QMP. La corrida de aquel día fue a
|
||
mano y no dejó un log que se pueda releer; ésta deja todo en `work/atuq-imagen-*`.
|
||
|
||
**La sospecha que se fue a probar** salía del propio serial: aquel arranque veía DOS dispositivos DRM
|
||
y éste uno solo.
|
||
|
||
== cosmic :: kernel 6.16.12 · drm: card0 card1 renderD128 ← §6.10.ter (sin `-vga none`)
|
||
== cosmic :: kernel 6.16.12 · drm: card0 renderD128 ← con `-vga none`
|
||
|
||
Sin `-vga none`, QEMU agrega una VGA estándar ADEMÁS del virtio-gpu, OVMF pinta su GOP ahí y el
|
||
kernel levanta `simpledrm` encima. Un compositor con dos tarjetas puede componer en la que el
|
||
`screendump` no muestra, y eso se vería exactamente como «la ventana no aparece».
|
||
|
||
**Medido, con las dos configuraciones y el mismo lanzamiento** (perfil nuevo, `MOZ_ENABLE_WAYLAND=1`,
|
||
página de un solo color):
|
||
|
||
| vídeo de QEMU | DRM en el guest | magenta en pantalla |
|
||
|---|---|---|
|
||
| `-vga none` | `card0` | **703 766 px** |
|
||
| `VGA=1` (como el §6.10.ter) | `card0 card1` | **703 766 px** |
|
||
|
||
El mismo número en las dos, al píxel. La ventana aparece —panel de COSMIC, dock y el navegador con su
|
||
barra lateral—, `nsWindow::Create() Toplevel` sale en el `MOZ_LOG`, la superficie se mapea y
|
||
`NotifyOcclusionState() mIsFullyOccluded 0`. El camino de buffers es el mismo que bajo sway:
|
||
`WaylandBufferSHM`, 1280×696.
|
||
|
||
⇒ **la segunda pantalla NO es la causa**, y el síntoma del §6.10.ter no se reproduce así. Eso deja una
|
||
sola diferencia entre aquella corrida y ésta, y es la que sigue: **cómo se lanzó el navegador**. Acá
|
||
va con perfil nuevo y las variables puestas; allá fue un `atuq` pelado, con su perfil por defecto —el
|
||
que trae la barra lateral y la IA— tecleado en el serial. Por eso el guion tiene `--como-usuario`:
|
||
lanza exactamente eso.
|
||
|
||
⚠ **Y la trampa del método que costó una corrida entera**, escrita en el guion para que no se repita:
|
||
el terminal hace **eco** de lo que se le escribe, así que una marca de fin escrita literal aparece en
|
||
el serial ANTES de que el shell ejecute nada — el arnés la lee en el eco, da la orden por terminada y
|
||
manda la siguiente encima. Y `cmd & ; echo …` es **error de sintaxis** en ash: el navegador no se
|
||
lanzó y la corrida siguió como si todo fuera bien, con capturas de un escritorio vacío que se leían
|
||
igual que el fallo que se estaba investigando.
|
||
|
||
#### 6.10.sexies El número: pinta a +197…+204 s — y **2 de 5 veces no pinta** (2026-09-14)
|
||
|
||
El §6.10.quinquies dejó una sola variable viva —cómo se lanza el navegador— y `--as-user` la mide:
|
||
`atuq` pelado, con SU perfil por defecto (el que vive en la ext4 y hace su primer arranque entero),
|
||
tal como se tecleó en el serial aquella noche. Cinco corridas sobre la MISMA imagen:
|
||
|
||
| # | lanzamiento | vídeo | observación | primera pintura |
|
||
|---|---|---|---|---|
|
||
| 1 | perfil nuevo en tmpfs | `-vga none` | 180 s | **+197 s** |
|
||
| 2 | perfil nuevo en tmpfs | `VGA=1` | 240 s | **+197 s** |
|
||
| 3 | perfil del usuario | `VGA=1` | 291 s | ✗ no pintó |
|
||
| 4 | perfil del usuario | `VGA=1` | 900 s | **+204 s** |
|
||
| 5 | perfil del usuario | `VGA=1` | 900 s | ✗ **no pintó en 15 min** |
|
||
|
||
⇒ **el fallo es INTERMITENTE** —con diez corridas más, la tasa quedó en **3 de 13**, ver §6.10.septies—,
|
||
y las corridas 4 y 5 son el mismo comando sobre la misma imagen. No
|
||
es el compositor (§6.10.quater), no es la segunda pantalla (§6.10.quinquies) y **tampoco es sólo que
|
||
tarde**: cuando pinta, pinta siempre alrededor de los 200 s; cuando no, no pinta aunque se le den
|
||
quince minutos.
|
||
|
||
⚠ **La lección de método, y me la comí yo hoy mismo.** Entre la corrida 3 y la 4 publiqué que «la
|
||
ventana SÍ aparece, tarda», y el argumento era el `MOZ_LOG`: en la 3, treinta segundos después de la
|
||
última captura, Gecko decía `mapped 1`, «marked as visible & has buffer» y commiteaba un
|
||
`WaylandBufferSHM` de 1280×696 — lo mismo que hace cuando pinta. La corrida 5 lo refutó: **commiteaba
|
||
cuadros desde ≤ +377 s y la pantalla estaba vacía a los 900 s.** O sea que *el cliente cree que está
|
||
visible* no es evidencia de que se vea; la evidencia es el píxel. Un log en verde compatible con una
|
||
pantalla vacía es exactamente el cuadro contra el que este documento viene advirtiendo, y aun así lo
|
||
usé para cerrar una pregunta. Ver [[la-etiqueta-no-es-el-hecho]].
|
||
|
||
**Dónde queda el número, para que no viva en el scrollback de quien corrió la VM** —que es como se
|
||
perdió el de la primera corrida—: `scripts/cosmic/atuq-en-imagen.py` mide la primera pintura y
|
||
**acumula** cada corrida en `docs/state/primera-pintura.json` con sus condiciones (lanzamiento,
|
||
vídeo, RAM, vcpus, cadencia = resolución del número). Acumula y no pisa **porque el fenómeno es
|
||
intermitente**: un fichero de una sola medición convierte esto en «+204 s» o en «no pinta» según qué
|
||
corrida tocó última, que es la forma más cara de mentir con datos ciertos. Y `scripts/vigia-imagen.py`
|
||
—que mide artefactos en segundos y no arranca nada— lo LEE y lo informa como sexto dato:
|
||
|
||
== primera pintura en la imagen arrancada (no lo mide este vigía)
|
||
⚠ pintaron 3 de 5 corridas · primera pintura +197…+204s
|
||
✓ +197s perfil nuevo en tmpfs virtio-gpu solo (-vga none)
|
||
✗ — perfil del usuario vga+virtio-gpu (dos DRM)
|
||
✓ +204s perfil del usuario vga+virtio-gpu (dos DRM)
|
||
✗ — perfil del usuario vga+virtio-gpu (dos DRM)
|
||
⚠ en las que NO pintó, el MOZ_LOG igual decía `mapped 1` + «has buffer» + …
|
||
|
||
⚠ **Y una trampa para cualquier medición sobre la imagen: `cosmic-idle` ATENÚA la pantalla.** Entre
|
||
+526 s y +590 s sin una sola entrada, los 703 766 px de `(255,0,255)` pasan a 703 779 px de
|
||
`(117,0,117)` — la misma ventana al 46 % de brillo. Por eso el detector cuenta magenta **con
|
||
tolerancia** y no por color exacto: contar el color exacto lee el escritorio dormido como «desapareció
|
||
la ventana».
|
||
|
||
**Lo que queda abierto**, y ahora con arnés para atacarlo: por qué a veces la superficie mapeada y con
|
||
buffer no llega a la pantalla. Lo próximo es mirarlo del lado del compositor —si el `xdg_toplevel`
|
||
recibe su `configure`, en qué workspace y en qué salida quedó la ventana— y correr N veces para tener
|
||
una tasa, no una anécdota.
|
||
|
||
#### 6.10.septies La TASA: «3 de 13» — ⚠ **el número medía LA CÁMARA, no el producto (ver §6.10.octies)** (2026-09-14)
|
||
|
||
Diez corridas seguidas, idénticas, con `scripts/cosmic/tasa-primera-pintura.sh`: `--as-user`, `VGA=1`,
|
||
ventana de 480 s, captura cada 30 s. Más las cinco de las secciones anteriores, todas sobre la misma
|
||
imagen y anotadas en `docs/state/primera-pintura.json`:
|
||
|
||
| lanzamiento | pintaron | primera pintura |
|
||
|---|---|---|
|
||
| perfil del usuario (lo que hace un usuario) | **3 de 13** | 184, 199, 204 s |
|
||
| perfil nuevo en tmpfs | 2 de 2 | 197, 197 s |
|
||
|
||
⚠ **ESTA TABLA NO SIGNIFICA LO QUE PARECE.** Las 13 del perfil del usuario salieron todas con `VGA=1`
|
||
y la de perfil nuevo casi ninguna ⇒ «perfil» y «vídeo» están confundidos; y las capturas miraban UNA
|
||
de las DOS salidas. El §6.10.octies lo mide: la ventana cae en cualquiera de las dos pantallas, así
|
||
que los ✗ son «no estaba en la que fotografié». Lo que sí sobrevive es el tiempo: cuando se la ve,
|
||
se la ve entre +184 y +312 s.
|
||
|
||
**Dos cosas que el número dice y una anécdota no podía:**
|
||
|
||
1. **cuando pinta, pinta siempre en la misma ventana** —184…204 s— y nunca a los 300, 400 ni 900. O
|
||
sea que «tardaba» era falso: no hay una cola larga, hay **dos regímenes**. O sale a los ~200 s o no
|
||
sale;
|
||
2. **la tasa con el perfil del usuario es ~1 de 4**, y con perfil nuevo en tmpfs no falló nunca (2 de
|
||
2 — pocas corridas para afirmar que nunca falla, pero suficientes para que la diferencia valga como
|
||
pista de dónde mirar: qué hace el primer arranque del perfil que el perfil ya hecho no hace).
|
||
|
||
⚠ **Condición de la medida, que es parte del número:** las diez corridas salieron con la máquina
|
||
anfitriona a **load ~10** (la tanda de KDE de otro agente más una VM de otra sesión, en 4 cores). Eso
|
||
empuja hacia el ✗ y por eso el número es un PISO, no una constante del producto: la misma imagen en
|
||
una máquina ociosa puede fallar menos. Lo que no cambia con la carga es lo de arriba: las que pintan
|
||
pintan a ~200 s, no a los 400.
|
||
|
||
⚠ Y una trampa del arnés, medida en las corridas 8–10: con el anfitrión así de cargado, el guest tarda
|
||
más de 420 s en llegar al shell del serial y los `esperar` del guion vencen —«nunca llegó ‹fin de la
|
||
ventana de observación›»—. **No las invalida** (se verificó una por una que el navegador se lanzó y
|
||
dejó su `MOZ_LOG` de 1200–5000 líneas), pero una corrida abortada de verdad se vería casi igual: por
|
||
eso el guion de la tasa cuenta las **abortadas aparte** en vez de sumarlas a los fallos.
|
||
|
||
#### 6.10.octies La causa: **son DOS SALIDAS y la ventana cae en cualquiera** — y el «3 de 13» medía la cámara (2026-09-14)
|
||
|
||
El §6.10.septies cerró con «con el perfil del usuario pinta 3 de 13, con perfil nuevo 2 de 2» y la
|
||
pregunta «qué hace el primer arranque del perfil». La respuesta es: **nada**. Dos cosas antes de la
|
||
medición que la contestó:
|
||
|
||
1. **el `MOZ_LOG` de las que pintan y las que no termina IDÉNTICO** — las dos commitean
|
||
`WaylandBufferSHM` de 1280×696. O sea que el navegador dibuja igual en los dos casos;
|
||
2. **el cruce de las 15 corridas está CONFUNDIDO**: las 13 «perfil del usuario» salieron TODAS con
|
||
`VGA=1`, y de «perfil nuevo» hay una sola con dos tarjetas. Con esa tabla no se puede separar
|
||
«perfil» de «vídeo»: comparar una columna que no varía es comparar con nada.
|
||
|
||
**La medición que lo contesta:** `screendump` de QMP fotografía **un** dispositivo. Con `VGA=1` hay
|
||
dos (`vga0` estándar + `gpu0` virtio-gpu) y hasta ahora se fotografiaba uno. Se le puso `id=` a cada
|
||
uno y se capturan LOS DOS en cada toma. Resultado, en la misma corrida y el mismo instante:
|
||
|
||
+312s ██ PRIMERA PINTURA — 704456 px de la página EN vga0
|
||
gpu0 = 962 675 px de fondo azul + panel y dock (el escritorio, SIN navegador)
|
||
vga0 = 703 766 px magenta (la ventana, con la página)
|
||
|
||
⇒ **`cosmic-comp` maneja las DOS salidas y la ventana cae en una u otra.** Lo que se publicó como
|
||
«pinta 3 de 13» era **cuántas veces la ventana cayó en la pantalla que yo fotografiaba**. No es una
|
||
tasa del producto: es una tasa de mi cámara. Y el §6.10.ter —«arranca y no pinta la ventana»— tiene
|
||
la misma explicación: aquel arranque también tenía dos tarjetas (`drm: card0 card1`), y la captura
|
||
miraba una.
|
||
|
||
**Lo que queda en pie de todo lo anterior**, ahora sí sin confundido:
|
||
· el navegador arranca, mapea su superficie y commitea cuadros **siempre** (eso nunca falló);
|
||
· cuando la ventana está en la pantalla que se mira, se ve entre **+184 y +312 s**;
|
||
· el perfil del usuario **no** era la causa, y `cosmic-comp` nesteado tampoco (§6.10.quater).
|
||
|
||
⚠ **La lección de método, que es la tercera vez en el mismo día:** con dos salidas, un ✗ de una
|
||
captura de una sola pantalla **no dice «la ventana no está»**, dice «no está en ESA». Igual que
|
||
`mapped 1` no probaba que se viera, una foto no prueba que no exista: prueba dónde no está. Por eso
|
||
`docs/state/primera-pintura.json` anota ahora **en qué pantalla** apareció y **cuántas fotografió**
|
||
cada corrida, y `scripts/vigia-imagen.py` **no cuenta las ciegas** — las nombra aparte:
|
||
|
||
⚠ pintaron 6 de 6 corridas CONCLUYENTES · primera pintura +184…+312s
|
||
✓ +312s vga0 perfil del usuario vga+virtio-gpu (dos DRM)
|
||
⚠ 10 corridas NO se cuentan: con dos salidas fotografiaron UNA sola, y su ✗ sólo dice que
|
||
la ventana no estaba en esa pantalla
|
||
|
||
**Lo que sigue**, y ahora es otra pregunta: no «por qué no pinta» sino **por qué el escritorio abre la
|
||
ventana en la salida que no tiene el foco** —y si con una sola salida (`-vga none`, que es como se va
|
||
a usar la imagen de verdad) eso desaparece—. Es barato: la misma serie con una tarjeta. **Hecha: §6.10.nonies — y el hueco es real, 2 de 10.**
|
||
|
||
#### 6.10.nonies Con UNA sola salida: **2 de 10** — y el hueco resulta ser real (2026-09-14)
|
||
|
||
El §6.10.octies cerró pidiendo exactamente esta serie: las mismas diez corridas pero con `-vga none`,
|
||
una sola tarjeta (`drm: card0`), que es como se usa la imagen de verdad. Con una sola salida la
|
||
captura es COMPLETA, así que por primera vez **un ✗ significa «la ventana no está»** y no «no está en
|
||
la que fotografié».
|
||
|
||
| vídeo | pintaron | qué dice el ✗ |
|
||
|---|---|---|
|
||
| `vga+virtio-gpu` (dos DRM) | 4 de 14 | nada: la cámara miraba una de dos pantallas |
|
||
| **`virtio-gpu` solo (`-vga none`)** | **2 de 10** | **concluyente** |
|
||
|
||
Y como la captura ya no tiene punto ciego, se puede clasificar **el último cuadro de cada corrida** por
|
||
su color dominante, que separa dos fallos que hasta ahora eran el mismo ✗:
|
||
|
||
| último cuadro | corridas | qué es |
|
||
|---|---|---|
|
||
| `#ff00ff` 68 % | 2 | ✓ la página, pintada |
|
||
| `#f9f9fb` 68 % | 1 | la **ventana está** —misma geometría que la que pinta— pero **en blanco** |
|
||
| `#214a87` 91 % | 7 | **sólo el escritorio**: fondo, panel y dock. **No hay ventana** |
|
||
|
||
⇒ **el hueco del §6.10.ter existe y no era la cámara.** Las dos salidas explicaban el número inflado
|
||
de ✗ de las series con `VGA=1`, no el fenómeno.
|
||
|
||
**Y el `MOZ_LOG` separa los dos regímenes con un número, no con una impresión:**
|
||
|
||
| | líneas de `moz.moz_log` | último apunte del log |
|
||
|---|---|---|
|
||
| las 3 con ventana | 952 – 1505 | sigue escribiendo hasta que se apaga la VM |
|
||
| las 7 sin ventana | 573 – 727 | **se corta a los ~40 s del lanzamiento**, y quedan **6 minutos de silencio** |
|
||
|
||
No es que dibuje invisible: **deja de trabajar**. Y no muere por un crash — `atuq.log` de una fallida
|
||
llega hasta `WebRender - OpenGL version new 3.2` / `Renderer: Software WebRender` sin una sola línea
|
||
de error. Las últimas líneas que escribe son justo el bucle de vsync emulado:
|
||
|
||
WaylandSurface::VSyncCallbackHandler() marked as visible & has buffer
|
||
WaylandSurface::SetVSyncCallbackLocked(), enabled 1 mapped 1
|
||
WaylandSurface::Commit() allowed [1] needs commit 1
|
||
fire VSync callback aEmulated [0] cb.mEmulated [1] ← y aquí se acaba
|
||
|
||
Que es la forma que tiene Gecko de decir «commiteo a ciegas porque **nadie me devuelve frame
|
||
callbacks**». La hipótesis que queda —**no medida todavía**, y hay que decirlo así— es que
|
||
`cosmic-comp` nunca le da a esa superficie el estado de visible, así que el cliente se queda sin
|
||
callbacks y se detiene. Lo contesta el log del compositor, no el del navegador.
|
||
|
||
⚠ **Condición de la medida:** anfitrión a **load ~15** (la tanda de KDE del otro agente más VMs de
|
||
otra sesión, en 4 cores) — más alta que el ~10 de la serie anterior. Las dos que pintaron lo hicieron
|
||
a **+175 s** y **+319 s**, en línea con los +184…+312 de antes. Y hay un orden sospechoso que la serie
|
||
no puede resolver sola: las 3 con ventana son las corridas 1-3 y las 7 sin ventana las 4-10, todas
|
||
seguidas. Eso puede ser la carga subiendo o puede ser estado que dejan las corridas entre sí; **con
|
||
diez corridas no se separa**.
|
||
|
||
⚠ **Dos deudas del arnés que esta serie dejó a la vista**, ninguna barrida:
|
||
· la cuenta de «procesos atuq» **nunca volvió**: el eco del serial se come la salida, así que no se
|
||
puede afirmar si el proceso seguía vivo en las fallidas (el `atuq.log` sin crash es evidencia
|
||
indirecta, no la misma cosa);
|
||
· `tasa-primera-pintura.sh` quedó parametrizado por `VGA` pero **el nombre del log no**: la serie
|
||
nueva sobrescribió los crudos de la anterior (se salvaron 9 de 10 en `work/serie-dos-tarjetas/`).
|
||
Arreglado después de la serie — no durante: bash lee el guion a medida que lo ejecuta.
|
||
|
||
#### 6.10.decies El log del compositor a `info` NO puede contestar — y por qué hizo falta una perilla (2026-09-14)
|
||
|
||
La hipótesis del §6.10.nonies —«nadie le devuelve frame callbacks»— se contesta del lado de
|
||
`cosmic-comp`, así que el arnés ahora vuelca **`/var/log/cosmic/cosmic-session.log`**, que ya existía
|
||
desde el primer día y nunca se había mirado. Corrida fallida (91 % de azul de escritorio, sin
|
||
ventana), y el log entero:
|
||
|
||
92 líneas. Todas de 21:17:48 — los segundos del arranque. Ni una superficie, ni un toplevel,
|
||
ni una salida. Termina en:
|
||
ERROR cosmic_comp::xwayland: Failed to start Xwayland
|
||
err=Custom { kind: AddrInUse, error: "Could not find a free socket for the XServer." }
|
||
|
||
Dos cosas, y conviene no confundirlas:
|
||
|
||
1. **el ERROR de Xwayland es ruido aquí** — la distro es Wayland-only por decisión (§frente-gnome), y
|
||
`atuq` es cliente Wayland nativo: que no haya X no le quita ni un frame. Es una pista falsa
|
||
esperando a que alguien la agarre;
|
||
2. **a `RUST_LOG=info` el compositor no dice NADA de ventanas.** El log no es que muestre algo raro:
|
||
es que no habla del tema. Un log en silencio no es evidencia de nada — lo mismo imprime cuando
|
||
todo anda.
|
||
|
||
⇒ para responder hace falta subirle el nivel, y eso **no se puede hacer desde fuera**: `cosmic-comp`
|
||
lo lanza `cosmic-start` desde el getty, en el arranque, sin ambiente heredable. La perilla existe y
|
||
estaba a la vista: `cosmic-start` hace `. /etc/cosmic-mode` **antes** de
|
||
`export RUST_LOG="${RUST_LOG:-info}"`, así que ese fichero manda. Y la imagen se arranca sin
|
||
`-snapshot`, o sea que **lo que se escribe adentro persiste**. De ahí
|
||
`atuq-en-imagen.py --set-rust-log VALOR`: entra por el serial, escribe `/etc/cosmic-mode` y apaga; lo
|
||
usa el arranque SIGUIENTE. Con la cadena vacía lo quita, que es como se deja la imagen después de
|
||
medir — subirle el log a una imagen compartida y olvidárselo es dejar una trampa para el próximo.
|
||
|
||
#### 6.10.undecies **La ventana SÍ está: mide 117×70 px** — y la hipótesis de los frame callbacks era falsa (2026-09-14)
|
||
|
||
Como el compositor no habla por su log (§6.10.decies), se le preguntó por el **protocolo**:
|
||
`atuq-en-imagen.py --wayland-debug` lanza el navegador con `WAYLAND_DEBUG=1`, que registra cada
|
||
mensaje en los dos sentidos. Corrida fallida (91 % de azul de escritorio), y esto es lo que MANDA
|
||
`cosmic-comp`:
|
||
|
||
xdg_toplevel#58.configure_bounds(0, 0) ← al mapear: todavía no sabe el tamaño
|
||
xdg_toplevel#58.configure(0, 0, array[0])
|
||
xdg_toplevel#58.configure_bounds(1280, 692) ← después sí
|
||
xdg_toplevel#58.configure(117, 70, array[16]) ← y la ventana queda de 117×70
|
||
wl_surface#52.enter(wl_output#16)
|
||
wl_callback#76.done(…) × 43
|
||
|
||
Y el píxel lo confirma, restando el cuadro de ANTES del de DESPUÉS: aparece un cluster en
|
||
**(496,115), de unos 286×86 con sombra**, con el blanco `#fafafb` y el acento cian `#63d0df` de la
|
||
decoración de COSMIC. Una ventanita.
|
||
|
||
⇒ **`atuq` no deja de pintar: pinta una ventana de 117×70 px.** Con eso caen dos cosas que este mismo
|
||
documento daba por buenas hace dos secciones:
|
||
|
||
| lo que decía | lo que mide el protocolo |
|
||
|---|---|
|
||
| «nadie le devuelve frame callbacks» (§6.10.nonies) | **43 `wl_callback.done`**. Los devuelve |
|
||
| «deja de trabajar a los ~40 s» | deja de **escribir**: con la ventana de 117×70 no hay casi nada que redibujar |
|
||
| «7 de 10 no tienen ventana» (§6.10.nonies) | **la tienen**. Mi contador de magenta no la ve porque es 0,7 % de la pantalla |
|
||
|
||
**La tasa 2 de 10, entonces, tampoco mide lo que decía.** No es «pintó / no pintó»: es «la ventana
|
||
salió grande / salió de 117×70». El fenómeno es real y sigue estando —una ventana así es inusable—
|
||
pero el nombre estaba mal, y con el nombre mal la causa que se busca es otra.
|
||
|
||
**De dónde sale 117×70, y qué es hipótesis.** El primer `configure` llega con `bounds(0,0)`: en ese
|
||
instante el compositor todavía no le puede decir al cliente de qué tamaño es la pantalla, y el
|
||
protocolo dice «elegí vos». La medida NO prueba quién eligió 117×70 —para eso hay que ver qué
|
||
tamaño de buffer commitea el cliente ANTES de ese configure—, pero el orden es compatible con una
|
||
**carrera**: `atuq` dimensiona su ventana antes de tener la geometría de la salida y cae en un
|
||
mínimo. En las corridas que salen bien la ventana mide 1073×549 (§6.10.quater), que es lo que se ve
|
||
cuando la geometría llegó primero. **Eso es lo próximo a medir, y se mide con el mismo arnés.**
|
||
|
||
⚠ **Método, tercera vez en el mismo frente:** el primer vuelco del protocolo filtró por `' -> '`
|
||
creyendo que era «lo que manda el compositor». En `WAYLAND_DEBUG` la flecha marca los **pedidos del
|
||
cliente**; los eventos son las líneas SIN flecha. O sea que volví a leer al navegador —la mitad que
|
||
ya sabíamos que miente— con un filtro nuevo. Y las cuentas dieron cinco ceros porque los nombres
|
||
llevan `#id` en el medio (`wl_surface#30.commit`), así que `wl_surface.commit` no engancha: **un
|
||
grep que devuelve 0 puede ser una ausencia o puede ser un patrón mal escrito, y las dos se ven
|
||
igual.**
|
||
|
||
#### 6.10.duodecies **El 117×70 lo pide el CLIENTE** — el compositor sólo obedece (2026-09-15)
|
||
|
||
El §6.10.undecies dejó abierto quién elige el tamaño, y dijo cómo medirlo: mirar, **en orden y en los
|
||
dos sentidos**, dónde aparece por primera vez. `create_buffer` trae el tamaño (el `attach` no), y
|
||
`set_window_geometry` es lo que el cliente DECLARA querer. Numerado, sale así:
|
||
|
||
590 xdg_toplevel#58.configure_bounds(0, 0) ← compositor: «elegí vos»
|
||
592 xdg_toplevel#58.configure(0, 0, array[0])
|
||
629 → xdg_toplevel#58.set_min_size(117, 37) ← EL CLIENTE
|
||
630 → xdg_toplevel#58.set_max_size(348, 16332)
|
||
631 → xdg_surface#57.set_window_geometry(26, 23, 117, 70) ← EL CLIENTE pide 117×70
|
||
664 xdg_toplevel#58.configure_bounds(1280, 692)
|
||
665 xdg_toplevel#58.configure(117, 70, array[16]) ← el compositor ACATA
|
||
|
||
⇒ **`cosmic-comp` no impone nada.** Tiene 1280×692 para dar y devuelve exactamente lo que el cliente
|
||
pidió. La ventana de 117×70 la elige **`atuq`**.
|
||
|
||
Y el `MOZ_LOG` de la misma corrida dice cómo:
|
||
|
||
nsWindow::Create()
|
||
nsWindow::Create() Initial resize to 1 x 1 ← nace de 1×1
|
||
nsWindow::Create() Toplevel
|
||
nsWindowWayland::CreateNative()
|
||
|
||
**Gecko crea la ventana de 1×1 y nunca la agranda.** El 117×70 no es un tamaño elegido: es lo que
|
||
queda cuando el chrome se mide a sí mismo sin que nadie le diga de qué tamaño tiene que ser. Lo
|
||
confirma el `set_max_size(348, 16332)`: ancho máximo 348 px y alto 16332 — las restricciones de una
|
||
caja que se dimensiona por su contenido, no las de una ventana de navegador. (Y el buffer que
|
||
commitea es de 157×110 para una geometría de 117×70: los ~20 px de margen son la sombra del CSD.)
|
||
|
||
⇒ **el frente se mueve de COSMIC a Gecko.** No hay nada que arreglar en el compositor. Lo que falta
|
||
saber es por qué el `nsWindow` se queda en su tamaño mínimo, y la sospecha —**todavía sin medir**— es
|
||
que cuando dimensiona no tiene la geometría de la salida: el primer `configure` llega con
|
||
`bounds(0,0)`, o sea que en ese instante ni el compositor se la puede dar. Se comprueba ordenando en
|
||
la MISMA lista los eventos `wl_output` contra el `set_window_geometry`; con dos greps separados no se
|
||
puede, porque no se pueden intercalar.
|
||
|
||
⚠ **Esa sospecha se midió al día siguiente y es FALSA —y además la pregunta estaba mal hecha: esa
|
||
ventana no era la del navegador (§6.10.terdecies).** Queda escrita porque el orden importa: era una
|
||
hipótesis razonable, y lo que la desmintió fue preguntarle al `ScreenManager` en vez de seguir
|
||
deduciendo del protocolo.
|
||
|
||
#### 6.10.terdecies **La ventana de 117×70 es un DIÁLOGO: el navegador nunca abrió** (2026-09-15)
|
||
|
||
Las últimas cuatro secciones discutieron por qué la ventana de `atuq` sale de 117×70. La respuesta es
|
||
que **no era la ventana de `atuq`**, y lo dice el mismo volcado de protocolo que ya se había leído
|
||
—dos líneas más abajo de donde paró la lectura—:
|
||
|
||
556 -> xdg_surface#57.get_toplevel(new id xdg_toplevel#58)
|
||
558 -> xdg_toplevel#58.set_title("Open atuq in Troubleshoot Mode?")
|
||
559 -> xdg_toplevel#58.set_app_id("atuq")
|
||
629 -> xdg_toplevel#58.set_min_size(117, 37)
|
||
630 -> xdg_toplevel#58.set_max_size(348, 16332)
|
||
631 -> xdg_surface#57.set_window_geometry(26, 23, 117, 70)
|
||
|
||
Es `chrome://browser/content/safeMode.xhtml`: el **diálogo de Modo de resolución de problemas**. Con
|
||
eso la pregunta cambia de «¿por qué la ventana sale chica?» a «¿por qué `atuq` arranca en modo de
|
||
resolución de problemas?», que tiene respuesta y tiene arreglo.
|
||
|
||
**La cadena entera, y dónde se lee cada eslabón.** Nada de esto es deducción:
|
||
|
||
1. el perfil del usuario traía el contador de caídas alto — `prefs.js` con fecha 15-Sep 00:36, o sea
|
||
**antes** de cualquier corrida de ese día:
|
||
|
||
user_pref("toolkit.startup.recent_crashes", 16);
|
||
|
||
2. el umbral, en **nuestro** build: `browser/omni.ja → defaults/preferences/firefox.js:831` dice
|
||
`pref("toolkit.startup.max_resumed_crashes", 3)` ⇒ 16 > 3 ⇒ `automaticSafeModeNecessary`;
|
||
3. quién abre el diálogo: `modules/BrowserGlue.sys.mjs:389`, en `_beforeUIStartup()`, cuyo propio
|
||
comentario dice *«runs on startup, before the first command line handler is invoked (i.e. before
|
||
the first window is opened)»*:
|
||
|
||
if (Services.appinfo.inSafeMode) {
|
||
Services.ww.openWindow(null, "chrome://browser/content/safeMode.xhtml", "_blank",
|
||
"chrome,centerscreen,modal,resizable=no", null);
|
||
}
|
||
|
||
**modal y antes de la primera ventana** ⇒ no hay un navegador detrás esperando: no hay navegador;
|
||
4. hasta el `set_max_size(348, 16332)` sale de ahí: el título del diálogo se traduce con Fluent y la
|
||
traducción trae el tamaño — `localization/en-US/browser/safeMode.ftl`,
|
||
`troubleshoot-mode-window .style = max-width: 400px`;
|
||
5. y por qué el proceso se MUERE solo (el «`[1]+ Done`» que imprimió el shell del serial): el único camino de
|
||
ese diálogo que termina el programa es `onCancel()` de `safeMode.js`, que llama
|
||
`appStartup.quit(appStartup.eForceQuit)`. El log trae `ATUQ-EXIT=0`: se va solo, sin caerse. Quién
|
||
cancela el diálogo —Escape, el compositor, el cierre de la ventana— **no está medido**.
|
||
|
||
**Cómo se leyó el perfil sin arrancar la imagen**, que es la única forma honesta de mirar el estado
|
||
*previo* —arrancarla lo cambia—: el disco es un raw con tabla de particiones y `debugfs` lee la
|
||
partición sin montarla y sin root:
|
||
|
||
debugfs -R "dump /root/.config/mozilla/firefox/<id>.default-default/prefs.js /tmp/prefs.js" \
|
||
"/mnt/cosecha/takana-cosmic-qemu.img?offset=$((264192*512))"
|
||
|
||
⚠ **Y esa lectura MIENTE si la VM se acaba de matar, con una forma peligrosa** (medido el 2026-09-15
|
||
por la otra sesión del frente): el ext4 del guest queda con el journal sucio, `debugfs` se planta con
|
||
«Block bitmap checksum does not match» y con `-c` abre pero lee el estado **PRE-journal** — ahí
|
||
`prefs.js` figura de **0 bytes** con la fecha de la corrida anterior. Un `prefs.js` vacío leído así se
|
||
ve idéntico a «el navegador borró el perfil», que es un diagnóstico mucho más grave y falso. La
|
||
lectura buena es **desde adentro**, después de que el mount replaye el journal: para eso están los
|
||
campos `recent_crashes_antes` / `_despues` del arnés. `debugfs` sirve para mirar el estado *previo*
|
||
con la imagen apagada, no para leer lo que dejó un kill.
|
||
|
||
##### La sospecha del §6.10.duodecies era falsa: Gecko SÍ ve la pantalla, 1280×800
|
||
|
||
Se midió agregándole `WidgetScreen:5` al `MOZ_LOG` —el módulo que registra qué monitores arma el
|
||
`ScreenManager`; que el módulo EXISTE en el `libxul` sellado se comprobó con `grep` antes de confiar
|
||
en la variable, porque un módulo que no está se ve igual que un módulo que no tiene nada que decir—.
|
||
La respuesta llega con hora:
|
||
|
||
17:12:29.031 D/WidgetScreen New monitor 0 size [0,0 -> 1280 x 800] depth 24 scale 1.0 … refresh 75
|
||
17:12:29.031 D/WidgetScreen workarea [0, 0] -> [1280 x 800]
|
||
17:12:31.377 D/WidgetScreen (otra vez, ya en el proceso Parent)
|
||
17:12:34.843 D/Widget nsWindow::Create() Initial resize to 1 x 1 ← nace de 1×1
|
||
17:12:35.290 D/WidgetScreen GetScreenForWindow() [0] screen (x=0, y=0, w=1280, h=800)
|
||
|
||
⇒ la pantalla está completa y a tiempo, **cinco segundos antes** de que la ventana se cree, y la
|
||
propia ventana la resuelve bien cuando pregunta. La carrera con `wl_output` queda **refutada** y el
|
||
`configure_bounds(0, 0)` del principio es ruido del protocolo, no información que a Gecko le falte.
|
||
Que el 117×70 resultara ser un diálogo con `max-width` no le quita valor: era la hipótesis viva y
|
||
hacía falta matarla con un instrumento.
|
||
|
||
##### El control, en los DOS sentidos — y es el contador
|
||
|
||
Dos corridas seguidas sobre la misma imagen, el mismo artefacto y el **mismo perfil**, cambiando una
|
||
sola cosa: `toolkit.startup.recent_crashes`, fijado en el `user.js` del perfil (que gana sobre
|
||
`prefs.js` en cada arranque). El mando es `atuq-en-imagen.py --crashes N`:
|
||
|
||
| corrida | contador | qué pasó |
|
||
|---|---|---|
|
||
| **A** 17:31 | ausente (0) | ✓ **primera pintura +123 s**, 704 458 px de la página magenta |
|
||
| **B** 17:44 | fijado en **16** | ✗ nada en 300 s · `set_title("Open atuq in Troubleshoot Mode?")` · `ATUQ-EXIT=0` |
|
||
|
||
⚠ **Y una honestidad que cambia la fuerza del argumento:** en la corrida A el `--crashes 0` fue un
|
||
**no-op** —el contador ya no estaba en `prefs.js`, y **por qué no estaba sigue sin explicación
|
||
medida** (ver más abajo: mostrar el diálogo NO lo limpia)—, así que A sola no prueba nada: prueba
|
||
tanto «lo arregló el 0» como «el perfil ya estaba limpio». **La que prueba la causa es B**, que lo pone de vuelta y rompe de vuelta. Una sola dirección
|
||
habría sido una correlación con suerte.
|
||
|
||
##### ⚠⚠ La serie de `primera-pintura.json` está CONTAMINADA, y por el propio andamiaje
|
||
|
||
El arnés mata la VM con el navegador vivo. Un arranque que no llega a terminar es, para Gecko, una
|
||
caída de arranque; pasados 3 el sujeto de la medición **deja de ser el navegador y pasa a ser el
|
||
diálogo**. Con 16 acumulados, todo lo medido desde el 14-Sep cae bajo sospecha: las tasas «3 de 13»
|
||
del §6.10.septies y «2 de 10» del §6.10.nonies no midieron intermitencia del producto sino **un
|
||
perfil que se degradaba corrida a corrida**. El instrumento degradaba al sujeto.
|
||
|
||
**Y eso quedó MEDIDO el mismo día** (tres fases de 60 s en un mismo arranque, cada una matada con
|
||
`kill -9` **antes** de pintar — o sea antes del gancho que cierra la detección de caída de arranque):
|
||
|
||
| fase | contador antes | después | qué la precedió |
|
||
|---|---|---|---|
|
||
| k1 | ausente | **ausente** | una corrida SANA, que terminó bien |
|
||
| k2 | ausente | **1** | k1, matada antes de pintar |
|
||
| k3 | 1 | **2** | k2, matada igual |
|
||
|
||
Es un **tally**: uno por arranque interrumpido, sin techo. Y trae dos cosas que la serie sola no
|
||
distinguía:
|
||
|
||
- **el incremento lo escribe el arranque SIGUIENTE, no el kill.** Se ve en que el «después» de k1
|
||
sigue ausente —el navegador estaba vivo todavía— y el 1 aparece recién en el «después» de k2.
|
||
Quien cuenta no es el que muere sino el que arranca y encuentra el anterior sin terminar; de ahí la
|
||
consistencia interna de la serie, `antes(k3) = después(k2) = 1`;
|
||
- **un arranque que sigue a una corrida sana no suma** (k1). Eso separa «cualquier arranque
|
||
incrementa» de «sólo después de uno interrumpido», que es la diferencia entre culpar al kill y
|
||
culpar al arranque que no llegó a terminar.
|
||
|
||
##### El borde, medido en los dos lados: **3 guardado no alcanza, 4 sí**
|
||
|
||
Partiendo del perfil en 2, tres fases más en un arranque (las dos primeras matadas antes de pintar):
|
||
|
||
| fase | antes | después | qué ventana abrió | `ATUQ-EXIT` |
|
||
|---|---|---|---|---|
|
||
| k1 | 2 | 3 | **el navegador** — `set_title("atuq")`, `set_min_size(638, 120)` | 137 (la matamos) |
|
||
| k2 | 3 | **4** | **el diálogo** — `set_title("Open atuq in Troubleshoot Mode?")`, `min_size(117, 37)` | 0 |
|
||
| k3 | 4 | **5** | el diálogo otra vez | 0 |
|
||
|
||
⇒ **la comparación es DESPUÉS de sumar**: el arranque que encuentra 3 guardado lo sube a 4, compara
|
||
4 > 3 y abre el diálogo; el que encuentra 2 pinta. Contando desde la última corrida que terminó bien,
|
||
eso son **cuatro arranques interrumpidos seguidos, y el quinto lanzamiento ya no abre el navegador**.
|
||
|
||
Y la otra mitad del cálculo lo confirma sin ambigüedad: `toolkit.startup.last_success` vale
|
||
**1789494795 en las tres fases** —congelado en el arranque de la última corrida sana—. El contador
|
||
sube mientras `last_success` no se mueve; con las dos cifras juntas, un contador que no cambió deja
|
||
de ser ambiguo entre «no subió» y «subió y se limpió».
|
||
|
||
⚠⚠ **Y eso REFUTA algo que este documento afirmaba dos secciones más arriba: mostrar el diálogo NO
|
||
limpia el contador.** k2 lo dejó en 4 y k3 en 5 — dos corridas consecutivas con diálogo y ninguna
|
||
limpieza. Entonces la desaparición del 16 entre las 17:12 y las 17:20 **no tiene explicación
|
||
medida**, y la atribución «Gecko lo borró al mostrarlo» se retira. Dos candidatas, las dos sin
|
||
comprobar: que el `prefs.js` se perdiera en el kill (cuando se lo leyó con `debugfs` figuraba de 0
|
||
bytes, y ese volcado no es fiable — es la lección del párrafo de `debugfs`), o que un arranque que
|
||
TERMINA sí lo limpie, que es lo que hace upstream.
|
||
|
||
⚠ Hubo una tercera candidata, mía, y **está descartada con un cero que viene con control**: «el
|
||
`--crashes 0` fija el valor por DEFECTO y un pref igual al default no se persiste, así que el
|
||
“ausente” prueba el pin y no una limpieza». No: `toolkit.startup.recent_crashes` **no tiene default
|
||
en este build**, y por eso un 0 puesto a mano sí se escribe. Tres lugares del artefacto sellado lo
|
||
dicen y el cuarto es el que convierte a los otros en evidencia:
|
||
|
||
greprefs.js (omni de toolkit) → 0 líneas con «toolkit.startup»
|
||
browser/omni.ja defaults/preferences/*.js → 0 líneas con «recent_crashes»
|
||
libxul.so, strings «sMirror_toolkit_startup*» → 0 ⇒ no es pref de StaticPrefList
|
||
libxul.so, strings «sMirror_browser_crashReporter*» → 2 ⇐ EL CONTROL, que TENÍA que dar positivo
|
||
|
||
Los dos primeros ceros no valen solos, y el primero es el ejemplo de por qué: `greprefs.js` da cero
|
||
porque ahí no vive **ningún** `toolkit.startup.*`, no porque el pref no exista. Un `grep` que
|
||
devuelve 0 puede ser una ausencia o un sitio equivocado, y las dos se ven igual; lo único que las
|
||
separa es un patrón hermano que tenga que dar positivo en el mismo fichero. (Y una distinción que se
|
||
parece a otra y no lo es: el binario lee ese pref con un **0 de respaldo en el sitio del `GetInt`**,
|
||
que no es un default registrado y no evita que el valor se escriba.)
|
||
|
||
##### La serie sana — y lo que refuta: **pintar no es TERMINAR**
|
||
|
||
Cuatro arranques FRÍOS en serie, el perfil partiendo del 5 que dejó la corrida del umbral, con
|
||
`--until-paint` salvo la tercera (matada a los 60 s a propósito):
|
||
|
||
| corrida | pin | antes | después | ventana | primera pintura |
|
||
|---|---|---|---|---|---|
|
||
| **S1** 18:27 | `--crashes 0` | 5 | 1 | el navegador | ✓ **+97 s** |
|
||
| **S2** 18:33 | — | 1 | 2 | el navegador | ✓ **+98 s** |
|
||
| **S3** 18:38 | — | 2 | 3 | (matada a los 60 s) | — |
|
||
| **S4** 18:50 | — | 3 | 4 | **el diálogo** (`min_size(117, 37)`, título Troubleshoot) | ✗ en 420 s |
|
||
|
||
Tres cosas, y la tercera no la esperaba nadie:
|
||
|
||
1. **el número de la tasa, por fin con perfil sano: +97 s y +98 s**, dos corridas independientes y
|
||
pegadas —contra los +123, +195, +197 y +204 de antes—. Con el anfitrión más descargado el mismo
|
||
artefacto pinta en la mitad de tiempo, o sea que el número de esta serie es de **la máquina y la
|
||
carga tanto como del producto**: se publica con sus condiciones o no se publica;
|
||
2. **el umbral se reprodujo solo, en otra secuencia y sin buscarlo**: S4 arrancó con 3 guardado,
|
||
sumó a 4 y abrió el diálogo. Es la segunda medición independiente del mismo borde, y esta vez la
|
||
ventana se identificó por `min_size` y no por el título;
|
||
3. ⚠⚠ **y una corrida que PINTA no limpia el contador.** S2 pintó y dejó el 2; S1 pintó y dejó 1.
|
||
Más todavía: `toolkit.startup.last_success` vale **1789494795 en las cuatro corridas** —la marca
|
||
del arranque de las 17:53— o sea que **el gancho de cierre no llegó a correr ni una sola vez**,
|
||
incluso en las que pintaron. Pintar no es terminar el arranque.
|
||
|
||
⇒ la pregunta de quién limpia el contador **no la cierra esta serie** —el §6.10.terdecies decía que
|
||
sí—, y lo que hacía falta era una corrida que dejara al navegador **seguir vivo** después de pintar.
|
||
|
||
##### Lo que limpia el contador: **sobrevivir un rato después de pintar** (2026-09-15)
|
||
|
||
El par aísla la variable: mismo pin, mismo perfil, misma imagen, y la única diferencia es cuánto vive
|
||
el navegador después del primer cuadro.
|
||
|
||
| corrida | pin | antes | pintó | vida después de pintar | después |
|
||
|---|---|---|---|---|---|
|
||
| S1 18:27 | `--crashes 0` | 5 | +97 s | corta — `--until-paint` corta la observación | **1** |
|
||
| cierre-1 19:06 | `--crashes 0` | 4 | +120 s | **~360 s** (ventana de 480 s, sin `--until-paint`) | **ausente** |
|
||
|
||
⇒ **pintar no alcanza; sobrevivir un rato después de pintar, sí.** Y explica A sin anomalía: A
|
||
también siguió viva minutos después del primer cuadro, porque los volcados por el serial tardan.
|
||
|
||
**Y el sello viaja con la limpieza: es UN gancho, no dos.** El «antes» de la corrida siguiente trae
|
||
`last_success = 1789498722 = 18:58:42 UTC`, que es la hora de **arranque** de `cierre-1`: el mismo
|
||
arranque que limpió el contador escribió el sello. (Ese volcado sólo grepeaba el contador y se
|
||
resolvió en el instrumento en vez de dejarlo como nota: `last_success` va ahora en los DOS volcados y
|
||
se guarda en la medición, `last_success_antes` / `_despues`.)
|
||
|
||
##### ⚠ El reloj es el del LANZAMIENTO, no el de la pintura — y el piso que sale de ahí
|
||
|
||
Leyendo el perfil en CADA captura (`--watch-prefs`), la traza de una corrida arrancada a las
|
||
19:12:17:
|
||
|
||
| captura | `last_success` en disco | `recent_crashes` |
|
||
|---|---|---|
|
||
| +30 s · +64 s · +100 s | 18:58:42 (el de la corrida anterior) | 0 |
|
||
| **+137 s** ██ primera pintura | **19:12:17 (el suyo)** | **ausente** |
|
||
| +167…+433 s | 19:12:17 | ausente |
|
||
|
||
⇒ el sello llega al disco **entre +100 s y +137 s desde el lanzamiento**. Y S1 —que parecía la
|
||
excepción— lo **acota por abajo de forma independiente**: sus propios ficheros dicen que vivió
|
||
**100 s** desde el lanzamiento (primer cuadro a +97 s, conversión a PNG tres segundos después) y no
|
||
selló. Dos corridas distintas, el mismo intervalo.
|
||
|
||
**Si el reloj fuera el de la pintura, S1 tendría que haber sellado** —pintó a +97 s y siguió unos
|
||
segundos— y no lo hizo. Con el reloj del lanzamiento encaja todo sin excepciones: S1 pintó pronto y
|
||
murió a los 100 s; `cierre-1` pintó a +120 y vivió 480; A pintó a +195 y vivió los minutos de los
|
||
volcados; la de arriba selló a los ~+120 pintara cuando pintara.
|
||
|
||
⚠ **Y la consecuencia es contraintuitiva, y es la que muerde: cuanto MÁS RÁPIDO pinta, más fácil es
|
||
envenenar el perfil**, porque `--until-paint` corta antes. La serie que se rompió a la cuarta se
|
||
rompió por eso — no por ser larga, por ser rápida. La regla está en el arnés y no en el runbook:
|
||
`PISO_SELLO = 180` (margen sobre la cota alta de 137), `--until-paint` no corta antes de ese piso y
|
||
dice por qué sigue esperando. El comentario lleva los dos números medidos al lado, porque **un piso
|
||
que se lee como elegido lo baja el próximo que lo vea**.
|
||
|
||
⚠ **Y el piso se COMPRUEBA, no se confía.** Los 180 s son de ESTA máquina —TCG sin KVM y con la carga
|
||
que tenga el anfitrión, igual que los +97/+120/+137 de la pintura—, así que con el anfitrión cargado
|
||
el mismo arranque puede no llegar al gancho: la corrida sumaría al contador y envenenaría a la
|
||
siguiente **en silencio**, que es exactamente como empezó todo esto. Como el sello se lee antes y
|
||
después, el arnés lo dice en la cara:
|
||
|
||
✓ el arranque SELLÓ el éxito (last_success 18:58:42 → 19:12:17): esta corrida no envenena la siguiente
|
||
⚠⚠ el sello NO se movió: este arranque no terminó ⇒ la corrida SUMA al contador. Subí PISO_SELLO
|
||
|
||
Un piso que falla callado es un piso que no existe; éste avisa cuando no alcanzó.
|
||
|
||
⚠ **Y el 16 sigue sin explicación, con una contradicción medida encima**: desapareció en una corrida
|
||
que fue diálogo + `ATUQ-EXIT=0`, y las fases k2/k3 del umbral fueron diálogo + `ATUQ-EXIT=0` también
|
||
y no limpiaron nada (quedaron en 4 y 5). Mismo par de condiciones, resultado opuesto ⇒ «el diálogo
|
||
que se cierra limpio limpia el contador» no se sostiene. Conviene no dejar que la respuesta buena
|
||
—la de la tabla de arriba— tape esta, que es de otra pregunta.
|
||
|
||
**Y la consecuencia para el instrumento es lo más caro de la serie:** con `--until-paint`, **cada
|
||
corrida suma uno**, pinte o no pinte. Cuatro corridas envenenan cualquier perfil, así que una serie
|
||
larga se rompe sola a la cuarta — que es exactamente lo que había pasado ayer sin que nadie lo viera.
|
||
Dos formas de convivir con eso, las dos escritas en el arnés: **pinnear `--crashes 0` en cada corrida
|
||
de una serie** (S1 muestra que el pin funciona incluso partiendo de 5: resetea la base y el
|
||
incremento del arranque la deja en 1, lejos del borde), o dejar que el navegador llegue al gancho de
|
||
cierre, que cuesta más tiempo de VM del que cuesta medir la pintura. Y mientras tanto el arnés
|
||
**avisa** cuando el contador está en 3: la corrida siguiente va a abrir el diálogo y no el navegador.
|
||
|
||
**Un discriminador que salió gratis y sirve para siempre:** el `ATUQ-EXIT` de una fase que matamos es
|
||
**137** (128+9) y el de una corrida que se fue sola por el diálogo es **0**. El código de salida
|
||
distingue «la mataron» de «se fue sola» sin mirar nada más — que es justo lo que el §6.10.terdecies
|
||
no podía distinguir de una captura.
|
||
|
||
Qué se hizo con eso, en vez de borrar el fichero:
|
||
|
||
- **el contador viaja con cada medición**: `recent_crashes_antes`, `recent_crashes_despues` y
|
||
`recent_crashes_fijado` son campos de cada corrida de `docs/state/primera-pintura.json`. Antes y
|
||
después porque el número cambia DENTRO de la corrida —lo escribe el arranque, no el kill— y porque
|
||
sin el «después» no se puede encadenar una corrida con la siguiente, que es lo que convirtió esto
|
||
en una serie legible;
|
||
- **el vigía las APARTA y lo dice**: `scripts/vigia-imagen.py` saca de la tasa las corridas con el
|
||
contador > 3 y, para las 21 anteriores al campo, informa que **no se pueden clasificar** en vez de
|
||
contarlas como buenas. Un denominador que se marca, no que se borra.
|
||
|
||
##### Tres arreglos de instrumento que salieron de la misma corrida
|
||
|
||
- **el código de salida se escribe**: el lanzamiento va en un subshell que agrega `ATUQ-EXIT=$?` al
|
||
log. En la corrida de las 17:12 el navegador terminó a los ~25 s y el arnés siguió 420 s
|
||
fotografiando un escritorio sin navegador para concluir «no pintó en 420 s» — cierto y sin ningún
|
||
significado. «Se cayó» y «se fue solo» se ven igual en una captura y son frentes distintos;
|
||
- **se lee el log ENTERO**, no `tail -25`: con `WAYLAND_DEBUG=1` esas 25 líneas son protocolo, y los
|
||
errores de JS del chrome nunca se habían leído. Ahora hay `head` y un `grep` de
|
||
`JavaScript error|Uncaught|NS_ERROR|###!!!`; y como el disco de la VM es persistente, el log del
|
||
arranque anterior se copia a `/var/log/cosmic-anterior/` antes de pisarlo;
|
||
- **el perfil se BUSCA y se imprime**: el `find` miraba sólo `/root` a 4 niveles y el perfil está a
|
||
cinco (`/root/.config/mozilla/firefox/<id>.default-default`), así que la sonda salía muda con un
|
||
aviso de una línea. Ahora va `find / -xdev -maxdepth 7` e imprime `$HOME`: si el perfil no está
|
||
donde se supone, lo que hace falta es saber dónde está.
|
||
|
||
Y la lista ordenada del protocolo tenía su propio agujero: **se ahogaba en los ~60 modos que anuncia
|
||
QEMU** (`wl_output.mode(0, …)`), así que el `head -70` cortaba antes del `set_window_geometry` —la
|
||
lista que existía para ordenar dos cosas se quedaba sin una de las dos—. Ahora excluye los modos no
|
||
actuales y trae `set_title`/`set_app_id`, que es lo que identificó la ventana.
|
||
|
||
##### ⚠ Dos agentes, una imagen — y lo que nos salvó fue el lock de QEMU
|
||
|
||
Mientras se medía esto, **dos sesiones distintas** corrieron `atuq-en-imagen.py` sobre el mismo
|
||
`takana-cosmic-qemu.img`. La segunda murió en el arranque:
|
||
|
||
qemu-system-x86_64: -drive file=…/takana-cosmic-qemu.img,format=raw,if=virtio:
|
||
Failed to get "write" lock. Is another process using the image?
|
||
|
||
Dos VMs escribiendo el mismo raw es corrupción silenciosa del sistema de ficheros del guest, y la
|
||
imagen **no** se abre con `snapshot=on` a propósito (las corridas se acumulan: de ahí salen los logs
|
||
del arranque anterior). Que el candado exista es de QEMU, no nuestro — el mismo patrón del ADR 0012
|
||
con el árbol de fuentes, pero con la mitigación ya puesta. Dos consecuencias:
|
||
|
||
- **la imagen es de a uno, y quien la usa lo dice**;
|
||
- el arnés ahora **mira si QEMU murió** en el primer segundo y muestra `qemu.log`. Sin eso, el fallo
|
||
se veía como diez minutos esperando una marca del serial que no iba a llegar nunca: el socket queda
|
||
creado, el `connect()` funciona y no falla nada.
|
||
|
||
##### Lo que queda, y una decisión de producto
|
||
|
||
1. **decidir si `atuq` debe traer el modo automático apagado.** `toolkit.startup.max_resumed_crashes
|
||
= -1` lo desactiva. A favor: en una distro que se entrega, un diálogo modal en inglés sin
|
||
navegador detrás es un arranque roto para el usuario, y el diálogo aparece por caídas que pueden
|
||
no ser suyas. En contra: es una red de seguridad de upstream, y apagarla esconde caídas de
|
||
arranque reales. **Es una decisión, no un arreglo que se mete de paso** — toca `recipes/atuq/` y
|
||
por lo tanto re-sella el artefacto (~340 M de store y todas las sondas a repetir);
|
||
2. **rehacer la serie de la tasa con perfil sano**: hoy hay UNA corrida limpia (+123 s) y el resto
|
||
está marcado. «Cuánto tarda en pintar» vuelve a estar sin medir, y ahora se puede medir bien;
|
||
3. ~~¿respeta el `sizemode=maximized` que el perfil recuerda?~~ **CONTESTADA el mismo día: sí.** Con
|
||
perfil sano el navegador pintó a +195 s y el protocolo trae las dos cifras, cada una en su papel:
|
||
`set_window_geometry(26, 23, 1332, 852)` —el tamaño normal que restaura del `xulstore`— y
|
||
`configure(1280, 692)` acatado, que es el área de trabajo por el maximizado. Ninguna es 1152×720
|
||
⇒ `browser-init.js` no calculó nada, que es lo correcto en un perfil que ya recuerda.
|
||
|
||
**De ahí sale el discriminador barato entre las dos ventanas**, y vale para cualquier corrida
|
||
futura: el navegador pide `set_min_size(638, 120)` —después (690, 172)— y
|
||
`set_max_size(16332, 16332)`; el diálogo pide `set_min_size(117, 37)` y `set_max_size(348, 16332)`.
|
||
Con mirar el `min_size` se sabe cuál abrió, **y hay que mirar ése y no el título**: `set_title`
|
||
también las separa pero está TRADUCIDO, así que en una imagen en castellano el `grep` por
|
||
«Troubleshoot Mode» deja de enganchar y su ausencia se lee como «no salió el diálogo» — la
|
||
conclusión contraria a la verdadera. El `min_size` es la misma pregunta hecha a un número. (Y el
|
||
`348` del diálogo sale de su propio Fluent: `troubleshoot-mode-window .style = max-width: 400px`.
|
||
Una cadena de localización decidiendo geometría, que es por qué el 117×70 no se parecía al tamaño
|
||
de nada.)
|
||
|
||
Dos cosas más de esa corrida: el `xulstore` quedó igual ⇒ **una corrida sana no ensucia lo que el
|
||
perfil recuerda**; y `recent_crashes_despues` salió **ausente**, que en su momento se leyó como
|
||
«la corrida que pinta limpia el contador» y **hay que no leerlo así** — esa corrida llevaba
|
||
`--crashes 0` pinneado, o sea el valor por DEFECTO, y un pref igual al default no se persiste. El
|
||
«ausente» puede ser eso y no una limpieza.
|
||
|
||
### 6.11 Y ahora la pregunta que faltaba: ¿REPRODUCE? (2026-09-07)
|
||
|
||
El §6.10 preguntó «¿qué NO puede hacer?» y contestó leyendo cadenas. Media pregunta. La otra mitad
|
||
—**¿se ve el vídeo?**— no la contesta ningún auditor de ELF, y al hacerla aparecieron **dos errores
|
||
del mapa y un bug real del navegador**.
|
||
|
||
**Error 1: «no hay receta de ffmpeg».** La hay. `recipes/ffmpeg.toml`, 7.1, sellada, publica
|
||
`libavcodec.so.61` —uno de los once sonames que `libxul.so` sondea por `dlopen` literal— y **ya
|
||
viajaba en la clausura de los cuatro escritorios**, arrastrada por `mpv`. Comprobado con
|
||
`yupana.membresia`, que es la vista honesta por par `(cola, nombre)`, no leyendo el TOML:
|
||
|
||
escritorio-kde 308 nodos · escritorio-gnome 192 · escritorio-sway 203 · escritorio-cosmic 163
|
||
ffmpeg ✓ en los cuatro · libva ✓ en los cuatro
|
||
|
||
Lo que faltaba no era la receta: era **la lista de raíces de `scripts/atuq-nested.sh`**, escrita a
|
||
mano, que hidrataba un rootfs más pobre que la imagen. Otra vez el instrumento y no el artefacto —
|
||
la misma forma que `gcc-libs` el día anterior. Y la comprobación previa que sí se pudo hacer sin
|
||
correr nada: de los 82 símbolos con forma `av*_` que `libxul` dlsimboliza, **los 9 que nuestro
|
||
`libavcodec` no exporta son los de la API vieja** (`avcodec_decode_video2`, `av_lockmgr_register`,
|
||
`avcodec_register_all`…), que Firefox sólo pide para las versiones 53-57.
|
||
|
||
**Error 2: «VA-API, sin receta».** También la hay, y también está en las cuatro imágenes. El hueco
|
||
es real pero por la OTRA mitad: las tres recetas de mesa construyen con `-Dgallium-va=disabled` y
|
||
`-Dvideo-codecs=` vacío ⇒ **no existe un solo `*_drv_video.so`**. Un cargador sin ICD no acelera
|
||
nada, exactamente como `vulkan-loader`. ⚠ Y con una trampa nueva: ahora que `libva` está en el
|
||
rootfs, **el mapa de `--dlopen` ya no la reporta**, porque mide presencia de fichero. La librería
|
||
presente SILENCIA el aviso sin encender la función. Queda escrito acá porque el guardián ya no
|
||
puede decirlo.
|
||
|
||
**El bug real: AV1 no reproducía.** Gecko **se come el primer elemento de `LD_LIBRARY_PATH`** al
|
||
lanzar el proceso RDD, el que decodifica vídeo. El lanzador ponía `/usr/lib/atuq` una sola vez —o
|
||
sea, primero—, así que el RDD arrancaba sin él, no encontraba el ffvpx bundleado (donde vive dav1d)
|
||
y anotaba `FFVPX: Link result: NoProvidedLib`. Y ahí **no falla**: se cae al ffmpeg del sistema, que
|
||
cubre H.264/AAC/VP8/VP9/MP3/FLAC/Opus… pero **no AV1 por software**, porque el decodificador `av1`
|
||
de ffmpeg es sólo hwaccel y nuestra receta va sin dav1d. Resultado: un vídeo AV1 se quedaba en
|
||
`readyState=1` para siempre, sin un error, sin un NEEDED faltante y sin una cadena ausente.
|
||
|
||
Tres corridas del MISMO artefacto que sólo cambian esa variable:
|
||
|
||
LD=/usr/lib/atuq:/usr/lib:/lib → RDD NoProvidedLib AV1 ✗
|
||
LD=/usr/lib/atuq:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓
|
||
LD=/relleno:/usr/lib/atuq:/usr/lib → RDD Success AV1 ✓ (el relleno se lo come)
|
||
|
||
El arreglo es repetir el appdir en `recipes/atuq/bin/atuq`. Feo y correcto mientras no haya
|
||
`patchelf` en el corpus para grabar `RUNPATH=$ORIGIN`, que borraría la variable entera.
|
||
|
||
**Lo que quedó medido**, con `scripts/test-atuq-codecs.sh`, headless, por el lanzador de verdad y
|
||
sin `LD_LIBRARY_PATH` de afuera:
|
||
|
||
| Muestra | Veredicto |
|
||
|---|---|
|
||
| H.264 + AAC (mp4) | ✅ `t=3.00`, 75 cuadros |
|
||
| VP9 + Opus (webm) | ✅ `t=3.00` |
|
||
| AV1 (mp4) | ✅ `t=3.00` — sólo desde el arreglo del lanzador |
|
||
| MP3 | ✅ |
|
||
| FLAC | ✅ |
|
||
|
||
El veredicto **no es «se creó el decodificador»**: es que `currentTime` AVANCE. La distinción no es
|
||
retórica — en el caso de AV1 el decodificador se creaba (`RemoteDecoderChild … codec: av1`) y no
|
||
entregaba un solo cuadro. El guardián corre con `--negative-control`, que saltea el lanzador y
|
||
**exige que AV1 falle**: probado en los dos sentidos, porque uno que no puede fallar y uno que nunca
|
||
falló se ven igual desde afuera.
|
||
|
||
**Dos bugs de andamiaje que salieron de paso**, los dos en `atuq-nested.sh` y los dos escondidos
|
||
detrás de una caché: el bucle de hidratación salía 1 siempre (línea vacía final + `set -e`) y mataba
|
||
el script sin imprimir nada, y la comparación del sello nunca daba igual (un `\n` que `$(cat …)`
|
||
recorta de un lado y no del otro), así que el rootfs se rehidrataba entero en cada corrida. El
|
||
primero sólo se veía cuando cambiaban los hashes; lo destapó agregar `ffmpeg` a las raíces.
|
||
|
||
### 4.g El chrome se programa SIN abrir `omni.ja` — y la vista dividida ya la trae el motor (2026-09-09)
|
||
|
||
El pendiente escrito era «el chrome de verdad dentro de `omni.ja` (split view, dientes propios): el
|
||
re-empaque ya está resuelto, lo que falta es el contenido». Las dos mitades de esa frase eran falsas,
|
||
y las dos se midieron con `scripts/test-atuq-chrome.py` sobre el artefacto vigente:
|
||
|
||
VIVO el JS de atuq.cfg corre
|
||
VENTANA con gBrowser
|
||
PREF splitView.enabled=true
|
||
WRAPPER tabs=2
|
||
ACTIVA si
|
||
BROWSERS 2
|
||
|
||
**1. El zip no es el gancho del chrome; `atuq.cfg` sí.** Ese fichero ya corre con privilegios —de ahí
|
||
sale `nsIStyleSheetService`, y es la razón de `sandbox_enabled = false`—, así que desde ahí se observa
|
||
`browser-delayed-startup-finished` y se toca el `gBrowser` de CADA ventana que abra el navegador. La
|
||
sonda abre dos pestañas y las parte en dos desde `atuq.cfg`, sin tocar `omni.ja`.
|
||
|
||
Y no es un atajo: es la vía CORRECTA, porque re-empacar `omni.ja` es exactamente lo que pelea con el
|
||
orden del `jarlog` del PGO —el motivo por el que el `jarlog` está aparcado (`c3413d29`)—. Dicho al
|
||
revés: **cada función del chrome que no entre al zip es una que no compite con la optimización de
|
||
arranque.** Eso cambia el orden de preferencia para todo lo que venga: primero prefs, después
|
||
extensión de sistema, después JS de chrome por autoconfig, y `omni.ja` sólo si no queda otra.
|
||
|
||
**2. La vista dividida no hay que escribirla: Firefox 154 la trae y viene PRENDIDA.** En
|
||
`browser/omni.ja` están `tabbrowser/tabsplitview.js` (492 líneas, el custom element completo con
|
||
`addTabs`/`unsplitTabs`/`reverseTabs`), `opentabs-splitview.mjs` y `split-view-footer.js`; y
|
||
`defaults/preferences/firefox.js` trae `pref("browser.tabs.splitView.enabled", true)`. La sonda no se
|
||
queda en «el fichero está»: llama a `gBrowser.addTabSplitView([t1, t2])` y le pregunta al motor, que
|
||
contesta `activeSplitView` puesta y `splitViewBrowsers.length == 2`. **Funciona en nuestro build.**
|
||
|
||
Es el mismo patrón que ya pagó dos veces en este SDD —los dientes (`sidebar.verticalTabs`), los
|
||
contenedores (`privacy.userContext.enabled`)—: la función existe en el motor y viene apagada o sin
|
||
usar, y el trabajo es prenderla y comprobarla, no escribirla. La forma de equivocarse es al revés:
|
||
declarar como deuda propia algo que upstream ya nos dio.
|
||
|
||
⚠ **Lo que esto NO dice.** atuq todavía **no lleva JS de chrome propio**: lo medido es el GANCHO, con
|
||
la sonda inyectada por una capa de overlay que no viaja en el artefacto. Cuando haya una función que
|
||
lo justifique, va en `recipes/atuq/chrome/atuq.js` y la carga `atuq.cfg`. No se shipea un cargador
|
||
vacío para «tenerlo listo».
|
||
|
||
El control negativo sale del propio motor y no de una idea nuestra: `addTabSplitView` documenta que
|
||
si TODAS las pestañas están fijadas borra el wrapper y devuelve `null`. `--negative-control` las fija
|
||
y exige justamente eso — sin él, una sonda que dijera «partió» pase lo que pase se vería idéntica a
|
||
una que funciona.
|
||
|
||
### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo
|
||
|
||
Escribir la receta destapó un muro que este documento no había visto: `[source]` era
|
||
**obligatoriamente** git o tarball, así que una receta cuyo contenido no viene de upstream sino de
|
||
nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se
|
||
ignora **miente** sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al
|
||
worker a tener acceso a un repo privado.
|
||
|
||
Se agregó **`source.dir`** (commit `54fa4a9`): un árbol dentro del propio repo, hasheado por
|
||
**contenido** con `ArtifactHash::of_tree` —que ya existía para verificar bit-reproducibilidad en
|
||
Stage 2— y no por ruta. Es la misma disciplina que ya tenían los `patches`, que entran al hash por
|
||
bytes. Editar un CSS del overlay mueve el `ArtifactHash`; renombrar el directorio, no.
|
||
|
||
Un `.swm` **no** puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan
|
||
diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera.
|
||
|
||
**Hecho el 2026-09-05** (commits `aeebb19` y `6f6c32b`): `recipes/atuq.toml` + `recipes/atuq/`.
|
||
La **v0.2** (`b3:7eff4ba4`) ya trae el branding: `application.ini` (Vendor/Name/RemotingName/
|
||
CodeName, con el `ID` intacto para no salirse del ecosistema de complementos), `brand.ftl` dentro de
|
||
`browser/omni.ja`, los binarios renombrados, iconos propios y `.desktop`. El re-empaque preserva
|
||
orden, `STORED` y fechas del original, y se verificó contra el artefacto: **5306 entradas, mismo
|
||
orden, y una sola con contenido distinto**. El `BuildID` se deriva del contenido del overlay —no de
|
||
la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de
|
||
chrome arranca con la interfaz vieja y *parece* que el overlay no agarró.
|
||
|
||
La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, `atuq.cfg`, `chrome/atuq.css`,
|
||
`policies.json`— y el CSS se registra como `USER_SHEET` desde autoconfig porque `userChrome.css`
|
||
vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el
|
||
primer arranque. Falta construirlo: su dep `firefox` está en vuelo.
|
||
|
||
### 2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver
|
||
|
||
El 2026-09-05, con `atuq` sellado y verificado, se hidrató y se abrió en el compositor de la sesión
|
||
(waypipe). **No arrancó**, y los tres motivos son la diferencia entre «sellado» y «usable»:
|
||
|
||
1. **EXDEV al hidratar.** `hydrate` proyecta con hardlinks y `linkat()` rechaza cruzar un punto de
|
||
montaje aunque los dos lados sean el mismo filesystem. Acá el store es `/dev/sdb` bind-monteado
|
||
y `work/` vive en `/dev/sdc`. El rootfs va bajo `/mnt/cosecha`, donde el volumen está montado
|
||
entero. Se comprueba con `findmnt -T`, **nunca** con `stat -c %d`.
|
||
2. **El lanzador no puede ser un symlink.** Ni el binario ni sus `.so` traen `RPATH`/`RUNPATH`
|
||
(`readelf -d`), así que el motor no encontraba su propio `libnspr4.so` y moría en
|
||
`XPCOMGlueLoad`. Es un script con `LD_LIBRARY_PATH` + `exec`, como Debian y Fedora. La
|
||
alternativa limpia —`RPATH=$ORIGIN` con `patchelf`, que es lo que hace Alpine— espera a que
|
||
`patchelf` exista como receta del corpus.
|
||
3. **Un bug del corpus, no de atuq: `atk` se había tragado GObject.** Declaraba la variante
|
||
ESTÁTICA de glib y produce un objeto compartido, así que `libatk-1.0.so` llevaba una copia
|
||
entera del sistema de tipos. Dos GObject en un proceso ⇒ 45 `GLib-GObject-CRITICAL` y
|
||
`Segmentation fault`. **El síntoma no nombra a atk**, y no se ve mirando el rootfs: había una
|
||
sola `libgobject`. Se cazó preguntando quién DEFINE el símbolo:
|
||
`nm -D --defined-only <cada .so> | grep " T g_type_register_static"` — deben salir uno, salieron
|
||
dos.
|
||
|
||
Radio medido antes de tocar (`yupana radio atk`): 4 transitivos, 3 sellados a deuda; GNOME fuera
|
||
porque usa gtk4. La cadena `atk → gtk3 → firefox → atuq` volvió a sellar 4/4, y **firefox tardó
|
||
~50 minutos, no las cuatro horas** que repetía este documento — el número venía del folclore de la
|
||
receta, no de una medición.
|
||
|
||
**La lección para el plan: ninguna unidad de este frente está cerrada hasta que algo se ABRE en una
|
||
pantalla.** La clausura decía 100% con un navegador que no llegaba a pintar un píxel.
|
||
|
||
### 2.quinquies La v0.3 y los tres mecanismos: dos muertos, uno vivo
|
||
|
||
La página de inicio y la pestaña nueva parecían un `pref` y resultaron ser un frente. Lo que se
|
||
midió, en orden:
|
||
|
||
| Mecanismo | Resultado |
|
||
|---|---|
|
||
| `distribution/extensions/` — el clásico de las distros | **No instala nada.** Firefox retiró el *sideloading*. El `extensions.json` del perfil ni lo mencionaba y el log no dijo una palabra: fallo perfectamente silencioso |
|
||
| `policies.json` → `ExtensionSettings` con `install_url` | **Se lee** (`browser.policies.applied=true` en el perfil) y falla nombrándose: `ERROR_SIGNEDSTATE_REQUIRED` |
|
||
| `xpinstall.signatures.required = false` | **No alcanza** — la exigencia viene COMPILADA |
|
||
|
||
**El instrumento que destrabó el diagnóstico fue un testigo.** `defaultPref` no deja rastro en
|
||
`prefs.js` —sólo se guarda lo que difiere del default— así que un autoconfig que **no se ejecuta**
|
||
es indistinguible de uno que sí y no hace nada. `atuq.cfg` escribe ahora `atuq.autoconfig.ok` como
|
||
pref de **usuario**, legible desde fuera sin abrir el navegador. Salió `true` ⇒ el `.cfg` corría, y
|
||
por lo tanto la pref de firma se estaba ignorando.
|
||
|
||
De ahí `MOZ_REQUIRE_SIGNING` vacío en `recipes/firefox.toml`. Dos detalles que costaron cada uno su
|
||
vuelta:
|
||
|
||
1. El default sale del **milestone** (`milestone.is_release_or_beta`), no del canal de actualización.
|
||
El nuestro es `default` y la exigencia estaba activa igual.
|
||
2. **Se desactiva con el valor VACÍO, no con cero.** `=0` muere con «takes 0 values»: es booleana y
|
||
el cero es un valor que no acepta. Leído del parser de mozbuild, no deducido.
|
||
|
||
**El precio, escrito:** este Firefox deja de exigir la firma de Mozilla para cualquier complemento.
|
||
La alternativa es firmar en AMO —cuenta y revisión de Mozilla por versión—, que es la dependencia
|
||
externa que esta distro existe para no tener. Y no es un desvío: **sin esto, el `sct` del §6.1
|
||
tampoco se podría shipear nunca**, así que la unidad 6 dependía de ésta sin que el plan lo dijera.
|
||
|
||
**Método que conviene repetir:** la prueba del testigo se hizo **sin reconstruir**, montando el
|
||
`.cfg` modificado con `--ro-bind` por encima del artefacto. Editar el fichero en el rootfs habría
|
||
escrito sobre un **hardlink del store** y corrompido el artefacto sellado.
|
||
|
||
### 2.sexies Lo que quedó PROBADO, y con qué
|
||
|
||
Tres afirmaciones que este documento venía haciendo sin medir, y cómo se cerraron:
|
||
|
||
| Afirmación | Prueba |
|
||
|---|---|
|
||
| El re-empaque del `omni.ja` es determinista | `why-differs` entre dos builds de `atuq`: **85 entradas idénticas, 0 divergen** |
|
||
| La capa de configuración se aplica | testigo `atuq.autoconfig.ok` escrito como pref de **usuario** en el perfil |
|
||
| El branding se ve | captura de la pantalla real con `grim` desde dentro de la jaula, y la página de inicio renderizada por el propio navegador con `--headless --screenshot` |
|
||
| La base también reproduce | `why-differs` entre dos builds de `firefox` en el worker: **56 entradas idénticas, 0 divergen** |
|
||
|
||
Y el contraste que salió de la primera: **el derivado reproducía y la base no.** El `BuildID` de
|
||
`firefox` era la hora del build, así que dos construcciones con el mismo `ArtifactHash` daban bytes
|
||
distintos — y `build-state.json` no lo podía ver, porque el hash es input-addressed y no se mueve
|
||
por esto. Verde y mintiendo. Cerrado con `MOZ_BUILD_DATE` desde `SOURCE_DATE_EPOCH`, que es el
|
||
mecanismo de hermeticidad que el sandbox ya tenía — y **comprobado como se comprueba esto**:
|
||
apartando el artefacto como `.ref`, construyendo de nuevo y pasándole `why-differs`. Reproduce.
|
||
|
||
**Dos guardianes nuevos en la fase `install` de `firefox`**, hermanos entre sí, porque los dos fallos
|
||
son de la misma familia —una bandera que el `configure` acepta y el artefacto ignora, muda y a 50
|
||
minutos de distancia—: uno comprueba `MOZ_REQUIRE_SIGNING: false` dentro del `omni.ja`, el otro que
|
||
el `BuildID` sea el que `SOURCE_DATE_EPOCH` obliga.
|
||
|
||
**La regla que sale de todo el día:** cuando la duda es «¿llegó al artefacto?», la respuesta no está
|
||
en el log del build ni en la receta — está **dentro del artefacto**, y hay que ir a buscarla ahí.
|
||
|
||
### 2.septies Los diecinueve guardianes, en una corrida — y el sello que se había quedado atrás (2026-09-15)
|
||
|
||
Cada afirmación de este documento tiene quién la mida, y son **diecinueve guardianes**. Hasta hoy
|
||
ninguno corría solo: se corrían de a uno, el día que alguien tocaba esa parte. Ya se sabe cómo
|
||
termina eso —el de descargas estuvo ROJO dos días y nadie lo corrió (`29831e62`)—, así que la corrida
|
||
entera es ahora **un comando**: `scripts/test-atuq-suite.sh`.
|
||
|
||
**Y lo primero que encontró fue un hueco que no era de ningún guardián, sino del ciclo.** Todo
|
||
`recipes/atuq/` es `source.dir`, así que el commit de la bóveda de esta mañana (unidad 12,
|
||
`6f0ba974`) movió el `ArtifactHash` — y **`atuq` quedó NO-SELLADO durante las nueve horas
|
||
siguientes**. En ese lapso ningún guardián podía medir nada: se niegan a correr contra un artefacto
|
||
que no sea el vigente, que es lo correcto. Pero un guardián que se niega **sólo grita cuando alguien
|
||
lo corre**, y nadie lo corría: el repo se veía sano, el commit estaba pusheado, y la única señal era
|
||
un `hash --check` que nadie tenía motivo para teclear. Por eso lo primero que hace el runner es mirar
|
||
el sello y NO correr nada si falta, diciendo el comando exacto para construirlo. Verificado con
|
||
control: contra un store vacío sale por ahí y devuelve 1.
|
||
|
||
El re-sello costó **3,7 s** —`atuq` es derivado y el `firefox` vigente estaba en el store— y el ciclo
|
||
que manda es el de siempre: **editar todo → resellar UNA vez → medir**.
|
||
|
||
**El cuadro, sobre `b3:e556024b` (gioser, en serie, 32,2 min):**
|
||
|
||
| | |
|
||
|---|---|
|
||
| **17 en verde** | política, rootfs, inicio, chrome, nativo, foco, instalación, ruteo, archivo, descargas, medios, torrent, `sct`, códecs, IA, archivo semántico y notificaciones |
|
||
| **2 en ROJO** | `vigia-atuq-verbos` y `test-atuq-boveda-coherente`, **los dos por la misma causa**: los tres verbos de la bóveda (§7.quinquies) |
|
||
|
||
Los caros son `sct` (242 s), el archivo semántico (238 s), la instalación (180 s, tres sesiones), la
|
||
IA (182 s, levanta `llama-server`) y las notificaciones (180 s, sway headless). Los cuatro primeros
|
||
salen en **5 s** y no tocan el navegador: para «¿rompí el cruce política↔XPI?» alcanza `--only`.
|
||
|
||
**Y al día siguiente, el mismo comando: 19 en verde y 0 en rojo** (2026-09-16, 32,1 min). Los dos
|
||
rojos se apagaron subiendo el pin de `puriy-costura` (§7.quinquies.bis) — no se tocó ningún guardián.
|
||
Que la suite existiera antes del arreglo es lo que permite decir eso: el cuadro de ayer y el de hoy
|
||
son el mismo instrumento.
|
||
|
||
⚠ **Lo que hay que cuidar ahora es el control del propio runner.** Mientras hubo un rojo conocido, el
|
||
número esperado distinguía solo «producto sano» de «runner que no corre nada». Con todo en verde ya
|
||
no, así que lo que sostiene la corrida son la **puerta del sello** (contra un store sin el artefacto
|
||
no corre nada y devuelve 1, verificado a propósito) y el **control interno de cada guardián**. El
|
||
runner no los reemplaza: los junta.
|
||
|
||
O sea: **el commit de la bóveda no rompió nada más**. Los quince guardianes que no tienen nada que
|
||
ver con ella siguen verdes sobre el artefacto que la trae adentro, y eso es lo que hacía falta saber
|
||
antes de seguir — porque la unidad 12 tocó `atuq.cfg`, la política y el empaquetado de extensiones,
|
||
que es exactamente donde un cambio se lleva puesto algo de al lado sin decirlo.
|
||
|
||
⚠ **El decimonoveno entró porque estaba nombrado como hueco, y eso envejece mal.** La primera
|
||
versión dejaba afuera al de notificaciones (`scripts/wlr/dunst-headless.sh --via-atuq`: una página
|
||
llama `new Notification(...)`, `libxul` hace `dlopen("libnotify.so.4")` —invisible para cualquier
|
||
auditor de ELF—, el bus activa `dunst` y dunst DIBUJA) con la excusa de que vive en el arnés de sway,
|
||
y lo escribía en la cabecera «para que su ausencia no se lea como cobertura». Dura tres días: al
|
||
cuarto, la lista es la cobertura y el párrafo no lo lee nadie. Se corrió aparte, salió verde en 180 s,
|
||
y entró. La lista pasó a llevar el COMANDO de cada guardián y no su nombre de fichero, que es lo que
|
||
lo tenía afuera.
|
||
|
||
⚠ **Y el control del runner se corrigió solo en la primera corrida.** La cabecera anunciaba «17 en
|
||
verde y UN rojo» —predicción escrita antes de correr— y el cuadro dijo dos: el guardián de coherencia
|
||
de la bóveda lleva el mismo chequeo del cable adentro, que es justamente lo que el §7.quinquies le
|
||
pidió. El número quedó escrito en la cabecera **como control**: un cuadro entero en verde puede ser
|
||
un producto sano o un runner que no corre nada, y se ven igual.
|
||
|
||
## 7. La costura: un host de native messaging en Rust
|
||
|
||
Todo lo del §6 que no es CSS pasa por **un solo mecanismo**: un proceso Rust que habla native
|
||
messaging con la extensión de `atuq` y, del otro lado, con la suite (`agora` para identidad Ed25519,
|
||
`khipu`, el store, `foreign-*`, `cortafuegos`).
|
||
|
||
Que sea **uno** y no siete es la decisión: cada feature del §6 pasa a ser un verbo de ese host, no un
|
||
proyecto nuevo. El binario vive en tawasuyu (es suite, no build system); `atuq` sólo lo declara como
|
||
dep y le deja el manifiesto de native messaging en su sitio. **El nombre del crate se decide en
|
||
tawasuyu y con su regla 10 puesta** — acá no se bautiza nada de allá.
|
||
|
||
### 7.bis El camino EXISTE, y tres cosas que no eran obvias (2026-09-07)
|
||
|
||
Antes de escribir un crate contra un mecanismo hay que saber si el mecanismo funciona en NUESTRO
|
||
build —propio, rebrandeado, con `MOZ_REQUIRE_SIGNING` vacío—, así que se puso una **sonda**:
|
||
extensión de diez líneas con permiso `nativeMessaging` y un host de tres que contesta `{"ok":true}`.
|
||
Vive en `scripts/test-atuq-nativo.py`, con control negativo:
|
||
|
||
positivo MENSAJE {"ok":true}
|
||
control DESCONECTADO No such native application puente_atuq
|
||
|
||
El control no sólo falla: **nombra la causa**, que es lo que confirma que la ruta del manifiesto es
|
||
la que se probó. Lo que hay que saber para escribir el host:
|
||
|
||
| Qué | Medido |
|
||
|---|---|
|
||
| Dónde va el manifiesto | **`/usr/lib/mozilla/native-messaging-hosts/`**, NO el appdir. Sale de `XRESysNativeManifests`, un `/usr/lib/mozilla` compilado; el rebranding a `atuq` no lo mueve |
|
||
| Cómo se instala la extensión | por el **escaneo de `distribution/extensions/`**. `ExtensionSettings.install_url` con `file://` no instala nada, ni con `force_installed` ni con el XPI dentro del appdir, y **sin una línea de log** |
|
||
| Forma del host | un proceso **largo**: si escribe la respuesta y sale, Gecko cierra el puerto sin entregarla — `onDisconnect` «sin error y sin mensaje», el peor informe posible |
|
||
| Con qué diagnosticar | el error del puerto de `connectNative`. `sendNativeMessage` devuelve «An unexpected error occurred» para todo y no distingue nada |
|
||
|
||
⚠ La tercera fila del cuadro corrige una lectura fácil de `policies.json`: los dos mecanismos
|
||
apuntan a los mismos ficheros y se tapan uno al otro, así que la política PARECE ser la que instala.
|
||
Fija y configura; instalar, no.
|
||
|
||
### 7.ter Y esa fila ahora tiene guardián — porque el README decía lo contrario (2026-09-09)
|
||
|
||
La fila «cómo se instala la extensión» era una nota al pie de una sonda de native messaging, y
|
||
mientras tanto **`recipes/atuq/README.md` afirmaba lo opuesto**: que el *sideloading* desde
|
||
`distribution/extensions/` «ya no funciona» y que instalaba `ExtensionSettings`. Dos documentos
|
||
nuestros, contradictorios, sobre el mecanismo del que cuelgan TODAS las extensiones de atuq —hoy
|
||
`inicio` y `proxy`, mañana la de `sct`—. Y ninguna observación del artefacto los distingue, porque
|
||
los dos apuntan a los mismos ficheros.
|
||
|
||
`scripts/test-atuq-instalacion.py` lo rompe de a uno y mira qué pasa. Medido sobre
|
||
`atuq b3:fab2fbfb` (firefox 154 con PGO v2) y REPETIDO sobre `b3:9e16ac35` —el artefacto que esta
|
||
misma corrección creó, porque el README vive dentro de `source.dir` y editarlo re-hashea atuq—,
|
||
tres escenarios:
|
||
|
||
| escenario | qué se rompe | motor (`homepageOverride`) | perfil |
|
||
|---|---|---|---|
|
||
| `as-is` | nada | `moz-extension://…/inicio.html` | `inicio` y `proxy` activas |
|
||
| `no-policy` | `policies.json` **sin** `ExtensionSettings` | **sigue siendo la nuestra** | **las dos siguen activas** |
|
||
| `policy-only` | los XPI mudados a `/usr/lib/atuq/xpi-politica/`, con `install_url` apuntando ahí | `about:home` | **ninguna de las dos existe** |
|
||
|
||
⇒ **instala el escaneo de la carpeta; `install_url` con `file://` no instala nada.** El §7.bis
|
||
queda confirmado y el README corregido.
|
||
|
||
Dos cosas que este guión trae y que valen más que el veredicto:
|
||
|
||
1. **El control es por construcción, no un modo aparte.** La sonda que le pregunta al motor vive en
|
||
`distribution/extensions/` y **ninguna política la nombra**: si la sonda habla, el escaneo está
|
||
vivo en esa corrida. Una corrida muda no se lee como «no instaló» — se declara ROTA y se guarda
|
||
el log. Sin eso, un atuq que no arranca se vería idéntico a una política que no instala.
|
||
2. **La segunda señal que probé NO discrimina, y queda dicho.** Esperaba que el `location` de
|
||
`extensions.json` separara los mecanismos (`app-system-defaults` vs. `app-profile`). No: los dos
|
||
XPI salen `app-profile` **también** cuando los instala la carpeta y no hay política que los
|
||
mencione. Se imprime igual porque dice si están, pero como discriminador es cero — y una señal
|
||
que uno cree que discrimina y no discrimina es peor que ninguna.
|
||
|
||
**Por qué esto es un guardián y no un test más.** Mozilla lleva años retirando el *sideloading*, y
|
||
el día que la carpeta deje de instalar, atuq arranca **sin ninguna de sus extensiones** y sin una
|
||
línea de error: la página de inicio vuelve a ser la de Firefox y el proxy por contenedor
|
||
simplemente no enruta. Es el modo de fallo silencioso de siempre, y el que aparece cuando SUBE la
|
||
dep, no cuando alguien toca atuq.
|
||
|
||
⚠ **Y por eso mismo hay que decir lo que todavía NO está: ninguno de los `test-atuq-*` corre en el
|
||
latido.** `cosecha-cron.sh` sólo ejecuta `vigia-sonames.py`; los de atuq se corren a mano. Meterlos
|
||
cuesta ~3 min por ciclo (tres arranques de navegador headless por escenario) y exige que el hub tenga
|
||
hidratado el rootfs de `scripts/atuq-nested.sh`, así que **es una decisión con precio y no un
|
||
`echo` más en el cron** — queda escrita acá y sin dar por hecho lo contrario. El día que se meta, el
|
||
sitio es el bloque de guardianes de `cosecha-cron.sh` (L341-352).
|
||
|
||
### 7.quater El host, escrito — y por qué la extensión NO puede hablarle al testigo (2026-09-10)
|
||
|
||
El §6.1 decía que `sct` v1 es «extensión con `webRequest` bloqueante que hashea cada respuesta de
|
||
script y **consulta al testigo** antes de dejarla pasar». La primera mitad es correcta; la segunda es
|
||
imposible tal como está escrita, y conviene decirlo porque parecía un atajo para saltarse esta
|
||
unidad: **el cable de `puriy-sct-testigo` es un POST con `postcard` en el body**, no JSON. Un JS no
|
||
puede ser su cliente. Y reimplementar el registro TOFU en la extensión sería fabricar un sustituto
|
||
paralelo del original —lo que la regla 10 de tawasuyu prohíbe— además de duplicar la única pieza que
|
||
ya estaba certificada. Entonces v1 pasa por el host, y el host es esta unidad.
|
||
|
||
Se decidió con el operador, con el `grep` de la regla 10 hecho primero: **ningún término del dominio
|
||
significaba «host de native messaging»**, y los que suenan a mensajero están todos ocupados por
|
||
dominios ajenos y grandes (`chasqui` es un type broker, `chaka` el puente a COBOL, `paloma` el cliente
|
||
de correo, `tampu` el común de objetos). El nombre salió del título de este propio §7: **la costura**.
|
||
|
||
| Pieza | Dónde | Por qué ahí |
|
||
|---|---|---|
|
||
| `puriy-costura` | `00_unanchay/puriy/` | regla 1: subcrate del dominio, no proliferación lateral. Precedentes: `puriy-sct-testigo`, `rimay/verbo-daemon`, `watuy-daemon` |
|
||
| `foreign-webext` | `shared/` | regla 4: el protocolo es AJENO. Lib pura sin binario, como los otros 37 puentes (medido: ninguno lleva `[[bin]]`) |
|
||
| `recipes/puriy-costura.toml` | takana | el artefacto: estático musl, pineado por commit, `--locked` |
|
||
|
||
Verbos v1: `ping`, `sct.observe`, `sct.state`. Claves del cable en inglés (regla 7 de allá y regla 4
|
||
de acá: se tipean). `phase` es `learning`/`stable`/`not-evaluable`, y un `events[]` vacío es lo normal
|
||
— sólo hay evento cuando un origen **ya estable** ejecuta un hash que nadie vio nunca.
|
||
|
||
**Los tres tests que hacen que el veredicto signifique algo** (de catorce): el mismo cambio de script
|
||
mientras el origen todavía aprende **no** alerta; rotar `?v=` con el mismo código **tampoco** (la
|
||
identidad del recurso es su contenido, y sin eso cada despliegue con cache-buster sería un aviso de
|
||
supply-chain: un detector que grita todos los días no se mira más); y el aprendizaje **sobrevive al
|
||
cierre del navegador**, sin lo cual cada arranque volvería a «aprendiendo» y ningún cambio sería nunca
|
||
un evento.
|
||
|
||
**Los dos límites, escritos en el código y en los dos LEEME, no en una nota de sesión:**
|
||
|
||
1. **Qué bytes se hashean.** La extensión manda el TEXTO ya decodificado; se hashea su UTF-8. La
|
||
detección vale —el TOFU compara lo mismo contra lo mismo—, pero nuestro hash **no es comparable**
|
||
con el de los bytes servidos que publique un tercero, ni con el de la v2 (el gancho en el script
|
||
loader). Un hash que uno cree comparable y no lo es sería peor que no tenerlo.
|
||
2. **La semilla del log es provisional**: se deriva del directorio de estado, así que es
|
||
determinística (el log se reanuda) pero **no es un secreto** ⇒ la bitácora es a prueba de
|
||
REESCRITURA, no de SUPLANTACIÓN. La de verdad sale del perfil cuando el almacén del perfil exista.
|
||
|
||
**Y lo que queda medido para la unidad 6, preguntándole al artefacto y no a la documentación de
|
||
Mozilla** (§2.sexies): `filterResponseData` y el permiso `webRequestFilterResponse` **están** en
|
||
nuestro build (`omni.ja`, `chrome/toolkit/content/extensions/schemas/web_request.json`), así que la
|
||
extensión va a poder leer el cuerpo de un script. Lo que NO va a poder es ver los scripts **inline**
|
||
por esa vía —`filterResponseData` entrega el documento, no cada `<script>`—, y eso está bien para v1:
|
||
el ataque que `sct` nombra es la sustitución en el CDN, que es exactamente el caso `<script src>`.
|
||
|
||
### 7.quinquies El cable tiene DOS puntas en DOS repos — y hoy no coinciden (2026-09-15)
|
||
|
||
La bóveda se commiteó esta mañana (unidad 12, `6f0ba974`): la extensión, la política, el permiso del
|
||
host, las preferencias, y un guardián que mira los cuatro. Los cuatro estaban bien. **La bóveda no
|
||
funciona igual**, y el motivo no está en ninguno de esos cuatro sitios.
|
||
|
||
Cada función de `atuq` que no es CSS es un **verbo** del host nativo, y el host vive en OTRO REPO:
|
||
`recipes/puriy-costura.toml` lo pinea por commit de tawasuyu. O sea que el cable tiene dos puntas en
|
||
dos repos, y que `vault.match` sea un verbo o una cadena que nadie atiende lo decide un `commit =`
|
||
de un TOML que la extensión nunca menciona. Escribir la extensión y subir el pin son **dos unidades
|
||
de trabajo distintas**, y la primera se commitea sin la segunda sin que nada proteste.
|
||
|
||
**Cómo se ve el fallo, que es por qué merece vigía.** No falla nada. El manifiesto está, la política
|
||
instala, `connectNative` **conecta** —el host existe y contesta—, la extensión manda `vault.match` y
|
||
recibe:
|
||
|
||
{"ok": false, "error": "verbo desconocido: vault.match"}
|
||
|
||
`fondo.js` lee `r.ok !== true`, borra la insignia y se calla. El usuario ve un navegador sin bóveda;
|
||
un guardián de ficheros ve todo en orden. Es la misma familia que el §6.10 del subcomando sin driver:
|
||
sellado, con contenido, reproducible… y la función no está.
|
||
|
||
**La medición, con su control al lado.** El artefacto vigente (`hash --check` sobre la receta dice
|
||
`b3:1eb2b692 SELLADO`) contra su propio binario:
|
||
|
||
strings del binario sellado → «vault» 0 veces
|
||
→ «cas» 5 veces ⇐ EL CONTROL
|
||
|
||
El cero solo no probaba nada: un `grep` que devuelve cero puede ser una ausencia o un sitio
|
||
equivocado, y las dos se ven igual. Lo que lo convierte en evidencia es el hermano que TENÍA que dar
|
||
positivo en el mismo binario. Y desde la fuente sale lo mismo por otro camino: la receta pinea
|
||
`e19bb0e5`, que `git merge-base --is-ancestor` confirma **ANCESTRO** de `bc6903f9e`, el commit que
|
||
agregó los verbos. El host sellado es anterior a la bóveda por construcción.
|
||
|
||
**Y no se podía deducir mirando la receta.** Las otras nueve extensiones conviven bien con ese mismo
|
||
pin viejo: 13 verbos sobre 8 extensiones, y los únicos tres sin dueño son los de la bóveda. «Cuándo
|
||
se tocó la receta por última vez» no contesta esta pregunta.
|
||
|
||
| extensión | verbos | ¿el pin los atiende? |
|
||
|---|---|---|
|
||
| `archivo` | `archive.add`, `archive.ask`, `archive.search` | ✓ |
|
||
| **`boveda`** | **`vault.match`, `vault.fill`, `vault.save`** | **✗ los tres** |
|
||
| `descargas` | `cas.ingest` | ✓ |
|
||
| `foco` | `focus.state` | ✓ |
|
||
| `ia` | `ai.ask`, `archive.ask` | ✓ |
|
||
| `medios` | `media.open` | ✓ |
|
||
| `sct` | `sct.observe` | ✓ |
|
||
| `torrent` | `torrent.add` | ✓ |
|
||
| `inicio`, `proxy` | — | no le hablan al host, a propósito |
|
||
|
||
Queda vigilado por `scripts/vigia-atuq-verbos.py`, que corre en un segundo sin construir nada y
|
||
pregunta `git grep '"<verbo>" =>' <commit> -- '*.rs'` **leyendo del objeto** —sin checkout: el árbol
|
||
de tawasuyu es compartido y siempre tiene ficheros en vuelo—. El mismo chequeo quedó además dentro
|
||
de `test-atuq-boveda-coherente.py`, que pasa a decir **cinco** lugares y no cuatro.
|
||
|
||
⚠ **Y lo que costó más que el vigía: el extractor de verbos no veía la mitad.** Buscaba `verb: "…"`,
|
||
que es como lo escribe la bóveda; pero la extensión de IA arma el mensaje con `postMessage({ id,
|
||
verb: verbo, … })` —el verbo llega por VARIABLE— así que ese patrón encontraba **cero verbos en
|
||
`ia`** y la declaraba sana sin haber mirado nada. Cero verbos encontrados y cero verbos faltando son
|
||
el mismo cero con dos causas. De ahí salió un tercer control que no es ni positivo ni negativo sino
|
||
de **cobertura**: una extensión de la que no se extrae ningún verbo se REPORTA, y las dos que de
|
||
verdad no le hablan al host están nombradas en el código para que no se confundan con el extractor
|
||
mudo.
|
||
|
||
#### Por qué el pin NO se subió en el mismo turno, y qué hace falta
|
||
|
||
El candidato es `be8710cff` —el más nuevo que toca `puriy-costura`, y trae además «la aplicación de
|
||
la bóveda y el navegador ya pueden convivir», que pinear `bc6903f9e` se saltearía—. **No se puede
|
||
todavía, y el muro está medido**, no supuesto: el `Cargo.lock` de tawasuyu no cierra para
|
||
`puriy-costura`. Su entrada en el lock de `main` todavía lista las **once** deps viejas —sin
|
||
`pacha-boveda`, sin `pacha-cifrador`— y `pacha-boveda-daemon` no está en el lock en absoluto.
|
||
|
||
Reproducido en un árbol LIMPIO del commit (`git archive <sha> | tar -x`, que es el procedimiento que
|
||
la propia receta dejó escrito y que evita que el lock describa «el escritorio de todos»):
|
||
|
||
cargo metadata --locked --offline
|
||
error: cannot update the lock file … because --locked was passed to prevent this
|
||
|
||
—y, como la receta avisaba, **no nombra al crate culpable**. La reparación es barata y quedó
|
||
comprobada en ese mismo árbol limpio: `cargo metadata --offline` cierra el lock con un diff de **37
|
||
líneas**, todas de contabilidad de deps por ruta, **sin mover una sola versión de registry**.
|
||
|
||
**Pero ese fichero no se commiteó, y la razón es la regla 2 de `CLAUDE.md` vista desde el otro lado.**
|
||
En el clon compartido, `git status` dice `MM Cargo.lock`: otra sesión lo tiene **stageado Y
|
||
modificado** ahora mismo. Las tres versiones, medidas:
|
||
|
||
| dónde | tamaño | deps de `puriy-costura` |
|
||
|---|---|---|
|
||
| `main` (`2c40330e4`) | 672 409 B | 11 — no cierra |
|
||
| el **índice** (stageado por otro) | 671 160 B | 11 — no cierra, y **no es el de `main`** |
|
||
| el **árbol** de esa sesión | 673 397 B | **18 — cierra** |
|
||
|
||
O sea que la reparación ya existe, sin commitear, en el árbol de otro agente. Tocarla es pisarle el
|
||
trabajo, y el `--` no protege de eso. **Comprobado en un repo de juguete, porque la intuición es la
|
||
contraria:**
|
||
|
||
```sh
|
||
# f.txt en estado MM: v2 en el índice (del otro), v3 en el árbol (mío)
|
||
git commit -m … -- f.txt
|
||
# ⇒ commitea v3 (EL ÁRBOL, no lo stageado)
|
||
# ⇒ y el índice queda en v3: lo que el otro tenía stageado ahí DESAPARECE
|
||
```
|
||
|
||
El `--` acota **qué rutas** entran al commit y con eso aísla de lo que el otro dejó en OTRAS rutas
|
||
—que es lo que la regla 2 dice y sigue siendo cierto—; para la ruta que uno nombra, se lleva el
|
||
árbol y **borra el índice ajeno**. En un repo compartido, un fichero en `MM` que uno no tocó no se
|
||
commitea ni con pathspec: se avisa.
|
||
|
||
⇒ **Pendiente, y no es de takana:** que tawasuyu publique el lock cerrado. Con eso, subir el pin a
|
||
`be8710cff`, reconstruir (2,4 G de vendoreo del workspace entero, ver la receta) y recién ahí escribir
|
||
el guardián de METAL de la bóveda —servidor, navegador real, login real, el diálogo de consentimiento
|
||
a la vista—, que es lo que el commit de la unidad 12 dejó anunciado.
|
||
|
||
#### 7.quinquies.bis DESTRABADO (2026-09-16): el lock, el pin, y el mismo `strings` leyendo al revés
|
||
|
||
Las tres cosas que el párrafo de arriba dejaba pendientes están hechas, y en ese orden porque cada
|
||
una destrababa a la siguiente.
|
||
|
||
**1. El lock de tawasuyu, publicado** (`23a292863`). Regenerado en un árbol LIMPIO del `HEAD` de allá
|
||
—`git archive | tar -x`, que no toca el `.git` compartido— y verificado en los dos sentidos, que es
|
||
lo que lo convierte en evidencia: `cargo metadata --locked --offline` **moría antes** de regenerarlo
|
||
y **pasa después**. El diff son 215 líneas y **todas de contabilidad de deps por ruta: cero
|
||
`checksum` movidos, cero `source` movidos**, ninguna versión de registry tocada. Lo que aparece es
|
||
`llimphi-registro`, los cuatro crates de la bóveda y `umbral` dentro de `pacha-boveda`.
|
||
|
||
⚠ **Commiteado con un índice TEMPORAL, no con `git commit`.** El `Cargo.lock` seguía en vuelo en el
|
||
clon compartido, y la regla 2 ya está medida: `git commit -- Cargo.lock` se lleva **el árbol** y deja
|
||
el índice ahí, o sea que borra lo que el otro agente tuviera stageado en ESA ruta. El camino que no
|
||
toca nada es plumbing:
|
||
|
||
```sh
|
||
export GIT_INDEX_FILE=/tmp/…/tw.index # un índice PROPIO: el compartido no se abre
|
||
git read-tree HEAD
|
||
blob=$(git hash-object -w <el lock nuevo>)
|
||
git update-index --cacheinfo 100644,$blob,Cargo.lock
|
||
git commit-tree $(git write-tree) -p HEAD -F msg # ⇒ commit sin tocar índice ni árbol
|
||
git push origin <commit>:main
|
||
```
|
||
|
||
Con su control puesto: `git diff-tree -r --name-only HEAD <tree>` tiene que decir **un solo fichero**.
|
||
|
||
⚠ **Y la sorpresa que ordena el resto: el lock publicado resultó ser BYTE A BYTE el que la otra
|
||
sesión ya tenía en su árbol** —mismo blob `b2f0b9eb`—. No era una versión rival de la reparación: era
|
||
la suya, que llevaba horas sin commitear. O sea que lo que bloqueó a `atuq` tres semanas no fue un
|
||
problema técnico sino **un fichero correcto que nadie había publicado**, y del que ningún guardián de
|
||
ninguno de los dos repos podía hablar.
|
||
|
||
**2. El pin, subido** a ese commit ⇒ `b3:7d63655a`. El artefacto se construyó **en el worker**: el
|
||
vendoreo son 2,4 G y el disco del hub estaba al 99% (lo llena `tawasuyu/target`, 148 G), así que
|
||
construir acá habría sido el cuadro de «disco lleno = ✗ falso». Bajar el artefacto son 2,3 M.
|
||
|
||
**3. La misma medición que diagnosticó el problema, leída al revés.** El §7.quinquies lo cerró con
|
||
`strings` sobre el binario sellado: `vault` 0 veces, `cas` 5 —el control que tenía que dar positivo—.
|
||
Sobre el binario nuevo, con el mismo comando:
|
||
|
||
| | antes (`e19bb0e5`) | ahora (`23a29286`) |
|
||
|---|---|---|
|
||
| `vault.` | **0** | **10** |
|
||
| `cas.` / `sct.` (control) | presentes | presentes |
|
||
|
||
Y del lado del navegador, `scripts/vigia-atuq-verbos.py` pasa de **3 verbos sin dueño** a **cero**, con
|
||
su control positivo intacto (`sct.observe` sigue apareciendo ⇒ el grep mide).
|
||
|
||
**Lo que queda de la unidad 12** es el guardián de METAL de la bóveda —servidor, navegador real,
|
||
login real, el diálogo de consentimiento a la vista—, que ahora sí se puede escribir porque hay con
|
||
qué correrlo. ⚠ **Esto último resultó FALSO y está corregido en el §7.sexies: faltaban el dueño de la
|
||
bóveda y el diálogo de consentimiento, y sin ellos el host contesta «cerrada».**
|
||
|
||
### 7.sexies «Hay con qué correrlo» era falso: la bóveda del navegador contesta CERRADA (2026-09-18)
|
||
|
||
El párrafo de arriba cerró el §7.quinquies.bis diciendo que el guardián de metal «ahora sí se puede
|
||
escribir porque hay con qué correrlo». **No se podía, y la primera pregunta del guardián lo dice en
|
||
veinte segundos.** Antes de escribirlo se le preguntó al artefacto sellado —el mismo binario que va
|
||
en las cuatro imágenes de escritorio— por marcos de 4 bytes, igual que le habla la extensión:
|
||
|
||
```
|
||
← {"id":1,"ok":true,"verb":"ping","version":"0.1.0"}
|
||
← {"id":2,"ok":true,"verb":"vault.status","locked":true,"count":0}
|
||
← {"id":3,"ok":true,"verb":"vault.match","locked":true,"items":[]}
|
||
stderr: puriy-costura: sin bóveda (No such file or directory (os error 2)):
|
||
los verbos vault.* dirán «cerrada»
|
||
```
|
||
|
||
`ping` es el control: el host **está**, atiende y contesta. Lo que no hay es bóveda. Y el `ok:true`
|
||
de las otras dos es la parte que engaña: **«cerrada» es una respuesta exitosa**, no un error, así que
|
||
ningún reintento, ningún log y ninguna insignia distinguen esto de un usuario que todavía no
|
||
desbloqueó la suya.
|
||
|
||
**Por qué faltaba, y por qué el §7.quinquies no podía verlo.** Ese capítulo midió lo que se podía
|
||
medir desde acá: que el commit pineado del host CONOCE los verbos (`strings` → `vault` 10 veces,
|
||
`vigia-atuq-verbos` → cero verbos sin dueño). Las dos mediciones eran correctas y siguen siéndolo.
|
||
Lo que ninguna preguntaba es **quién contesta del otro lado del socket**: el host no abre la bóveda
|
||
nunca, a propósito —`sled` toma un lock exclusivo y el proceso que lanza el navegador muere y revive
|
||
con cada pestaña—, así que le habla a un DUEÑO, y el dueño no estaba en el corpus. Es la misma forma
|
||
de fallo del §7.quinquies una capa más abajo: **saber el verbo no es poder contestarlo**.
|
||
|
||
Las dos piezas ausentes, las dos con el mismo efecto de «se ve apagado y nada falla»:
|
||
|
||
| pieza | qué es | qué pasa sin ella |
|
||
|---|---|---|
|
||
| `boveda` (`pacha-boveda-llimphi`) | la app de la bóveda, y el **dueño único** de la base: la abre para su ventana y levanta el socket del navegador en un hilo | `vault.*` contesta `locked:true`: insignia vacía, nada que ofrecer |
|
||
| `shuma-pregunta` | el diálogo de consentimiento que `PorDialogo` lanza como proceso | todo `vault.fill` se **deniega** —`Command::new` falla ⇒ «no»—, indistinguible de que el usuario haya dicho que no |
|
||
|
||
⇒ Hoy, en las cuatro imágenes de escritorio, `atuq` instala la décima extensión y la función está
|
||
apagada de fábrica. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`, las dos pineadas al
|
||
**mismo** `23a292863` que ya comparten `puriy-costura`, `shuma-*` y `pacha-*` —un pin distinto es
|
||
otro vendoreo de 2,4 G del workspace entero— y las dos con la forma de `mirada-greeter`, que es el
|
||
patrón de una llimphi GUI: `link = "dynamic"` porque winit/wgpu cargan EGL/Vulkan/wayland por
|
||
`dlopen`, y la inyección de `LOCKSTEP_XML_PATH` que evita el panic de `zbus-lockstep-macros` en el
|
||
subdir `xml/schemas/` de atspi (mismo `accesskit_winit` 0.33, mismo `atspi-common` 0.13 en el lock).
|
||
|
||
⚠ **Y quedan DOS decisiones que no se toman de paso**, las dos de producto:
|
||
|
||
1. **en qué imágenes se declaran.** La función del navegador no existe sin las dos, así que o entran
|
||
a las cuatro de escritorio o la bóveda de `atuq` se queda como una extensión que no ofrece nada.
|
||
Es la misma pregunta abierta que el §6.7 dejó con `llama-cpp`, y se contesta con el tamaño medido
|
||
al lado, no antes: **`boveda` son 22 M y `shuma-pregunta` 21 M**, o sea ~43 M por perfil, contra
|
||
los ~1,25 GiB que pide el §6.7. ⚠ Pero hoy la respuesta es **NO, y no por el tamaño**: ninguna de
|
||
las dos abre ventana en ninguna imagen (§7.septies), así que declararlas ahora sería agregar 43 M
|
||
de binarios que no pueden pintar — y peor, dejar la bóveda «instalada» negando todo en silencio;
|
||
2. **de dónde sale la raíz de las claves.** La app la saca de la seed de identidad del llavero del
|
||
kernel, que en una máquina de desarrollo no está desbloqueada — y por eso existe `BOVEDA_TEST_ROOT`,
|
||
que deriva la raíz de una cadena y abre **otra** bóveda, vacía, diciéndolo en pantalla. Es la
|
||
escotilla con la que el guardián de metal va a poder sembrar una credencial sin tocar la de nadie.
|
||
|
||
**Lo que queda de la unidad 12**, entonces, no es «escribir el guardián de metal»: es sellar estas
|
||
dos y recién ahí escribirlo. Lo que sí queda escrito desde hoy es la pregunta con la que ese guardián
|
||
tiene que empezar —`vault.status` contra el host— porque es la que separa «la bóveda dijo que no» de
|
||
«no hay bóveda», que se ven idénticas desde la extensión.
|
||
|
||
#### 7.septies El diálogo NO ABRE, y el muro no es de la bóveda: **ninguna app llimphi de escritorio puede pintar en esta distro** (2026-09-18)
|
||
|
||
Con `boveda` y `shuma-pregunta` sellados, la etapa B del guardián de metal falla, y falla en un sitio
|
||
que no tiene nada que ver con las contraseñas:
|
||
|
||
```
|
||
thread 'main' panicked at 02_ruway/llimphi/llimphi-hal/src/lib.rs:1032:83:
|
||
index out of bounds: the len is 0 but the index is 0
|
||
```
|
||
|
||
Esa línea es `caps.formats[0]` sobre las capacidades de la surface. O sea: **la surface no tiene NI UN
|
||
formato**. La cadena entera, medida eslabón por eslabón con `RUST_LOG=wgpu_hal=debug`:
|
||
|
||
```
|
||
wgpu_hal::gles::egl: No (or unknown) windowing system ((None, Some(...))) present.
|
||
Using surfaceless platform
|
||
wgpu_hal::gles::egl: Trying native-render → No config found!
|
||
wgpu_hal::gles::egl: Trying presentation → No config found!
|
||
wgpu_hal::gles::egl: Trying off-screen → (ésta sí)
|
||
```
|
||
|
||
El `None` de ese par es `desc.raw_display_handle`, y **no es un accidente del arnés: es lo que el
|
||
propio llimphi hace a propósito**, con su comentario al lado (`llimphi-hal/src/lib.rs`, camino de
|
||
escritorio `new_inner`):
|
||
|
||
> «Sin display: este camino no tiene ventana todavía (la surface se crea después, contra esta misma
|
||
> instancia). Los caminos que SÍ la tienen —`new_for_raw_surface`, `recreate_for_window`— la pasan.»
|
||
|
||
Sin display handle, wgpu abre el display EGL por la plataforma **surfaceless**, que por definición no
|
||
publica configs con `WINDOW_BIT`; sin config de ventana, `presentable` es falso; sin `presentable`,
|
||
`surface_capabilities` devuelve `None` y la lista de formatos sale vacía. Cada paso está en el log.
|
||
|
||
**Por qué esto no se nota en la máquina de quien escribió llimphi, y sí acá.** Ese camino elige
|
||
`Backends::PRIMARY` —Vulkan/Metal/DX12— y **a Vulkan el display handle no le hace falta**. Sólo cae al
|
||
backend GL cuando no hay ninguno de los tres… que es exactamente el caso de esta distro:
|
||
|
||
| | Vulkan | efecto |
|
||
|---|---|---|
|
||
| `recipes/mesa.toml` (iris, la de las imágenes) | `-Dvulkan-drivers=` **vacío** | no hay ICD |
|
||
| `recipes/mesa-swrast.toml` · `recipes/mesa-llvmpipe.toml` | `-Dvulkan-drivers=` **vacío** | no hay ICD |
|
||
| `vulkan-loader` | sólo en `incoming-kde`, y **ningún perfil lo declara** | y el loader sin driver no es un driver |
|
||
|
||
⇒ **En ninguna imagen de takana hay un driver Vulkan.** Con lo cual toda app llimphi de escritorio
|
||
—no sólo el diálogo de la bóveda— toma el camino GL, y el camino GL sin display handle no puede abrir
|
||
ventana. Comprobado en TRES binarios distintos y con dos versiones de wgpu: `shuma-pregunta` (wgpu 29),
|
||
`boveda` (wgpu 29) y `llimphi-counter` (wgpu 27, pineado a otro repo).
|
||
|
||
**Los controles, porque la conclusión es fuerte:**
|
||
|
||
· pedir Vulkan explícito (`LLIMPHI_WGPU_BACKEND=vulkan`) ⇒ **`NoAdapter`**, y `ls /usr/share/vulkan/icd.d`
|
||
y `ls /usr/lib/libvulkan*` no existen en ninguna capa. La premisa «no hay Vulkan» está medida, no
|
||
supuesta;
|
||
· con **softpipe** el fallo es OTRO y anterior —`RequestDevice("Parent device is lost")`, con
|
||
`indirect-validation error: ComputePipeline(… COMPUTE_SHADER …)` una línea antes, porque softpipe se
|
||
queda en GL 3.3 y wgpu pide compute—. O sea que llegar hasta el panic de los formatos ya exige
|
||
`llvmpipe` (GL 4.5), que es lo que el arnés monta: `mesa-llvmpipe` + `llvm18`;
|
||
· y el mismo arnés levanta `sway`, el socket wayland aparece y `WAYLAND_DISPLAY` está puesto — el
|
||
compositor no es el que falta.
|
||
|
||
⚠ **Lo que esto dice del producto, y es lo que importa:** la bóveda de `atuq` no está «casi lista». Su
|
||
consentimiento —`PorDialogo`, que lanza `shuma-pregunta`— **no puede pedir permiso en ninguna imagen
|
||
de hoy**, y como el fallo del lanzamiento se traduce a «no» (`Command::new` falla ⇒ `return false`),
|
||
lo que un usuario vería es una bóveda que niega todo, sin un error. Y no es sólo la bóveda: es
|
||
cualquier ventana llimphi que la distro quiera abrir.
|
||
|
||
**Dónde se arregla, y dónde NO.** No se arregla en takana: ni en la receta, ni en el arnés, ni
|
||
poniendo otro mesa. Las dos salidas son de otro repo o de otra receta, y conviene decir cuál es cuál:
|
||
|
||
1. **llimphi** (tawasuyu): que el camino de escritorio pase el display handle cuando lo tiene, o que
|
||
al caer al backend GL reconstruya la instancia con él. La función que hace falta **ya existe ahí
|
||
al lado** (`instancia_con`, que usan `new_for_raw_surface` y `recreate_for_window`);
|
||
2. **un driver Vulkan por software** en el corpus (lavapipe: `-Dvulkan-drivers=swrast`), que además
|
||
destrabaría el muro de ScreenCast que el runbook de COSMIC documenta por otro camino. Es una
|
||
receta nueva y una decisión de tamaño, no un arreglo de paso.
|
||
|
||
La 1 es la correcta: un navegador que pide permiso no debería depender de que la máquina tenga
|
||
Vulkan. La 2 es la que, además, le sirve a otros frentes.
|
||
|
||
##### El arreglo, escrito y empujado en llimphi (`eed3120b6`) — falta medirlo
|
||
|
||
Se hizo la 1, que son dos ficheros y ninguna pieza nueva: **la función que faltaba ya existía al
|
||
lado**. `Hal::new_con_display` usa el mismo `instancia_con` que el camino layer-shell, y el llamador
|
||
de escritorio le pasa la `window` — que **está ahí, creada una línea antes** para hacerle la surface.
|
||
`new_inner` y el nuevo comparten cuerpo (`new_generico`) y sólo se distinguen en cómo arman la
|
||
instancia: duplicar la elección de backend y el fallback es cómo se termina con dos caminos que
|
||
divergen sin que nadie se entere.
|
||
|
||
Controles antes de empujar, porque el arreglo no se puede probar hasta reconstruir: `cargo check -p
|
||
shuma-pregunta` pasa con el parche **y falla con una rotura a propósito en la línea tocada** (si no,
|
||
un check que no compila el fichero se ve igual que uno que sí).
|
||
|
||
⚠ **Commiteado allá con índice temporal** (`GIT_INDEX_FILE` + `commit-tree` + `push <sha>:main`), que
|
||
es la única forma de publicar dos rutas en un clon compartido sin llevarse por delante los 181
|
||
ficheros que otra sesión tiene en vuelo. Control: `git diff-tree -r --name-only` nombra **dos**. Y el
|
||
push fue rechazado la primera vez porque el remoto se había movido: se rehízo el `commit-tree` sobre
|
||
el `FETCH_HEAD` nuevo, comprobando antes que los dos ficheros no habían cambiado allá.
|
||
|
||
##### ⚠⚠ «SELLADA» en cero segundos: el latido REVIERTE la receta del worker (2026-09-18)
|
||
|
||
Al reconstruir con el pin nuevo, el log del worker dijo `### boveda SELLADA` casi al instante. No
|
||
era. El worker había vuelto a la receta VIEJA y lo que «selló» fue un **acierto de caché sobre el
|
||
artefacto anterior**:
|
||
|
||
```
|
||
hub: recipes/boveda.toml → b3:8d1d7536 (pin eed3120b6)
|
||
worker: recipes/boveda.toml → b3:59ffd74b (pin 23a292863, el de antes)
|
||
```
|
||
|
||
**Quién la revierte:** el latido (`cosecha-cron.sh`) siembra `rsync -az --delete` hub→worker **al
|
||
principio** de cada ciclo, y hace su `git pull --ff-only` **al final**. O sea que cada media hora el
|
||
worker vuelve al árbol de ESE hub, que puede estar hasta un ciclo atrasado — y un `rsync` manual de
|
||
una receta dura lo que tarde el siguiente latido. El cron, además, no corre en este hub: corre en el
|
||
otro, con su propio checkout.
|
||
|
||
**Por qué es peligroso y no sólo molesto:** el modo de fallo no es un error, es un **éxito falso**.
|
||
Si el artefacto de la receta vieja ya está sellado, el build imprime `SELLADA` en cero segundos y el
|
||
operador lee exactamente lo que esperaba leer. Es la forma «un ausente falla ruidosamente; un vacío
|
||
llega hasta el final diciendo que todo fue bien» de la regla 3, un piso más abajo.
|
||
|
||
**La mitigación, que es una línea y va DENTRO del mismo comando que toma el lock:** preguntarle al
|
||
worker el hash y compararlo con el del hub antes de construir.
|
||
|
||
```sh
|
||
h=$(./target/release/takana --store ./store hash recipes/boveda.toml | sed s/^b3://)
|
||
[ "$h" = "$ESPERADO" ] || { echo "### RECETA REVERTIDA: el worker hashea ${h:0:12}"; exit 3; }
|
||
flock -o work/.farm-build.lock ./target/release/takana --store ./store build recipes/boveda.toml
|
||
```
|
||
|
||
Con eso el segundo intento dijo `### receta verificada 8d1d75369548` antes de compilar nada.
|
||
|
||
⚠ **El pin de `boveda` y `shuma-pregunta` ahora DIVERGE del de sus hermanas** (`eed3120b6` contra
|
||
`23a292863`), y eso cuesta **un árbol de fuentes propio: otro vendoreo de 2,4 G**, porque el árbol se
|
||
comparte por `<repo>-<sha>`. Es el precio de no mover las otras once recetas del monorepo en el mismo
|
||
turno; se paga hasta que suban.
|
||
|
||
**Lo que queda escrito y corriendo mientras tanto** es `scripts/test-atuq-boveda-metal.py`, con sus
|
||
seis etapas declaradas y la A en verde: sin la app dueña, el host contesta `locked:true`. Es poco, y
|
||
es exactamente lo que se puede afirmar hoy — que es mejor que un guardián que no existe y que uno que
|
||
diera verde midiendo nada.
|
||
|
||
#### 7.octies Con el navegador abierto la bóveda queda MUDA — el dueño atendía de a un cliente (2026-09-18)
|
||
|
||
Con llimphi arreglado, el guardián de metal llegó hasta la etapa D —guardar una contraseña con
|
||
consentimiento real y encontrarla de nuevo— y **se plantó en la E**, que es la que usa el navegador.
|
||
El cuadro era éste, y ninguna de sus líneas es un error:
|
||
|
||
```
|
||
BOVEDA CONECTADO puriy_costura ← la extensión conecta con su host
|
||
BOVEDA-METAL EXTENSION cargada
|
||
BOVEDA-METAL MOSTRADA true ← el botón de la extensión SÍ está para esa pestaña
|
||
BOVEDA-METAL INSIGNIA "" ← …y la insignia está vacía
|
||
BOVEDA-METAL DISPARO api ← se aprieta el botón
|
||
(nada más. Ni diálogo, ni relleno, ni una línea de `fondo.js`)
|
||
```
|
||
|
||
La extensión no dice nada porque **no tiene nada que decir**: `vault.match` se manda y **no vuelve
|
||
nunca**. Sin respuesta no hay insignia, no hay log y no hay error — que desde el navegador es
|
||
indistinguible de «este sitio no tiene contraseñas guardadas».
|
||
|
||
**La causa, medida y no deducida.** `pacha_boveda_daemon::Dueno::servir` llamaba a `atender_cliente`
|
||
en el hilo del `accept`, y `atender_cliente` no vuelve hasta que el cliente se va ⇒ **la primera
|
||
conexión se queda con el dueño mientras viva** y el resto espera en la cola del socket para siempre.
|
||
Y el caso normal es justamente ése: **Gecko lanza un `puriy-costura` por PUERTO** —uno por extensión,
|
||
ocho en `atuq`— y cada uno abre su conexión a la bóveda al arrancar (§7.quater ya había medido lo de
|
||
«un host por puerto»; lo que faltaba era ver qué le hace eso al dueño).
|
||
|
||
El control, en los dos sentidos y sin navegador, dentro de la misma jaula:
|
||
|
||
```
|
||
== un solo cliente → {"verb":"vault.status","locked":false} en milisegundos
|
||
== con otro host conectado → colgado; lo mata el `timeout` a los 30 s
|
||
```
|
||
|
||
**Arreglado allá** (`cf3540460`): un hilo por conexión. El argumento por el que se serializaba —dos
|
||
diálogos de consentimiento a la vez es cómo alguien autoriza el que no era— sigue en pie, y ahora lo
|
||
sostiene **el `Mutex` de la bóveda**, que quien atiende toma para responder y sostiene mientras
|
||
pregunta. Lo que deja de serializarse es lo que nunca debió: estar conectado.
|
||
|
||
⚠ **Y el arreglo obvio estaba mal por una razón que no se ve leyendo:** clonar el `Dueno` para cada
|
||
hilo hace que el primero que termina **borre el socket** (lo hace en su `Drop`, y está bien que lo
|
||
haga: un socket huérfano deja al próximo cliente esperando en vez de decirle que no hay daemon). Por
|
||
eso atiende un `Atendedor` —bóveda y a quién preguntarle, sin la ruta—. Lo cazó el test nuevo en el
|
||
primer intento.
|
||
|
||
**El test, y por qué el crate no podía ver su propio fallo.** `dos_clientes_vivos_a_la_vez_son_
|
||
atendidos`. Los siete que ya existían abrían un cliente, lo usaban y lo soltaban antes del siguiente
|
||
—y el propio arnés del test llama a `atender_cliente` en secuencia—, así que **nunca hubo dos
|
||
conexiones solapadas**: el test serializaba justo lo que producción no serializa nunca. El segundo
|
||
cliente se pregunta en un hilo con `recv_timeout` a propósito, porque el fallo ES colgarse y un test
|
||
que se cuelga no dice qué pasó. Probado en los dos sentidos: con el bucle viejo falla a los 5 s, con
|
||
el nuevo pasa, y los otros siete siguen verdes.
|
||
|
||
##### ⚠ Y el lock de tawasuyu volvió a estar abierto — se cierra con UNA línea, no con mil
|
||
|
||
Subir el pin a `cf3540460` dio el error de siempre: `cargo vendor --locked` muere con «cannot update
|
||
the lock file … because --locked was passed», **sin nombrar el crate**. Es el mismo modo de fallo que
|
||
bloqueó a `atuq` tres semanas (§7.quinquies.bis), y vuelve cada vez que alguien agrega una dep de
|
||
ruta sin actualizar el lock.
|
||
|
||
Lo que importa para la próxima vez es **cuál de las dos operaciones se usa para cerrarlo**:
|
||
|
||
| | diff | checksums movidos | sirve |
|
||
|---|---|---|---|
|
||
| `cargo metadata` sin `--locked` (mínima) | **1 línea** | **0** | ✅ |
|
||
| `cargo generate-lockfile` | 6.305 líneas | 750 | ❌ invalida el vendoreo de todos |
|
||
|
||
La mínima agrega sólo la arista que falta. La otra re-resuelve el workspace entero y se ve igual de
|
||
«correcta» hasta que uno mira el diff. Las dos en un árbol LIMPIO (`git archive | tar -x`), con el
|
||
control de siempre: `cargo metadata --locked` falla antes y pasa después. Publicado en `b80f7567c`,
|
||
con índice temporal porque el `Cargo.lock` estaba `MM` en el clon compartido.
|
||
|
||
#### 7.novies CERRADA: la bóveda entrega una contraseña, y sólo cuando alguien dice que sí (2026-09-18)
|
||
|
||
Las seis etapas del guardián de metal, en verde, sobre artefactos vigentes:
|
||
|
||
```
|
||
A ✓ sin la app dueña, el host contesta locked:true — y con ok:true, que es la trampa
|
||
B ✓ con la app, vault.status dice ABIERTA: el socket sube en 1-2 s
|
||
C ✓ el diálogo ABRE en el compositor y se lo CONTESTA: «1» ⇒ yes, «2» ⇒ no
|
||
D ✓ vault.save con consentimiento real guarda, y vault.match la encuentra SIN contraseña
|
||
E ✓ el navegador real, sobre una página servida por HTTP: la contraseña llega al campo
|
||
F ✓ contestando que NO, al campo no llega NADA
|
||
```
|
||
|
||
**Lo que la E afirma, y por qué la F es la mitad que la hace valer.** La E es la promesa entera del
|
||
§6: un servidor de verdad sirve un formulario, `atuq` lo carga, se aprieta el botón de la extensión,
|
||
`vault.fill` abre el diálogo, alguien dice que sí y la contraseña aparece en el campo. Sola no
|
||
probaría nada: **una bóveda que entrega siempre se ve idéntica a una que entrega con permiso.** La F
|
||
corre lo mismo contestando que no y exige que al campo no llegue nada.
|
||
|
||
Y lo que mide no es lo que el host contesta sino **lo que hay en el campo**: la página tiene un
|
||
guión que delata al servidor lo que le pongan. Es la lección del §7.quater —«el guardián leyó el
|
||
disco, no la respuesta»— aplicada al otro extremo.
|
||
|
||
##### Tres fallos del ARNÉS, y los tres se veían como fallos del producto
|
||
|
||
Ninguno era de la bóveda, y los tres habrían quedado escritos como «la bóveda no anda»:
|
||
|
||
1. **`wait` a secas esperaba también a la app de la bóveda**, que es un trabajo en segundo plano.
|
||
La jaula no cerraba nunca, se comía su timeout y a la app la mataba un KILL — y con ella se iba
|
||
lo recién guardado, porque `sled` no alcanzaba a volcar. La etapa siguiente no encontraba la
|
||
credencial: **«la bóveda no guarda», que es el diagnóstico contrario al verdadero.** `wait
|
||
"$contestador"`;
|
||
2. **el diálogo no responde al teclado mientras la app de la bóveda está inicializando su GPU.**
|
||
Con render por software en cuatro núcleos, dos llimphi arrancando a la vez se pisan: la ventana
|
||
del diálogo entra en el árbol del compositor, toma el foco y se come doce teclas seguidas sin
|
||
efecto; `vault.save` vuelve `denied` por timeout **con la ventana todavía abierta**. Se espera a
|
||
que la app PINTE (aparezca en el árbol, 12-13 s), no sólo a que atienda el socket;
|
||
3. **una sola tecla no alcanza y el borde se mueve**: con el mismo `wtype rc=0`, una corrida
|
||
contestaba y otra volvía `denied`. El contestador insiste hasta que la ventana se va, que es la
|
||
única señal que no depende de adivinar cuánto tarda en estar listo.
|
||
|
||
⚠ Y uno más, del lado del chrome: **`WebExtensionPolicy` no es global en el scope de una ventana del
|
||
navegador** —la sonda moría con `ReferenceError` y eso se leía como «la extensión no está»—. Sale
|
||
del módulo `ExtensionParent`, que además la reexporta.
|
||
|
||
##### Lo que costó llegar hasta acá, en fallos ajenos al navegador
|
||
|
||
El guardián se escribió para medir una función y terminó destapando tres piezas rotas, **ninguna en
|
||
`atuq`**: que el dueño de la bóveda y el diálogo no estaban en el corpus (§7.sexies), que ninguna app
|
||
llimphi de escritorio podía pintar sin Vulkan (§7.septies), y que el dueño atendía de a un cliente
|
||
(§7.octies). Las tres se veían igual desde el navegador: una bóveda que no ofrece nada, sin un solo
|
||
error. Es el argumento entero a favor de medir en metal y no leer ficheros.
|
||
|
||
#### 7.decies La bóveda estaba SELLADA y en ninguna imagen — el sexto lugar donde una extensión se enchufa (2026-09-21)
|
||
|
||
El §7.novies cerró la función: las seis etapas en verde, el diálogo a la vista, el control de decir
|
||
que no. Lo que quedaba abierto era la **decisión 1 del §7.sexies** —«en qué imágenes se declaran»—
|
||
con su respuesta escrita como **NO**, y con el motivo: mientras ninguna app llimphi pudiera pintar
|
||
(§7.septies), declararlas era instalar 43 M de binarios que niegan todo sin un error.
|
||
|
||
Ese motivo se cayó el 2026-09-18. La respuesta de hoy es **SÍ, en las cuatro**, y antes de tomarla
|
||
la pregunta se volvió a medir en vez de darla por sabida:
|
||
|
||
```
|
||
atuq sealed perfiles=['escritorio-cosmic','escritorio-gnome','escritorio-kde','escritorio-sway']
|
||
puriy-costura sealed perfiles=['escritorio-cosmic','escritorio-gnome','escritorio-kde','escritorio-sway']
|
||
boveda sealed perfiles=[]
|
||
shuma-pregunta sealed perfiles=[]
|
||
```
|
||
|
||
**`sealed` con `perfiles: []` es la lección de `foot` otra vez** —y no por falta de haberla
|
||
escrito: `targets.toml` la repetía **quince veces** antes de hoy—: una receta sellada que ningún
|
||
perfil declara NO ESTÁ EN NINGUNA IMAGEN, y la métrica de clausura no lo puede ver porque mide lo
|
||
declarado. Las dos entran a los cuatro perfiles de escritorio de
|
||
`docs/state/targets.toml`, **las dos o ninguna**: sin el dueño `vault.match` no ofrece nada, y sin
|
||
el diálogo `Command::new` falla y TODO `vault.fill` se deniega — media bóveda es una que niega todo
|
||
en silencio. Cuestan **~43 M por imagen** (22 M + 21 M medidos sobre los artefactos sellados), contra
|
||
los ~1,25 GiB que ya lleva el §6.7.
|
||
|
||
##### El hueco que apareció al declararlas: estar en la imagen no es poder abrirla
|
||
|
||
Con las dos raíces puestas, la app viaja en las cuatro imágenes y **sigue sin existir para quien la
|
||
usa**: `recipes/boveda.toml` instalaba `/usr/bin/boveda` y nada más, y los lanzadores de los cuatro
|
||
escritorios leen `/usr/share/applications`. A un binario que nadie lista sólo se llega escribiendo
|
||
`boveda` en una terminal. Es **la misma forma de fallo, una capa más arriba**: la función instalada,
|
||
apagada y sin un error — que es lo único que este capítulo entero viene persiguiendo.
|
||
|
||
Entra `boveda.desktop` en la fase `install` de la receta. Tres cosas se midieron antes de escribirlo:
|
||
|
||
- **el icono existe.** `Icon=dialog-password` es nombre del icon naming spec, y está en los tres
|
||
temas que los perfiles declaran: `breeze-icons` (6 ficheros), `adwaita-icon-theme` (1),
|
||
`cosmic-icons` (2). El cuarto perfil (sway) lleva sólo `hicolor`, que por diseño no trae iconos:
|
||
ahí cae al genérico, que es degradarse y no romperse;
|
||
- **el fichero lo acepta el validador de verdad** —el `desktop-file-validate` del artefacto
|
||
`desktop-file-utils`, con `atuq.desktop` de control, que pasa sin una observación—. Deja un hint
|
||
sobre `Categories=Utility;Security;`: que `Security` se empareja con `Settings` o `System`. **Las
|
||
dos formas que callan ese hint lo cambian por uno peor** —medido: `Utility;Security;System;` y
|
||
`Utility;Security;Settings;` traen dos categorías principales ⇒ «application might appear more
|
||
than once in the application menu»—. Se queda como está, que es además lo que usa KeePassXC;
|
||
- **⚠ y la ventana NO se va a poder emparejar con el lanzador, y no es de acá.** `llimphi_ui::run`
|
||
no llama nunca a `with_name`, y winit 0.30.13 sólo manda `set_app_id` `if let Some(name) =
|
||
attributes.platform_specific.name` ⇒ **la ventana sale sin `app_id`**, y con el título `"llimphi"`,
|
||
que es el default de `App::title` y `BovedaApp` no sobrescribe. Por eso el `.desktop` no lleva
|
||
`StartupWMClass`: no habría contra qué emparejarlo. Vale para **toda** app llimphi, no para ésta:
|
||
el dock las ve a todas iguales y sin nombre. Es de llimphi —como el muro del §7.septies— y se
|
||
arregla allá.
|
||
|
||
El `.desktop` mueve el hash de la receta: `b3:b0c6adc4` ⇒ `b3:3f1072cc`, reconstruida en el worker
|
||
con la guarda del §7.quinquies puesta (`### receta verificada 3f1072cc…` antes de compilar nada,
|
||
porque el latido revierte la receta del worker cada media hora y un acierto de caché sobre la receta
|
||
vieja imprime `SELLADA` en cero segundos). Y el artefacto se miró por dentro, que es la regla 3:
|
||
|
||
```
|
||
/.hammer/recipe.toml
|
||
/usr/bin/boveda
|
||
/usr/share/applications/boveda.desktop ← 22 M, y el validador del store lo acepta
|
||
```
|
||
|
||
##### El SEXTO lugar, y su guardián
|
||
|
||
`test-atuq-boveda-coherente.py` decía —y este documento con él— que una extensión de `atuq` está
|
||
enchufada en **cinco** lugares. Son **seis**, y el sexto es el que faltaba:
|
||
|
||
| lugar | qué decide | cómo se ve cuando falta |
|
||
|---|---|---|
|
||
| `manifest.json` | el id que la extensión declara | — |
|
||
| `distribution/policies.json` | la política que la instala | navegador sin la función |
|
||
| `native-messaging/*.json` | el permiso para hablarle al host | `connectNative` falla en silencio |
|
||
| `atuq.cfg` | las preferencias que la acompañan | el gestor de Gecko se pelea por el campo |
|
||
| `recipes/puriy-costura.toml` | el COMMIT del host: si sus verbos existen | «verbo desconocido» ⇒ se calla |
|
||
| **`docs/state/targets.toml`** | **quién DECLARA al dueño y al diálogo en la imagen** | **`ok:true, locked:true`** |
|
||
|
||
El sexto chequeo mira `targets.toml` —el manifiesto de objetivo, no `build-state.json`, que es su
|
||
derivado— y exige las dos raíces en los cuatro perfiles, con **control positivo**: `atuq` tiene que
|
||
estar ahí, porque un `in` que no encuentra puede ser una raíz ausente o un campo equivocado y las dos
|
||
se ven igual. El tercer control negativo (`--negative-control-perfil`) saca a `boveda` de una copia
|
||
en memoria y exige que esto lo vea; sin él, un chequeo que siempre dice que sí se vería idéntico a
|
||
uno que funciona. Probado en los dos sentidos: los cuatro perfiles en verde, y el control en rojo.
|
||
|
||
##### Lo que queda abierto, dicho como lo que es
|
||
|
||
1. **quién levanta la app.** Hoy: la persona, desde el lanzador. Mientras no esté abierta, la bóveda
|
||
del navegador contesta `locked:true` —correcto, y es lo que hace cualquier gestor de contraseñas—,
|
||
pero nadie decidió todavía si debe autoarrancar con la sesión. No se decide de paso: autoarrancar
|
||
la ata a que la identidad del llavero esté desbloqueada en el login (la decisión 2 del §7.sexies,
|
||
que sigue abierta);
|
||
2. **el único proveedor de GL de las cuatro imágenes es `iris` (Intel).** `mesa-llvmpipe` no está en
|
||
ningún perfil y `mesa-swrast` sólo en `escritorio-mirada` — y softpipe no le alcanza a wgpu, que
|
||
pide compute (§7.septies). O sea que en metal sin GPU Intel el diálogo no pinta… igual que no
|
||
pintaría el escritorio entero, que también es cliente de GL: **la bóveda no agrega un hueco, lo
|
||
hereda**. Lo que taparía el caso de la VM es un proveedor por software en el perfil, y eso es una
|
||
decisión de tamaño con dos candidatos (`mesa-llvmpipe`, o lavapipe como receta nueva), no un
|
||
arreglo de paso.
|
||
|
||
## 8. Plan, por unidades de trabajo
|
||
|
||
Cada una cierra sola, se commitea y se pushea. El orden no es preferencia: cada una destraba a la
|
||
siguiente.
|
||
|
||
| # | Unidad | Puerta que abre | Bloqueada por |
|
||
|---|---|---|---|
|
||
| 1 | **`waterfox` SELLA** ✅ 2026-09-06 — `b3:88b5a762`, 377 M, BuildID determinista **y REPRODUCE bit a bit**. Cuatro muros, **ninguno de la receta**: takana viejo en el worker, el watchdog barriendo el post-mortem, deps estáticas vs. el cairo bundleado, y una rotura de upstream sólo-Linux | valida la tesis del reúso | — |
|
||
| 2 | ~~`llvm-toolchain`~~ → **`apk add lld` + `compiler="clang"`** ✅ 2026-09-05 | LTO **y** el muro 3 | — |
|
||
| 3 | Construir el `firefox` con LTO (hash `b3:6f2a3b2f`) | el eje de velocidad **y** las extensiones de `atuq` | 2 ✅ |
|
||
| 3.a | **PGO ENCENDIDO** ✅ 2026-09-07 — perfil de 501.521 funciones recogido bajo headless, publicado en el mirror y pineado por sha256; 189 compilaciones con `-fprofile-use`. Sin `jarlog` todavía | la otra mitad de la ganancia | 3 ✅ |
|
||
| 3.b | **RLBox ENCENDIDO** ✅ 2026-09-06 — cadena wasm de CINCO recetas selladas + guardián que la exige en el binario; firefox sellado **y REPRODUCE bit a bit** (`verificar-repro.sh`: 0 no-determinismos) | cierra el hueco de seguridad del §3.bis | 2 ✅ |
|
||
| 4 | **`atuq`: receta derivada + overlay de chrome** ✅ v0.1 escrita | el andamio de todo lo demás | 3 |
|
||
| 4.b | Branding + re-empaque de `omni.ja` ✅ v0.2 | el chrome de verdad | — |
|
||
| 4.d | **Abrirlo en pantalla** — destapó EXDEV, el lanzador, el `atk` envenenado y la cadena GTK3 estática | «usable», no sólo «sellado» | — |
|
||
| 4.e | **v0.3: página de inicio y pestaña nueva** por extensión de sistema; exigió `MOZ_REQUIRE_SIGNING` vacío. **Verificado 2026-09-07** con `scripts/test-atuq-inicio.py`: se le pregunta al MOTOR (`browserSettings.homepageOverride` / `newTabPageOverride`) y da `moz-extension://…/inicio.html` en las dos, contra `about:home`/`about:newtab` en el control | y con eso queda destrabada la unidad 6 (`sct`) | rebuild de firefox |
|
||
| 4.f | **Los códecs, medidos** ✅ 2026-09-07 — H.264+AAC, VP9+Opus, AV1, MP3 y FLAC reproducen; arreglado el `LD_LIBRARY_PATH` que dejaba al RDD sin ffvpx (AV1 muerto en silencio) y nacido `scripts/test-atuq-codecs.sh` con control negativo | «se ve el vídeo», que ningún auditor de ELF podía responder | — |
|
||
| 4.c | `MOZ_BUILD_DATE` determinista ✅ — `BuildID=19700101000001`, con guardián en install y **`why-differs` 56/56 sobre dos builds reales** | el invariante de reproducibilidad | — |
|
||
| 4.g | **El chrome se programa desde `atuq.cfg`, sin abrir `omni.ja`** ✅ 2026-09-09 — y de paso: la **vista dividida ya la trae el motor y viene prendida** (fx 154), ejercitada de verdad con `gBrowser.addTabSplitView` ⇒ `activeSplitView` + 2 navegadores. Guardián `scripts/test-atuq-chrome.py` con control negativo del propio motor | retira dos pendientes que eran de upstream, y deja el camino del chrome que NO pelea con el `jarlog` | — |
|
||
| 4.h | **Quién instala las extensiones, medido** ✅ 2026-09-09 — tres escenarios en `scripts/test-atuq-instalacion.py`: instala el ESCANEO de `distribution/extensions/`, `install_url` con `file://` no instala nada. El README de la receta afirmaba lo contrario y quedó corregido | el mecanismo del que cuelga la extensión de `sct` deja de ser una nota al pie y pasa a estar vigilado | — |
|
||
| 5 | **Host de native messaging — HECHO 2026-09-10.** `puriy-costura` en tawasuyu (`ebe41e96`) + el cable `shared/foreign-webext`, 14 tests sin navegador; y del lado de acá `recipes/puriy-costura.toml` ⇒ `b3:3b633431`, ELF estático de 952 K **verificado como artefacto**: contesta un marco de 4 bytes y hace la promesa del §6.1 completa (4 visitas aprendiendo → `stable` → un script cambiado = un evento). El camino ya estaba comprobado en el §7.bis con sonda y control negativo | los verbos del §6 | 4 ✅ |
|
||
| 6 | **`sct` v1 — HECHA 2026-09-10.** Extensión `sct@atuq.tawasuyu` (`filterResponseData` → native messaging) + el manifiesto en `/usr/lib/mozilla/native-messaging-hosts/` + el lanzador que le pone el `--state` que el manifiesto no puede llevar. Guardián `scripts/test-atuq-sct.py`: servidor HTTP real, seis cargas, **una carga = una visita** vigilado, la insignia releída, y dos controles (la 5ª repite el mismo script y no alerta; sin manifiesto no hay veredicto) | el diferenciador que nadie tiene, andando | 5 ✅ |
|
||
| 7 | **Descargas al CAS — HECHA 2026-09-10.** `cas.ingest`/`cas.list` en el host + la extensión `descargas@atuq.tawasuyu`; ingesta en STREAMING (`arje-cas::almacenar_fichero_en`, nueva) para no cargar una ISO entera en RAM; raíz propia porque el `GcCas` de arje borraría las descargas en silencio; el fichero del usuario no se toca. Guardián `scripts/test-atuq-descargas.py` con descarga real y control negativo | 6.2, y alimenta 6.9 | 5 ✅ |
|
||
| 8 | **Archivo personal — la mitad de congelar y buscar, HECHA 2026-09-10.** `archive.add`/`archive.search` + la extensión `archivo@atuq.tawasuyu`: el HTML ya ejecutado al CAS, el índice buscable en JSON, y nada de ventanas privadas. Guardián `scripts/test-atuq-archivo.py`, cuyo control negativo además **dice quién** impidió archivar lo privado. **Falta la mitad semántica**: un motor `rag-motor::RagMotor` (como `willay-rag`), que exige daemon de embeddings + LLM real | 6.3 | 5 ✅, 7 ✅ |
|
||
| 9 | **Medios (6.6) ✅ y torrent (6.9) ✅ — 2026-09-10.** El medio lo abre `mpv`; el torrent lo toma `puriy-costura-torrent`, un daemon **propio, configurable y perezoso** que sobrevive al navegador y cosecha al CAS. Medido antes de decidir: la pila de librqbit compila para musl (226 crates), así que la decisión fue «no ahí» y no «no se puede». **Faltan** el foco (6.5) y la IA local (6.7), ésta bloqueada porque el corpus no tiene modelo ni embeddings | 6.5–6.7, 6.9 | 5 ✅ |
|
||
| 10 | **Foco (6.5) — 2026-09-10.** Entra al corpus la cadena `nftables` (libmnl + libnftnl + nft 1.1.6), que el grafo pedía y nadie había puesto, con dos arreglos de fábrica: el sello de tiempo que rompía la reproducción y un bashismo. Medido con root: el cgroup en foco no sale y el de al lado sí. Y del lado del navegador, la extensión `foco` MUESTRA el estado y **no tiene verbo para apagarlo**. **Falta** quién pone a `atuq` en un cgroup (delegación, decisión de arje) y quién aplica la política (root) | 6.5 | 6 ✅ |
|
||
| 11 | **Motor de inferencia local (6.7) — 2026-09-11.** Entra `recipes/llama-cpp.toml` (b10901, estática, 199 M, **REPRODUCE**), que resulta ser **un solo muro para dos pendientes**: el §6.7 y la mitad semántica del §6.3 no eran dos problemas, era que el corpus no tenía con qué correr un modelo (`pluma-llm` sólo tiene backends de nube; `rimay-verbo-fastembed` DESCARGA onnxruntime glibc + el modelo). ⚠ Y dejó medido lo que no se podía deducir: **`SOURCE_DATE_EPOCH` —la variable que nos da reproducibilidad— apagaba las SEIS perillas de ISA de ggml**, y el artefacto sellaba y corría con 0 `%ymm`. Ahora van declaradas y el `install` las comprueba. Guardián `scripts/test-llama-cpp.py` con control negativo vivo. **Faltan** el modelo (fuente pineada, no receta), los verbos del host y quién levanta el servidor | 6.7, y la mitad semántica de 6.3 | 5 ✅ |
|
||
| 12 | **La bóveda (SDD-BOVEDA §8) — a medias, 2026-09-15.** La décima extensión: ofrece la credencial del sitio abierto y no puede sacar una contraseña por su cuenta —`vault.match` contesta títulos y usuarios; la contraseña sale por `vault.fill`, que **pregunta en el escritorio**—. La dirección la pone el chrome, nunca la página. El gestor de Gecko se aparta como VALOR DE ARRANQUE y no como política. **Destrabada el 2026-09-16** (§7.quinquies.bis): el `Cargo.lock` de tawasuyu publicado —y resultó ser byte a byte el que la otra sesión ya tenía sin commitear—, el pin subido a `23a292863` ⇒ `b3:7d63655a`, construido en el worker (el vendoreo son 2,4 G y el hub estaba al 99%) y medido con el mismo `strings` que había diagnosticado el hueco: `vault` de **0 a 10**, con `cas`/`sct` de control. **Falta**, y no era lo que este renglón decía (§7.sexies, 2026-09-18): el host sellado contesta `vault.status → locked:true` porque **el dueño de la bóveda y el diálogo de consentimiento no estaban en el corpus** — la función está apagada de fábrica en las cuatro imágenes. Entran `recipes/boveda.toml` y `recipes/shuma-pregunta.toml`, **las dos SELLADAS el 2026-09-18** (`b3:59ffd74b` y `b3:99763eca`, construidas en el worker). ⚠ Y con eso apareció el muro de verdad, que no es de la bóveda: **ninguna app llimphi de escritorio abre ventana en esta distro** porque no hay Vulkan en ninguna imagen y el camino GL de llimphi arma la instancia sin display handle (§7.septies). **CERRADA el 2026-09-18** (§7.novies): el guardián de metal —`scripts/test-atuq-boveda-metal.py`— pasa sus **seis etapas**, con el navegador de verdad, el diálogo a la vista y el control de decir que NO. Para llegar hubo que arreglar **tres piezas ajenas al navegador**: el dueño y el diálogo no estaban en el corpus (§7.sexies), ninguna ventana llimphi podía pintar sin Vulkan (§7.septies, arreglado en llimphi) y el dueño atendía de a UN cliente, lo que con el navegador abierto dejaba la bóveda muda (§7.octies, arreglado en pacha con su test de regresión). **Y ENTRA A LAS IMÁGENES el 2026-09-21** (§7.decies): hasta ese día las dos estaban `sealed` con `perfiles: []` —o sea selladas y en ninguna imagen, la lección de `foot` que ese mismo fichero repetía quince veces—, así que la función seguía apagada de fábrica aunque anduviera. Se declaran en los cuatro perfiles de escritorio (~43 M por imagen) y la receta aprende a instalar `boveda.desktop`, porque estar en la imagen no es poder abrirla: sin entrada en `/usr/share/applications` a la app sólo se llega escribiendo su nombre en una terminal. El guardián de coherencia pasa de CINCO lugares a SEIS —el sexto es `targets.toml`— con su tercer control negativo. **Abierto**: quién la levanta con la sesión, y de dónde sale la raíz de las claves (decisión 2 del §7.sexies) | el gestor de contraseñas de la suite dentro del navegador | 5 ✅ |
|
||
| 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — |
|
||
|
||
Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la
|
||
toolchain. Si hay dos frentes, van juntas.
|
||
|
||
## 9. Por qué esto no abandona a `puriy`
|
||
|
||
`puriy` es el motor DOM/CSS propio con JS real (QuickJS-NG sobre `wasmi`) cuyo límite declarado no es
|
||
el lenguaje sino **el wiring nativo**: DOM bindings completos, red, render. Eso no se acelera
|
||
escribiendo otro navegador; se acelera con tiempo.
|
||
|
||
`atuq` no le compite porque no comparte una sola línea con él, y **le sirve** porque todo lo del §6
|
||
vive del lado Rust: el host del §7, el testigo de `sct`, las descargas al CAS, el archivo con RAG.
|
||
Son **agnósticos del motor**. Cuando `puriy` madure, se enchufan del mismo lado sin reescribirse.
|
||
|
||
Dicho al revés, que es como conviene recordarlo: **`atuq` es el navegador que se puede usar mientras
|
||
`puriy` crece, y el andamio de las features que `puriy` va a heredar.** Si dentro de dos años `atuq`
|
||
se apaga, lo que se tira es una receta derivada y una hoja de CSS; todo lo demás sobrevive.
|