# Cómputo pesado fuera del hilo de UI — regla dura de Llimphi > Regla dura del framework, a aplicar en **toda** app Llimphi. Origen: una app > de escritorio real que se quedó en "Not Responding" calculando efemérides > dentro de `update` (2026-05-31). Lo que sigue es lo aprendido desde entonces: > el patrón, el auditor estático que lo vuelve repetible, el vigía que mide en > vivo lo que ningún linter puede decidir, y la categoría de defecto que no se > arregla mandándola a un worker. ## La regla Ningún `App::update`, `App::init` ni handler (`on_key`/`on_wheel`/…) debe ejecutar trabajo pesado **síncrono**. Bloquea el hilo de UI → la ventana no repinta, no responde, no cierra → "Not Responding". Es el antipatrón win32 de trabajo pesado en el message loop. Crítico: en winit, **`App::init()` corre dentro de `resumed`, DESPUÉS de crear la ventana**. Un cómputo pesado en init congela la ventana ya visible. Se nota brutal en **debug** (sin optimizar, 10–50× más lento; además debug *panica* en overflow donde release *wrappea*). Pero la mala arquitectura está igual en release: una carta pesada, una máquina lenta o un dataset grande la exponen. "Pesado" = efemérides/simulación, layout de árboles grandes, IO de disco/red, parse, embeddings, compresión… cualquier cosa que pueda pasar de ~unos ms. ## El patrón (mover a un worker) ```rust // 1) Mensaje de resultado: u64 = generación; Arc porque Msg: Clone. enum Msg { /* … */ XComputed(u64, std::sync::Arc) } // 2) En el Model: el resultado es Option (None = "calculando…"), // más un flag dirty y un contador de generación. struct Model { x: Option, x_dirty: bool, x_gen: u64, /* … */ } // 3) recompute_x sólo marca dirty (los helpers no tienen el Handle). fn recompute_x(m: &mut Model) { m.x_dirty = true; } // 4) Al FINAL de update() (que SÍ tiene el Handle): si está sucio, bumpear // generación, clonar los inputs y despachar a un worker. if m.x_dirty { m.x_dirty = false; m.x_gen = m.x_gen.wrapping_add(1); let gen = m.x_gen; let input = m.input.clone(); // sólo lo que el worker necesita handle.spawn(move || Msg::XComputed(gen, std::sync::Arc::new(compute(&input)))); } // 5) Arm del resultado: aplicar SÓLO si la generación sigue vigente // (un recálculo posterior ya dejó viejo a este). try_unwrap evita copiar // (el Arc llega con refcount 1 porque el Msg no se clona en el camino). Msg::XComputed(gen, x) => { if gen == m.x_gen { m.x = Some(std::sync::Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone())); } } // 6) En init: arrancar con None y despachar el primer cómputo a un worker // (init tiene el Handle). La vista pinta "calculando…" mientras tanto. // 7) En la vista: match &model.x { Some(v) => panel(v), None => calculando() } ``` Notas: - El campo `Option` exige `T: Clone` (para el fallback de `try_unwrap`). - La **generación** evita que un resultado tardío pise a uno más nuevo (drags, toggles rápidos). Imprescindible si el recálculo puede dispararse seguido. - Inputs al worker deben ser `Send` (clonar `Chart`, `Vec`, etc.). - No hace falta async-ear lo barato: en cosmos el render de la carta quedó síncrono (con el solver acotado son ms); sólo el astro (144 muestras × 10 cuerpos) fue a worker. ## Soluciones colaterales de la misma cacería (ya aplicadas, no revertir) - **Preferir Vulkan en `llimphi-hal`** (`Hal::new`, commit `9f221983`): pedir adapter con `Backends::PRIMARY` y caer a `all()` (incluye GL) sólo si no hay PRIMARY. El backend **GL de Mesa sobre Wayland segfaultea en el teardown** (`eglTerminate → wl_proxy_marshal` sobre conexión muerta, exit 139 sin panic). Es infra compartida → ya beneficia a todas las apps. No volver a `InstanceDescriptor::default()`. - **Acotar solvers iterativos** (`cosmos-ephemeris`, Kepler, commit `added8b3`): un `loop {}` con corte `dl.abs() < 1e-15` (pegado al epsilon de f64) entra en ciclo límite y NO converge para ciertos inputs → loop infinito. Release fusiona flops (FMA) y converge; debug no. **Todo solver Newton/bisección lleva cota dura** (`for _ in 0..N`), no `loop {}`. ## Cómo diagnosticar (sin ptrace; `ptrace_scope=1` bloquea gdb a no-hijos) - `/proc/$PID/wchan` del hilo principal: `do_epoll_wait` = ocioso sano; `__futex_wait` = deadlock de lock; estado `R` sostenido = spin o cómputo en el hilo de UI; `dma_fence`/`drm` = GPU; `poll` sobre fd `wayland-0` = frame callback. - gdb **como PADRE** sí puede (lanzar la app *bajo* gdb): backtrace del spin/ segfault. La pila de wgpu revela el backend (`wgpu_hal::gles` vs vulkan). - Trazar con un `eprintln` ENTER/DONE para distinguir "una llamada que no termina" (loop infinito) de "se llama repetidas veces" (storm de dispatch). - En debug arranca como `cargo run` (binario `target/debug`); el release puede ocultar el bug (float/overflow distintos). ## Barrido automático — `scripts/auditar-hilo-ui.py` El checklist a ojo no escala. `./scripts/auditar-hilo-ui.py [subárbol]` hace el barrido y es **repetible**: extrae el cuerpo de cada `fn init`/`update`/`view`/ `view_overlay` por **balanceo de llaves** (un `File::open` en otra función del mismo archivo no cuenta) y ubica cada llamada bloqueante **dentro o fuera de la región de un `spawn(...)`**. Ese segundo paso no es cosmético: un `update` real es un `match` con decenas de arms, y basta que uno despache para que un grep por archivo declare sano al cuerpo entero mientras otro arm bloquea. Sale con código 1 si encuentra algo, así que sirve de gate en CI. Dos detalles que se ganaron a los golpes: - **Enmascara comentarios y literales antes de analizar.** Sin eso el script se marca a sí mismo: el comentario que explica *«este `fs::copy` es a propósito»* matcheaba como si fuera código, y encima corría el número de línea reportado, dejando la marca de excepción fuera de alcance. Un linter que denuncia su propia documentación no sirve de gate. - **Excepciones explícitas y con motivo.** Un caso legítimamente sincrónico se silencia con un comentario arriba de la llamada: `// auditar-hilo-ui: intencional — `. La marca obliga a escribir el motivo al lado, porque un gate que se apaga sin explicar por qué es un gate que en dos meses nadie sabe si sigue valiendo. Para confiar en un verde, el control negativo es barato: correr el script contra la versión **anterior** del archivo (`git show HEAD:… > /tmp/…`) y ver que sí encuentra lo que ya se arregló. Un linter que dejó de detectar también da verde. ⚠️ **Dos límites, y el segundo es grande:** 1. Sólo ve llamadas **directas**. Un helper que adentro hace IO pasa invisible. 2. **No ve cómputo de CPU.** Detecta IO y `block_on`; un bucle que muele números en `update` le pasa por al lado — y el caso que originó esta regla era exactamente eso, no IO. Para esa mitad está el vigía. Sobre este repo el barrido sale limpio: los dos únicos hallazgos (`llimphi-anim-studio`, guardar y cargar) caen bajo «despachan a un worker (correcto, informativo)», que es la clasificación distinta, no la desaparición de la detección. ## El vigía del hilo de UI — el límite deja de necesitar ojo Decidir **estáticamente** si un bucle es caro es el problema de la parada con sombrero: ningún linter lo va a cerrar. Pero **medirlo en vivo sí**, y sale casi gratis, porque el runtime ya pasa por un solo lugar en cada `update`. `llimphi-ui/src/eventloop/vigia.rs` cronometra cada `App::update`, cada `App::init`, cada `App::view` y cada `App::view_overlay`. Si uno tarda más que su umbral, lo dice por stderr: ``` llimphi: update bloqueó el hilo de UI 342 ms (src/eventloop/input.rs:189) — el trabajo pesado va a un worker (ver COMPUTO-FUERA-DEL-HILO-UI.md) ``` Cuatro decisiones que valen más que el código: - **Encendido por defecto.** Si hiciera falta una feature o una env, volvería a depender de que alguien se acuerde. El costo es un par `Instant::now()` + `elapsed()` por llamada: **99 ns medidos**. Aun con mil eventos por segundo son 0,1 ms/s. `LLIMPHI_UI_SLOW_MS=` cambia el umbral, `=0` lo apaga. - **Umbral 100 ms para `update`/`init`.** Un cuadro a 60 Hz son 16,7 ms. Con 100 no se denuncia un `update` que se pasó de un cuadro —eso es ruido— sino uno que se comió seis: ahí ya no es una animación que salta, es la ventana congelada. - **Habla sólo cuando aprende algo**: la primera vez que un sitio pasa el umbral, y después sólo si empeora. Un `update` lento suele serlo en cada evento, y una línea por vez volvería el aviso invisible — la forma más común en que un aviso deja de servir. - **`#[track_caller]` nombra el sitio** (`input.rs:189` = teclado, `mod.rs:349` = canal de ticks) sin etiquetar a mano los 33 sitios de llamada. `init` lleva aviso propio: corre dentro de `resumed`, o sea **después** de crear la ventana, así que un cómputo pesado ahí congela una ventana que el usuario ya está viendo. ### La función que es peor que `update`: `view` `view` corre **en cada cuadro**, y el runtime no memoiza la llamada a nivel app (los nodos `memo` cachean *subárboles*, no la llamada). El costo tiene otra forma: > un `update` lento congela la ventana **una vez**; una `view` lenta le pone un > **techo de fps** mientras esté abierta. Tres decisiones propias: - **Umbral más bajo: un cuadro a 60 Hz (16,7 ms)**, y sale de una medición, no de la teoría: `LLIMPHI_PERF=1 stress_idle` da `view` en **190–326 µs de media y 727 µs de p95** sobre 987 nodos. El umbral queda 23× arriba del peor cuadro de un árbol normal —no puede haber falso positivo por un pico— y una app con una `view` genuinamente pesada todavía tiene 3–5× de aire. Hay un test que fija ese margen, para que nadie lo baje sin volver a medir. `LLIMPHI_UI_SLOW_VIEW_MS=` lo cambia, `=0` lo apaga. - **El aviso traduce ms a fps**, que es el número accionable: «84 ms» no dice nada, «techo de 12 fps» sí. - **Costo**: el mismo par de 99 ns, dos veces por cuadro. 12 µs por segundo a 60 fps. Verificado punta a punta contra el runtime y no sólo por unit test —que es lo que distingue «el envoltorio anda» de «el runtime lo usa»—: `LLIMPHI_UI_SLOW_VIEW_MS=0.05 cargo run -p llimphi-ui --example stress_idle` denuncia por stderr; con el umbral de fábrica la misma corrida de 12 s no dice **nada**. Y de paso se ve la regla de callarse trabajando bajo presión de cuadro: ~720 cuadros por encima del umbral forzado, **4 líneas**. ## La tercera categoría: no es IO, y tampoco va a un worker Este documento nació con dos categorías: **IO bloqueante** (que el auditor ve) y **cómputo pesado** (que sólo el vigía ve). Las dos se arreglan igual: al worker. Hay una tercera, y no se arregla así. Barriendo una app de edición de documentos aparecieron **cinco** funciones que corrían dentro de `view` —o sea en **cada cuadro**— y eran **accidentalmente cuadráticas**. Con 1.600 párrafos: 177.517 µs → 161 µs (**1.105×**), 96.252 µs → 1.057 µs (**91×**), 54.472 µs → 1.624 µs (**33,5×**), 49.100 µs → 80 µs (**642×**). **La forma es siempre la misma**, y por eso se reconoce: una función que resuelve *una* consulta recorriendo *todo* —partir el texto entero en párrafos, contar desde el principio, `position()` barriendo un `Vec`— llamada **dentro de un bucle** que recorre las líneas o los elementos. Cada función suelta es correcta y barata. El bucle es el que multiplica. **Y por eso no van a un worker.** Mandar 177 ms de trabajo cuadrático a otro hilo no arregla nada: sigue siendo cuadrático, y encima ahora hay que sincronizarlo. Lo que corresponde es **hacerlo lineal**: resolver la consulta para todos los elementos en una pasada, antes del bucle. Las funciones de a una **se dejan intactas**: para una consulta suelta son lo correcto, y sirven de oráculo. **Cómo se certifica.** Dos tests por arreglo, y ninguno mira una imagen: 1. **Equivalencia** contra la función de a una, que quedó sin tocar. Elemento por elemento, con guarda de arnés sobre cuántos se compararon y un caso que ejercite lo que podría desalinearse. 2. **Forma, no milisegundos.** No se fija un tiempo —depende de la máquina— sino que el **costo por elemento no crezca con el tamaño**. Medido a dos tallas (×16 de documento): antes crecía ×10 a ×13, después queda entre ×0,7 y ×1,4. **Qué lo habría encontrado antes.** El vigía, sin dudarlo: los cuatro medidos pasan los 16,7 ms del umbral de `view` por uno o dos órdenes de magnitud. El auditor estático **no**, y no hay forma de que lo haga. Se probó un barrido heurístico («un `.position(`/`.split(` adentro de un `for`, dentro de una función de vista»): **16 sitios, 15 falsos positivos**. No se dejó como gate — un linter con 94 % de falsos positivos entrena a ignorarlo, que es peor que no tenerlo. O sea que la defensa contra esta categoría es **correr la app con el vigía prendido**, no un script. ## La pregunta que hay que hacerle a cada app > **¿El costo de `view()` por cuadro depende del tamaño de los datos?** No «¿cuánto tarda?», sino «¿crece?». Se contesta con **dos tallas** del mismo dato (una chica, una 8× más grande), midiendo el **cociente**. ### Las tres trampas, todas pisadas el mismo día 1. **Un techo absoluto se pudre en silencio.** «`view` < medio cuadro» pasaba **mientras el costo crecía con el documento**: seguía verde hasta que alguien abriera uno más grande. Un gate de forma no envejece. 2. **Árbol acotado ≠ trabajo acotado.** Una app dio **462 nodos fijos** en las dos tallas —la virtualización del editor funciona— y aun así el tiempo crecía: una función recorría el texto entero por cuadro para producir esos 462 nodos. *Contar nodos no contesta la pregunta.* 3. **Una medición suelta no decide nada.** La misma forma dio ×6,29 (tallas en tandas separadas), ×1,02–1,70 (nueve rondas alternadas) y ×1,03–1,08 (veinticinco rondas alternadas). Las dos primeras eran ruido: una exageraba el problema seis veces. **Alternar las dos tallas en el mismo bucle y tomar el mínimo**, nunca medirlas en tandas. ### Definición de terminado, por app - Un test en el crate que arma la vista real con dos tallas y afirma la forma. - Gate puesto **sobre lo medido**, con el número en el comentario. - Guarda de arnés: que las dos vistas tengan nodos de verdad (comparar dos árboles vacíos da «plano» sin significar nada). - Si crece: desglose por fase impreso, para que quien lea el fallo no repita la investigación. ## El procedimiento, corto Para cada app que escribas sobre Llimphi: 1. `./scripts/auditar-hilo-ui.py ` → cero hallazgos sincrónicos. 2. Usarla con el **vigía de fábrica** (viene prendido) y ejercitar el caso grande, no el de demo: el dato que crece sin techo es donde vive el defecto. 3. Un test de **forma** de `view` con dos tallas, si el dato de la app crece. 4. Lo que quede legítimamente sincrónico se marca en el código con `// auditar-hilo-ui: intencional — `.