tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 COMPUTO-FUERA-DEL-HILO-UI.md Markdown · 292 lines · 14.9 KB History raw

docs: la regla del hilo de UI llega entera — el vigía, el auditor y la categoría que no va a un worker 168cd1875 · Sergio ·

1# Cómputo pesado fuera del hilo de UI — regla dura de Llimphi
2
3> Regla dura del framework, a aplicar en **toda** app Llimphi. Origen: una app
4> de escritorio real que se quedó en "Not Responding" calculando efemérides
5> dentro de `update` (2026-05-31). Lo que sigue es lo aprendido desde entonces:
6> el patrón, el auditor estático que lo vuelve repetible, el vigía que mide en
7> vivo lo que ningún linter puede decidir, y la categoría de defecto que no se
8> arregla mandándola a un worker.
9
10## La regla
11
12Ningún `App::update`, `App::init` ni handler (`on_key`/`on_wheel`/…) debe
13ejecutar trabajo pesado **síncrono**. Bloquea el hilo de UI → la ventana no
14repinta, no responde, no cierra → "Not Responding". Es el antipatrón win32 de
15trabajo pesado en el message loop.
16
17Crítico: en winit, **`App::init()` corre dentro de `resumed`, DESPUÉS de crear
18la ventana**. Un cómputo pesado en init congela la ventana ya visible.
19
20Se nota brutal en **debug** (sin optimizar, 10–50× más lento; además debug
21*panica* en overflow donde release *wrappea*). Pero la mala arquitectura está
22igual en release: una carta pesada, una máquina lenta o un dataset grande la
23exponen.
24
25"Pesado" = efemérides/simulación, layout de árboles grandes, IO de disco/red,
26parse, embeddings, compresión… cualquier cosa que pueda pasar de ~unos ms.
27
28## El patrón (mover a un worker)
29
30```rust
31// 1) Mensaje de resultado: u64 = generación; Arc<T> porque Msg: Clone.
32enum Msg { /* … */ XComputed(u64, std::sync::Arc<Resultado>) }
33
34// 2) En el Model: el resultado es Option (None = "calculando…"),
35//    más un flag dirty y un contador de generación.
36struct Model { x: Option<Resultado>, x_dirty: bool, x_gen: u64, /* … */ }
37
38// 3) recompute_x sólo marca dirty (los helpers no tienen el Handle).
39fn recompute_x(m: &mut Model) { m.x_dirty = true; }
40
41// 4) Al FINAL de update() (que SÍ tiene el Handle): si está sucio, bumpear
42//    generación, clonar los inputs y despachar a un worker.
43if m.x_dirty {
44    m.x_dirty = false;
45    m.x_gen = m.x_gen.wrapping_add(1);
46    let gen = m.x_gen;
47    let input = m.input.clone();              // sólo lo que el worker necesita
48    handle.spawn(move || Msg::XComputed(gen, std::sync::Arc::new(compute(&input))));
49}
50
51// 5) Arm del resultado: aplicar SÓLO si la generación sigue vigente
52//    (un recálculo posterior ya dejó viejo a este). try_unwrap evita copiar
53//    (el Arc llega con refcount 1 porque el Msg no se clona en el camino).
54Msg::XComputed(gen, x) => {
55    if gen == m.x_gen {
56        m.x = Some(std::sync::Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone()));
57    }
58}
59
60// 6) En init: arrancar con None y despachar el primer cómputo a un worker
61//    (init tiene el Handle). La vista pinta "calculando…" mientras tanto.
62
63// 7) En la vista: match &model.x { Some(v) => panel(v), None => calculando() }
64```
65
66Notas:
67- El campo `Option<T>` exige `T: Clone` (para el fallback de `try_unwrap`).
68- La **generación** evita que un resultado tardío pise a uno más nuevo
69  (drags, toggles rápidos). Imprescindible si el recálculo puede dispararse
70  seguido.
71- Inputs al worker deben ser `Send` (clonar `Chart`, `Vec`, etc.).
72- No hace falta async-ear lo barato: en cosmos el render de la carta quedó
73  síncrono (con el solver acotado son ms); sólo el astro (144 muestras × 10
74  cuerpos) fue a worker.
75
76## Soluciones colaterales de la misma cacería (ya aplicadas, no revertir)
77
78- **Preferir Vulkan en `llimphi-hal`** (`Hal::new`, commit `9f221983`): pedir
79  adapter con `Backends::PRIMARY` y caer a `all()` (incluye GL) sólo si no hay
80  PRIMARY. El backend **GL de Mesa sobre Wayland segfaultea en el teardown**
81  (`eglTerminate → wl_proxy_marshal` sobre conexión muerta, exit 139 sin
82  panic). Es infra compartida → ya beneficia a todas las apps. No volver a
83  `InstanceDescriptor::default()`.
84- **Acotar solvers iterativos** (`cosmos-ephemeris`, Kepler, commit `added8b3`):
85  un `loop {}` con corte `dl.abs() < 1e-15` (pegado al epsilon de f64) entra en
86  ciclo límite y NO converge para ciertos inputs → loop infinito. Release
87  fusiona flops (FMA) y converge; debug no. **Todo solver Newton/bisección
88  lleva cota dura** (`for _ in 0..N`), no `loop {}`.
89
90## Cómo diagnosticar (sin ptrace; `ptrace_scope=1` bloquea gdb a no-hijos)
91
92- `/proc/$PID/wchan` del hilo principal: `do_epoll_wait` = ocioso sano;
93  `__futex_wait` = deadlock de lock; estado `R` sostenido = spin o cómputo en
94  el hilo de UI; `dma_fence`/`drm` = GPU; `poll` sobre fd `wayland-0` = frame
95  callback.
96- gdb **como PADRE** sí puede (lanzar la app *bajo* gdb): backtrace del spin/
97  segfault. La pila de wgpu revela el backend (`wgpu_hal::gles` vs vulkan).
98- Trazar con un `eprintln` ENTER/DONE para distinguir "una llamada que no
99  termina" (loop infinito) de "se llama repetidas veces" (storm de dispatch).
100- En debug arranca como `cargo run` (binario `target/debug`); el release puede
101  ocultar el bug (float/overflow distintos).
102
103## Barrido automático — `scripts/auditar-hilo-ui.py`
104
105El checklist a ojo no escala. `./scripts/auditar-hilo-ui.py [subárbol]` hace el
106barrido y es **repetible**: extrae el cuerpo de cada `fn init`/`update`/`view`/
107`view_overlay` por **balanceo de llaves** (un `File::open` en otra función del
108mismo archivo no cuenta) y ubica cada llamada bloqueante **dentro o fuera de la
109región de un `spawn(...)`**. Ese segundo paso no es cosmético: un `update` real
110es un `match` con decenas de arms, y basta que uno despache para que un grep por
111archivo declare sano al cuerpo entero mientras otro arm bloquea. Sale con código
1121 si encuentra algo, así que sirve de gate en CI.
113
114Dos detalles que se ganaron a los golpes:
115
116- **Enmascara comentarios y literales antes de analizar.** Sin eso el script se
117  marca a sí mismo: el comentario que explica *«este `fs::copy` es a propósito»*
118  matcheaba como si fuera código, y encima corría el número de línea reportado,
119  dejando la marca de excepción fuera de alcance. Un linter que denuncia su
120  propia documentación no sirve de gate.
121- **Excepciones explícitas y con motivo.** Un caso legítimamente sincrónico se
122  silencia con un comentario arriba de la llamada:
123  `// auditar-hilo-ui: intencional — <por qué>`. La marca obliga a escribir el
124  motivo al lado, porque un gate que se apaga sin explicar por qué es un gate
125  que en dos meses nadie sabe si sigue valiendo.
126
127Para confiar en un verde, el control negativo es barato: correr el script contra
128la versión **anterior** del archivo (`git show HEAD:… > /tmp/…`) y ver que sí
129encuentra lo que ya se arregló. Un linter que dejó de detectar también da verde.
130
131⚠️ **Dos límites, y el segundo es grande:**
132
1331. Sólo ve llamadas **directas**. Un helper que adentro hace IO pasa invisible.
1342. **No ve cómputo de CPU.** Detecta IO y `block_on`; un bucle que muele números
135   en `update` le pasa por al lado — y el caso que originó esta regla era
136   exactamente eso, no IO. Para esa mitad está el vigía.
137
138Sobre este repo el barrido sale limpio: los dos únicos hallazgos
139(`llimphi-anim-studio`, guardar y cargar) caen bajo «despachan a un worker
140(correcto, informativo)», que es la clasificación distinta, no la desaparición
141de la detección.
142
143## El vigía del hilo de UI — el límite deja de necesitar ojo
144
145Decidir **estáticamente** si un bucle es caro es el problema de la parada con
146sombrero: ningún linter lo va a cerrar. Pero **medirlo en vivo sí**, y sale casi
147gratis, porque el runtime ya pasa por un solo lugar en cada `update`.
148
149`llimphi-ui/src/eventloop/vigia.rs` cronometra cada `App::update`, cada
150`App::init`, cada `App::view` y cada `App::view_overlay`. Si uno tarda más que su
151umbral, lo dice por stderr:
152
153```
154llimphi: update bloqueó el hilo de UI 342 ms (src/eventloop/input.rs:189) — el
155trabajo pesado va a un worker (ver COMPUTO-FUERA-DEL-HILO-UI.md)
156```
157
158Cuatro decisiones que valen más que el código:
159
160- **Encendido por defecto.** Si hiciera falta una feature o una env, volvería a
161  depender de que alguien se acuerde. El costo es un par
162  `Instant::now()` + `elapsed()` por llamada: **99 ns medidos**. Aun con mil
163  eventos por segundo son 0,1 ms/s. `LLIMPHI_UI_SLOW_MS=<ms>` cambia el umbral,
164  `=0` lo apaga.
165- **Umbral 100 ms para `update`/`init`.** Un cuadro a 60 Hz son 16,7 ms. Con 100
166  no se denuncia un `update` que se pasó de un cuadro —eso es ruido— sino uno que
167  se comió seis: ahí ya no es una animación que salta, es la ventana congelada.
168- **Habla sólo cuando aprende algo**: la primera vez que un sitio pasa el umbral,
169  y después sólo si empeora. Un `update` lento suele serlo en cada evento, y una
170  línea por vez volvería el aviso invisible — la forma más común en que un aviso
171  deja de servir.
172- **`#[track_caller]` nombra el sitio** (`input.rs:189` = teclado, `mod.rs:349` =
173  canal de ticks) sin etiquetar a mano los 33 sitios de llamada.
174
175`init` lleva aviso propio: corre dentro de `resumed`, o sea **después** de crear
176la ventana, así que un cómputo pesado ahí congela una ventana que el usuario ya
177está viendo.
178
179### La función que es peor que `update`: `view`
180
181`view` corre **en cada cuadro**, y el runtime no memoiza la llamada a nivel app
182(los nodos `memo` cachean *subárboles*, no la llamada). El costo tiene otra
183forma:
184
185> un `update` lento congela la ventana **una vez**; una `view` lenta le pone un
186> **techo de fps** mientras esté abierta.
187
188Tres decisiones propias:
189
190- **Umbral más bajo: un cuadro a 60 Hz (16,7 ms)**, y sale de una medición, no de
191  la teoría: `LLIMPHI_PERF=1 stress_idle` da `view` en **190–326 µs de media y
192  727 µs de p95** sobre 987 nodos. El umbral queda 23× arriba del peor cuadro de
193  un árbol normal —no puede haber falso positivo por un pico— y una app con una
194  `view` genuinamente pesada todavía tiene 3–5× de aire. Hay un test que fija ese
195  margen, para que nadie lo baje sin volver a medir.
196  `LLIMPHI_UI_SLOW_VIEW_MS=<ms>` lo cambia, `=0` lo apaga.
197- **El aviso traduce ms a fps**, que es el número accionable: «84 ms» no dice
198  nada, «techo de 12 fps» sí.
199- **Costo**: el mismo par de 99 ns, dos veces por cuadro. 12 µs por segundo a
200  60 fps.
201
202Verificado punta a punta contra el runtime y no sólo por unit test —que es lo que
203distingue «el envoltorio anda» de «el runtime lo usa»—:
204`LLIMPHI_UI_SLOW_VIEW_MS=0.05 cargo run -p llimphi-ui --example stress_idle`
205denuncia por stderr; con el umbral de fábrica la misma corrida de 12 s no dice
206**nada**. Y de paso se ve la regla de callarse trabajando bajo presión de cuadro:
207~720 cuadros por encima del umbral forzado, **4 líneas**.
208
209## La tercera categoría: no es IO, y tampoco va a un worker
210
211Este documento nació con dos categorías: **IO bloqueante** (que el auditor ve) y
212**cómputo pesado** (que sólo el vigía ve). Las dos se arreglan igual: al worker.
213Hay una tercera, y no se arregla así.
214
215Barriendo una app de edición de documentos aparecieron **cinco** funciones que
216corrían dentro de `view` —o sea en **cada cuadro**— y eran **accidentalmente
217cuadráticas**. Con 1.600 párrafos: 177.517 µs → 161 µs (**1.105×**), 96.252 µs →
2181.057 µs (**91×**), 54.472 µs → 1.624 µs (**33,5×**), 49.100 µs → 80 µs
219(**642×**).
220
221**La forma es siempre la misma**, y por eso se reconoce: una función que resuelve
222*una* consulta recorriendo *todo* —partir el texto entero en párrafos, contar
223desde el principio, `position()` barriendo un `Vec`— llamada **dentro de un
224bucle** que recorre las líneas o los elementos. Cada función suelta es correcta y
225barata. El bucle es el que multiplica.
226
227**Y por eso no van a un worker.** Mandar 177 ms de trabajo cuadrático a otro hilo
228no arregla nada: sigue siendo cuadrático, y encima ahora hay que sincronizarlo.
229Lo que corresponde es **hacerlo lineal**: resolver la consulta para todos los
230elementos en una pasada, antes del bucle. Las funciones de a una **se dejan
231intactas**: para una consulta suelta son lo correcto, y sirven de oráculo.
232
233**Cómo se certifica.** Dos tests por arreglo, y ninguno mira una imagen:
234
2351. **Equivalencia** contra la función de a una, que quedó sin tocar. Elemento por
236   elemento, con guarda de arnés sobre cuántos se compararon y un caso que
237   ejercite lo que podría desalinearse.
2382. **Forma, no milisegundos.** No se fija un tiempo —depende de la máquina— sino
239   que el **costo por elemento no crezca con el tamaño**. Medido a dos tallas
240   (×16 de documento): antes crecía ×10 a ×13, después queda entre ×0,7 y ×1,4.
241
242**Qué lo habría encontrado antes.** El vigía, sin dudarlo: los cuatro medidos
243pasan los 16,7 ms del umbral de `view` por uno o dos órdenes de magnitud. El
244auditor estático **no**, y no hay forma de que lo haga. Se probó un barrido
245heurístico («un `.position(`/`.split(` adentro de un `for`, dentro de una función
246de vista»): **16 sitios, 15 falsos positivos**. No se dejó como gate — un linter
247con 94 % de falsos positivos entrena a ignorarlo, que es peor que no tenerlo.
248
249O sea que la defensa contra esta categoría es **correr la app con el vigía
250prendido**, no un script.
251
252## La pregunta que hay que hacerle a cada app
253
254> **¿El costo de `view()` por cuadro depende del tamaño de los datos?**
255
256No «¿cuánto tarda?», sino «¿crece?». Se contesta con **dos tallas** del mismo
257dato (una chica, una 8× más grande), midiendo el **cociente**.
258
259### Las tres trampas, todas pisadas el mismo día
260
2611. **Un techo absoluto se pudre en silencio.** «`view` < medio cuadro» pasaba
262   **mientras el costo crecía con el documento**: seguía verde hasta que alguien
263   abriera uno más grande. Un gate de forma no envejece.
2642. **Árbol acotado ≠ trabajo acotado.** Una app dio **462 nodos fijos** en las dos
265   tallas —la virtualización del editor funciona— y aun así el tiempo crecía: una
266   función recorría el texto entero por cuadro para producir esos 462 nodos.
267   *Contar nodos no contesta la pregunta.*
2683. **Una medición suelta no decide nada.** La misma forma dio ×6,29 (tallas en
269   tandas separadas), ×1,02–1,70 (nueve rondas alternadas) y ×1,03–1,08
270   (veinticinco rondas alternadas). Las dos primeras eran ruido: una exageraba el
271   problema seis veces. **Alternar las dos tallas en el mismo bucle y tomar el
272   mínimo**, nunca medirlas en tandas.
273
274### Definición de terminado, por app
275
276- Un test en el crate que arma la vista real con dos tallas y afirma la forma.
277- Gate puesto **sobre lo medido**, con el número en el comentario.
278- Guarda de arnés: que las dos vistas tengan nodos de verdad (comparar dos
279  árboles vacíos da «plano» sin significar nada).
280- Si crece: desglose por fase impreso, para que quien lea el fallo no repita la
281  investigación.
282
283## El procedimiento, corto
284
285Para cada app que escribas sobre Llimphi:
286
2871. `./scripts/auditar-hilo-ui.py <ruta de la app>` → cero hallazgos sincrónicos.
2882. Usarla con el **vigía de fábrica** (viene prendido) y ejercitar el caso
289   grande, no el de demo: el dato que crece sin techo es donde vive el defecto.
2903. Un test de **forma** de `view` con dos tallas, si el dato de la app crece.
2914. Lo que quede legítimamente sincrónico se marca en el código con
292   `// auditar-hilo-ui: intencional — <por qué>`.

COMPUTO-FUERA-DEL-HILO-UI.md

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)

// 1) Mensaje de resultado: u64 = generación; Arc<T> porque Msg: Clone.
enum Msg { /* … */ XComputed(u64, std::sync::Arc<Resultado>) }

// 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<Resultado>, 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:

Soluciones colaterales de la misma cacería (ya aplicadas, no revertir)

Cómo diagnosticar (sin ptrace; ptrace_scope=1 bloquea gdb a no-hijos)

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:

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:

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:

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

El procedimiento, corto

Para cada app que escribas sobre Llimphi:

  1. ./scripts/auditar-hilo-ui.py <ruta de la app> → 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 — <por qué>.