tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1// eventloop/vigia.rs — el vigía del hilo de UI.
2//
3// `COMPUTO-FUERA-DEL-HILO-UI.md` fija la regla: ningún `update`/`init` puede
4// hacer trabajo pesado sincrónico, porque bloquea el hilo de UI y la ventana
5// deja de repintar, de responder y de cerrarse. Y ese documento declara su
6// propio límite más grande:
7//
8// «⚠️ **No ve cómputo de CPU.** [`auditar-hilo-ui.py`] detecta IO y
9// `block_on`; un bucle que muele números en `update` le pasa por al lado. Y
10// justamente el caso que originó esta regla —las efemérides de cosmos— era
11// eso, no IO.»
12//
13// Por eso nueve apps quedaron en `[~]`: «sin IO bloqueante directo; **falta**
14// revisar cómputo de CPU», esperando una lectura humana del `update` que nadie
15// iba a hacer app por app.
16//
17// Un linter estático no puede cerrar eso: decidir si un bucle es caro es el
18// problema de la parada con sombrero. Pero **medirlo en vivo sí**, y sale casi
19// gratis: el runtime ya pasa por un solo lugar en cada `update`. Si tardó más
20// que el umbral, lo dice. Sin falsos positivos —no estima, cronometra— y sin
21// que las apps toquen una línea.
22//
23// Encendido **por defecto**. Ese es el punto: si hubiera que compilar con una
24// feature o exportar una env, volvería a depender de que alguien se acuerde, y
25// las nueve apps seguirían en `[~]`. El costo de estar prendido es un par
26// `Instant::now()` + `elapsed()` por `update`: **99 ns medidos** en esta
27// laptop. Aun en una tormenta de mouse-move de mil eventos por segundo son
28// 0,1 ms por segundo — una diezmilésima del presupuesto — contra un `update`
29// que en el caso que se busca dura décimas de segundo.
30//
31// `LLIMPHI_UI_SLOW_MS=<ms>` cambia el umbral; `=0` lo apaga.
32//
33// ── El otro agujero: `view` (2026-08-05) ────────────────────────────────────
34//
35// Cronometrar `update` e `init` dejaba afuera la tercera función que la app
36// corre en el hilo de UI, y es la peor de las tres: **`view` corre en CADA
37// cuadro**. `redraw.rs` la llama sin memoizar el resultado a nivel app (los
38// nodos `memo` cachean *subárboles*, no la llamada), así que un `view` que
39// muele no congela la ventana una vez —como un `update` lento— sino para
40// siempre, mientras la ventana esté abierta.
41//
42// El auditor estático tampoco lo ve, y por el mismo motivo de siempre: no ve
43// cómputo de CPU. (Extendido a `view`/`view_overlay` el mismo día, sale en
44// **cero** hallazgos de IO — o sea que lo único que podía haber ahí era
45// justamente lo que un linter no puede ver.)
46//
47// Umbral propio, **más bajo** (un cuadro a 60 Hz) y con su propia env
48// (`LLIMPHI_UI_SLOW_VIEW_MS`), porque el costo tiene otra forma: un `update`
49// lento se come N cuadros y pasa; una `view` lenta es un techo de fps. Por eso
50// el aviso traduce los ms a fps, que es el número accionable.
51//
52// El costo de tenerlo prendido es el mismo par `Instant::now()`/`elapsed()`
53// —99 ns medidos— dos veces por cuadro (view + overlay): 12 µs por segundo a
54// 60 fps.
55//
56// **Verificado punta a punta contra el runtime, no sólo por unit test.**
57// `LLIMPHI_UI_SLOW_VIEW_MS=0.05 stress_idle` (la `view` real mide 0,2-0,3 ms)
58// denuncia por stderr; con el umbral de fábrica, la misma corrida de 12 s no
59// dice **nada**. Y de paso muestra la regla de callarse trabajando bajo
60// presión de cuadro: ~720 cuadros por encima del umbral forzado, **4 líneas**.
61
62use std::cell::RefCell;
63use std::collections::HashMap;
64use std::sync::OnceLock;
65
66use crate::{App, Handle, Instant};
67
68/// Umbral por defecto, en milisegundos.
69///
70/// Un cuadro a 60 Hz son 16,7 ms. Con 100 no se denuncia un `update` que
71/// simplemente se pasó de un cuadro —eso es ruido— sino uno que se comió seis:
72/// ahí ya no es una animación que salta, es la ventana congelada.
73const UMBRAL_MS: f64 = 100.0;
74
75/// Umbral de `view`/`view_overlay`, en milisegundos. **Un cuadro entero a
76/// 60 Hz.**
77///
78/// Es más bajo que el de `update` porque `view` corre en cada cuadro: no hay
79/// un «se comió seis cuadros y siguió», hay un techo de fps. Con 16,7 ms el
80/// aviso dice exactamente «tu `view` sola no deja pasar de 60 fps».
81///
82/// **Y el número sale de una medición, no de la teoría.** `LLIMPHI_PERF=1
83/// stress_idle` (987 nodos, laptop Iris Xe, 2026-08-05) da `view` en
84/// **190-326 µs de media y 727 µs de p95**. O sea que el umbral queda 23×
85/// arriba del peor cuadro de un árbol normal: no puede haber falso positivo
86/// por un pico. Una app con una `view` genuinamente pesada —un chart grande,
87/// una tabla larga— sigue teniendo 3-5× de aire antes de que hable.
88const UMBRAL_VIEW_MS: f64 = 1000.0 / 60.0;
89
90/// Umbral forzado, sólo en tests. La lectura de env está cacheada en un
91/// `OnceLock` (a propósito: una env por proceso), y eso hace que un test no
92/// pueda cambiarla — el primer test que corra fija el valor para todos.
93#[cfg(test)]
94thread_local! {
95 static UMBRAL_TEST: std::cell::Cell<Option<f64>> = const { std::cell::Cell::new(None) };
96}
97
98fn umbral_ms() -> f64 {
99 #[cfg(test)]
100 if let Some(u) = UMBRAL_TEST.with(|c| c.get()) {
101 return u;
102 }
103 static CELL: OnceLock<f64> = OnceLock::new();
104 *CELL.get_or_init(|| match std::env::var("LLIMPHI_UI_SLOW_MS") {
105 Ok(v) if !v.is_empty() => v.parse::<f64>().unwrap_or(UMBRAL_MS),
106 _ => UMBRAL_MS,
107 })
108}
109
110/// Umbral de `view`. `LLIMPHI_UI_SLOW_VIEW_MS=<ms>` lo cambia; `=0` lo apaga.
111///
112/// Env propia y no la de `update`: son dos costos con dos formas distintas
113/// (uno por evento, otro por cuadro) y quien esté cazando uno no quiere que el
114/// otro le llene la terminal.
115fn umbral_view_ms() -> f64 {
116 #[cfg(test)]
117 if let Some(u) = UMBRAL_TEST.with(|c| c.get()) {
118 return u;
119 }
120 static CELL: OnceLock<f64> = OnceLock::new();
121 *CELL.get_or_init(|| match std::env::var("LLIMPHI_UI_SLOW_VIEW_MS") {
122 Ok(v) if !v.is_empty() => v.parse::<f64>().unwrap_or(UMBRAL_VIEW_MS),
123 _ => UMBRAL_VIEW_MS,
124 })
125}
126
127/// Lo peor visto en un sitio de llamada.
128#[derive(Clone, Copy, Debug, Default, PartialEq)]
129struct Peor {
130 veces: u32,
131 ms: f64,
132}
133
134/// Lleva la cuenta de los `update` lentos y decide qué vale la pena decir.
135///
136/// Está separado del resto —y es puro— para poder probarlo sin ventana ni GPU.
137#[derive(Default)]
138pub(crate) struct Vigia {
139 peor: HashMap<String, Peor>,
140}
141
142impl Vigia {
143 /// Registra que `sitio` tardó `ms` y devuelve la línea a imprimir, o `None`
144 /// si no hay nada nuevo que decir.
145 ///
146 /// **Sólo habla cuando aprende algo**: la primera vez que un sitio pasa el
147 /// umbral, y después sólo si empeora. Un `update` lento suele serlo en cada
148 /// evento; escupir una línea por vez llenaría la terminal y el aviso se
149 /// volvería invisible, que es la forma más común en que un aviso deja de
150 /// servir.
151 pub(crate) fn registrar(&mut self, sitio: &str, ms: f64, umbral: f64) -> Option<String> {
152 if umbral <= 0.0 || ms < umbral {
153 return None;
154 }
155 let e = self.peor.entry(sitio.to_string()).or_default();
156 e.veces += 1;
157 if e.veces > 1 && ms <= e.ms {
158 return None;
159 }
160 let primera = e.veces == 1;
161 e.ms = ms;
162 Some(if primera {
163 format!(
164 "llimphi: update bloqueó el hilo de UI {ms:.0} ms ({sitio}) — \
165 el trabajo pesado va a un worker (ver COMPUTO-FUERA-DEL-HILO-UI.md)"
166 )
167 } else {
168 format!(
169 "llimphi: update bloqueó el hilo de UI {ms:.0} ms ({sitio}) — \
170 peor de {} veces en este sitio",
171 e.veces
172 )
173 })
174 }
175
176 /// Lo mismo para `view`/`view_overlay`, con el texto que corresponde.
177 ///
178 /// Comparte el mapa de «lo peor visto» —la clave es `cual`, que nunca
179 /// choca con un `archivo.rs:línea`— y por lo tanto comparte la regla de
180 /// hablar sólo cuando aprende algo. Acá esa regla pesa **mucho** más que en
181 /// `update`: una `view` lenta lo es en cada cuadro, o sea sesenta líneas por
182 /// segundo si el vigía no se callara.
183 pub(crate) fn registrar_vista(&mut self, cual: &str, ms: f64, umbral: f64) -> Option<String> {
184 if umbral <= 0.0 || ms < umbral {
185 return None;
186 }
187 let e = self.peor.entry(cual.to_string()).or_default();
188 e.veces += 1;
189 if e.veces > 1 && ms <= e.ms {
190 return None;
191 }
192 let primera = e.veces == 1;
193 e.ms = ms;
194 // Los fps que ese solo costo impone. Es la traducción que vuelve el
195 // número accionable: «84 ms» no dice nada, «12 fps de techo» sí.
196 let fps = 1000.0 / ms.max(1e-9);
197 Some(if primera {
198 format!(
199 "llimphi: {cual} tardó {ms:.1} ms y corre en CADA cuadro — si la ventana \
200 repinta en continuo, eso es un techo de {fps:.0} fps; el trabajo pesado se \
201 precomputa en `update` o va a un worker (ver COMPUTO-FUERA-DEL-HILO-UI.md)"
202 )
203 } else {
204 format!(
205 "llimphi: {cual} tardó {ms:.1} ms (techo {fps:.0} fps) — peor de {} cuadros",
206 e.veces
207 )
208 })
209 }
210}
211
212thread_local! {
213 /// Vive en el hilo de UI, que es el único que corre `update`.
214 static VIGIA: RefCell<Vigia> = RefCell::new(Vigia::default());
215}
216
217#[cfg(test)]
218thread_local! {
219 /// Último aviso emitido en este hilo. Existe para que un test pueda
220 /// comprobar que el cronómetro y el vigía están de verdad conectados —
221 /// probar el `Vigia` por su cuenta no dice nada sobre si alguien lo llama.
222 static ULTIMO: RefCell<Option<String>> = const { RefCell::new(None) };
223}
224
225fn avisar(sitio: &str, ms: f64, umbral: f64) {
226 if let Some(linea) = VIGIA.with(|v| v.borrow_mut().registrar(sitio, ms, umbral)) {
227 llimphi_hal::avisar!("{linea}");
228 #[cfg(test)]
229 ULTIMO.with(|u| *u.borrow_mut() = Some(linea));
230 }
231}
232
233thread_local! {
234 /// ¿Todavía no se pintó ningún cuadro? Ver `aplicar_view`.
235 static PRIMER_VIEW: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
236}
237
238/// Aviso del **primer** cuadro: mismo número, otra conclusión.
239fn avisar_vista_primera(cual: &str, ms: f64) {
240 let linea = format!(
241 "llimphi: el primer {cual} tardó {ms:.1} ms — incluye cachés frías (fuentes), \
242 así que todavía no dice nada sobre el régimen. Si se repite, se avisa de nuevo"
243 );
244 llimphi_hal::avisar!("{linea}");
245 #[cfg(test)]
246 ULTIMO.with(|u| *u.borrow_mut() = Some(linea));
247}
248
249fn avisar_vista(cual: &str, ms: f64, umbral: f64) {
250 if let Some(linea) = VIGIA.with(|v| v.borrow_mut().registrar_vista(cual, ms, umbral)) {
251 llimphi_hal::avisar!("{linea}");
252 #[cfg(test)]
253 ULTIMO.with(|u| *u.borrow_mut() = Some(linea));
254 }
255}
256
257/// Aplica un `Msg` al modelo, cronometrando.
258///
259/// Es el **único** camino por el que el runtime llama a `App::update` — los 33
260/// sitios de llamada pasan por acá. `#[track_caller]` hace que el aviso nombre
261/// el sitio real (`input.rs:189` = teclado, `mod.rs:349` = canal) sin que haya
262/// que etiquetar los 33 a mano.
263#[track_caller]
264pub(crate) fn aplicar_update<A: App>(
265 model: A::Model,
266 msg: A::Msg,
267 handle: &Handle<A::Msg>,
268) -> A::Model {
269 let umbral = umbral_ms();
270 // Apagado: ni se toca el reloj.
271 if umbral <= 0.0 {
272 return A::update(model, msg, handle);
273 }
274 let t0 = Instant::now();
275 let salida = A::update(model, msg, handle);
276 let ms = t0.elapsed().as_secs_f64() * 1e3;
277 // El camino normal no formatea nada: sólo se arma el `String` del sitio si
278 // de verdad hay algo que decir.
279 if ms >= umbral {
280 let l = core::panic::Location::caller();
281 avisar(&format!("{}:{}", nombre_corto(l.file()), l.line()), ms, umbral);
282 }
283 salida
284}
285
286/// Construye el modelo inicial, cronometrando.
287///
288/// `init` merece su propio aviso y su propio texto: en winit corre dentro de
289/// `resumed`, o sea **después** de crear la ventana, así que un cómputo pesado
290/// ahí congela una ventana que el usuario ya está viendo.
291pub(crate) fn aplicar_init<A: App>(handle: &Handle<A::Msg>) -> A::Model {
292 let t0 = Instant::now();
293 let modelo = crate::dormir::init_o_despertar::<A>(handle);
294 crate::dormir::escuchar::<A>(handle);
295 let ms = t0.elapsed().as_secs_f64() * 1e3;
296 let umbral = umbral_ms();
297 if umbral > 0.0 && ms >= umbral {
298 llimphi_hal::avisar!(
299 "llimphi: init tardó {ms:.0} ms antes del primer cuadro — corre DENTRO de \
300 `resumed`, con la ventana ya creada, así que eso es la ventana congelada \
301 (ver COMPUTO-FUERA-DEL-HILO-UI.md)"
302 );
303 }
304 modelo
305}
306
307/// Construye el árbol de la app, cronometrando.
308///
309/// Es el **único** camino por el que el runtime llama a `App::view`: las dos
310/// llamadas viven en `helpers::resolve_layout_builders` (la segunda sólo
311/// cuando el árbol tiene `layout_builder`, y también se cronometra — es trabajo
312/// de la app en el mismo cuadro).
313///
314/// Acá no hace falta `#[track_caller]`: a diferencia de `update`, que llega por
315/// 33 caminos distintos y el sitio dice cuál, `view` es una sola función de una
316/// sola app por proceso. Nombrarla alcanza.
317pub(crate) fn aplicar_view<A: App>(model: &A::Model) -> crate::View<A::Msg> {
318 let umbral = umbral_view_ms();
319 if umbral <= 0.0 {
320 return A::view(model);
321 }
322 let t0 = Instant::now();
323 let vista = A::view(model);
324 let ms = t0.elapsed().as_secs_f64() * 1e3;
325 if ms >= umbral {
326 // **El primer cuadro no es el régimen**, y decirlo importa. Paga
327 // cachés frías —las fuentes sobre todo— y encima muchas apps repintan
328 // a demanda: una que quedó ociosa después del arranque no tiene ningún
329 // «techo de fps», tiene un arranque lento. Confundir las dos cosas
330 // manda a optimizar lo que no es.
331 //
332 // Lo encontró el propio vigía usándose (2026-08-06): en `chaka` avisó
333 // «116 ms, techo de 9 fps» y la app estaba ociosa — nunca llegó a 60
334 // cuadros en dieciocho segundos. El aviso era cierto en el número y
335 // falso en la conclusión.
336 if PRIMER_VIEW.with(|c| c.replace(false)) {
337 avisar_vista_primera("view()", ms);
338 } else {
339 avisar_vista("view()", ms, umbral);
340 }
341 }
342 vista
343}
344
345/// Ídem para el overlay, que corre en el mismo cuadro y en el mismo hilo.
346///
347/// Lleva cuenta aparte porque son dos funciones que la app escribe por
348/// separado: si la lenta es una, decirlo por el nombre de la otra manda a
349/// mirar el archivo equivocado.
350pub(crate) fn aplicar_view_overlay<A: App>(model: &A::Model) -> Option<crate::View<A::Msg>> {
351 let umbral = umbral_view_ms();
352 if umbral <= 0.0 {
353 return A::view_overlay(model);
354 }
355 let t0 = Instant::now();
356 let vista = A::view_overlay(model);
357 let ms = t0.elapsed().as_secs_f64() * 1e3;
358 if ms >= umbral {
359 avisar_vista("view_overlay()", ms, umbral);
360 }
361 vista
362}
363
364/// `src/eventloop/input.rs` en vez de la ruta absoluta del build.
365fn nombre_corto(ruta: &str) -> &str {
366 match ruta.rfind("llimphi-ui/") {
367 Some(i) => &ruta[i + "llimphi-ui/".len()..],
368 None => ruta,
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn lo_que_no_pasa_el_umbral_no_se_menciona() {
378 let mut v = Vigia::default();
379 assert_eq!(v.registrar("input.rs:1", 99.0, 100.0), None);
380 assert_eq!(v.registrar("input.rs:1", 0.02, 100.0), None);
381 // Umbral 0 = apagado, aunque el update haya tardado un minuto.
382 assert_eq!(v.registrar("input.rs:1", 60_000.0, 0.0), None);
383 }
384
385 #[test]
386 fn avisa_la_primera_vez_y_despues_solo_si_empeora() {
387 // Un update lento suele serlo en CADA evento. Si el vigía hablara cada
388 // vez, llenaría la terminal y el aviso se volvería invisible — que es
389 // la forma más común en que un aviso deja de servir.
390 let mut v = Vigia::default();
391 let primera = v.registrar("input.rs:189", 340.0, 100.0).expect("la primera avisa");
392 assert!(primera.contains("340 ms") && primera.contains("input.rs:189"));
393
394 assert_eq!(v.registrar("input.rs:189", 200.0, 100.0), None, "menos que lo peor: calla");
395 assert_eq!(v.registrar("input.rs:189", 340.0, 100.0), None, "empatar no es empeorar");
396
397 let peor = v.registrar("input.rs:189", 900.0, 100.0).expect("empeoró: avisa");
398 assert!(peor.contains("900 ms"), "{peor}");
399 assert!(peor.contains("peor de 4"), "tiene que contar todas las veces: {peor}");
400 }
401
402 #[test]
403 fn cada_sitio_lleva_su_propia_cuenta() {
404 // El teclado y el canal de ticks son caminos distintos; que uno ya haya
405 // avisado no puede tapar al otro.
406 let mut v = Vigia::default();
407 assert!(v.registrar("input.rs:189", 300.0, 100.0).is_some());
408 let otro = v.registrar("mod.rs:349", 150.0, 100.0);
409 assert!(otro.is_some(), "otro sitio, aviso propio");
410 assert!(otro.unwrap().contains("mod.rs:349"));
411 }
412
413 #[test]
414 fn el_sitio_sale_relativo_al_crate() {
415 // La ruta absoluta del build no le dice nada a nadie.
416 assert_eq!(
417 nombre_corto("/home/quien/tawasuyu/02_ruway/llimphi/llimphi-ui/src/eventloop/input.rs"),
418 "src/eventloop/input.rs"
419 );
420 assert_eq!(nombre_corto("otro/lado.rs"), "otro/lado.rs");
421 }
422
423 // ── El camino real, con una App de mentira ───────────────────────────
424
425 struct Lenta;
426
427 impl App for Lenta {
428 type Model = u32;
429 type Msg = u32;
430 fn init(_h: &Handle<Self::Msg>) -> Self::Model {
431 0
432 }
433 fn update(m: Self::Model, msg: Self::Msg, _h: &Handle<Self::Msg>) -> Self::Model {
434 // Muele: es justo el caso que el auditor estático no ve.
435 let mut acc = m;
436 for i in 0..msg {
437 acc = acc.wrapping_add(i).rotate_left(1);
438 }
439 acc
440 }
441 // Muele **en proporción al modelo**, igual que `update`. Así el mismo
442 // tipo sirve para las dos mitades del vigía y un test puede pedir una
443 // `view` cara (`&200_000`) o una barata (`&0`) sin otra App.
444 fn view(m: &Self::Model) -> crate::View<Self::Msg> {
445 let mut acc = 0u32;
446 for i in 0..*m {
447 acc = acc.wrapping_add(i).rotate_left(1);
448 }
449 // `black_box` para que el optimizador no borre el bucle entero
450 // —es un cómputo sin efecto— y deje al test midiendo la nada.
451 core::hint::black_box(acc);
452 crate::View::new(Default::default())
453 }
454
455 fn view_overlay(m: &Self::Model) -> Option<crate::View<Self::Msg>> {
456 let mut acc = 0u32;
457 for i in 0..*m {
458 acc = acc.wrapping_add(i).rotate_left(3);
459 }
460 core::hint::black_box(acc);
461 Some(crate::View::new(Default::default()))
462 }
463 }
464
465 #[test]
466 fn el_envoltorio_devuelve_el_modelo_que_devolvio_la_app() {
467 // Lo primero que no puede romper: cronometrar no puede cambiar lo que
468 // la app calcula.
469 let h = Handle::<u32>::for_test_with(|_| {});
470 assert_eq!(aplicar_update::<Lenta>(7, 0, &h), 7);
471 assert_eq!(aplicar_update::<Lenta>(0, 3, &h), Lenta::update(0, 3, &h));
472 assert_eq!(aplicar_init::<Lenta>(&h), 0);
473 }
474
475 #[test]
476 fn un_update_que_muele_dispara_el_aviso_y_nombra_el_sitio() {
477 // El test que cierra el agujero de verdad: acá NO hay IO ni `block_on`
478 // —lo único que el auditor estático sabe ver— sino un bucle que muele.
479 // Eso es exactamente lo que las efemérides de cosmos le hacían al hilo
480 // de UI, y lo que dejó nueve apps en `[~]`.
481 UMBRAL_TEST.with(|c| c.set(Some(0.000_1)));
482 let h = Handle::<u32>::for_test_with(|_| {});
483
484 aplicar_update::<Lenta>(0, 200_000, &h);
485 let aviso = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
486 assert!(aviso.contains("bloqueó el hilo de UI"), "{aviso}");
487 // `#[track_caller]` tiene que nombrar el sitio de llamada —esta línea—,
488 // no el interior del vigía: es lo que permite distinguir el camino del
489 // teclado del de los ticks sin etiquetar 33 sitios a mano.
490 assert!(aviso.contains("src/eventloop/vigia.rs"), "no nombró el sitio: {aviso}");
491
492 // Control negativo: con un umbral que no se alcanza, ni una palabra.
493 UMBRAL_TEST.with(|c| c.set(Some(60_000.0)));
494 ULTIMO.with(|u| *u.borrow_mut() = None);
495 aplicar_update::<Lenta>(0, 200_000, &h);
496 assert_eq!(ULTIMO.with(|u| u.borrow().clone()), None, "un update sano no habla");
497 }
498
499 // ── La mitad de `view` ───────────────────────────────────────────────
500
501 #[test]
502 fn una_view_que_muele_se_denuncia_como_techo_de_fps() {
503 // La diferencia con `update` no es de grado: un `update` lento congela
504 // la ventana **una vez**; una `view` lenta le pone un techo de fps
505 // mientras esté abierta. El aviso tiene que decir eso, porque es lo que
506 // manda a mirar el lugar correcto.
507 UMBRAL_TEST.with(|c| c.set(Some(0.000_1)));
508 ULTIMO.with(|u| *u.borrow_mut() = None);
509
510 // **El primer cuadro tiene su propio texto y su propia conclusión.**
511 // Paga cachés frías, y muchas apps repintan a demanda: una que quedó
512 // ociosa después del arranque no tiene techo de fps, tiene un arranque
513 // lento. Lo encontró el propio vigía en `chaka` (2026-08-06): avisó
514 // «116 ms, techo de 9 fps» y la app nunca llegó a 60 cuadros en
515 // dieciocho segundos.
516 aplicar_view::<Lenta>(&200_000);
517 let primero = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
518 assert!(primero.contains("primer"), "no distinguió el primer cuadro: {primero}");
519 assert!(
520 !primero.contains("techo"),
521 "el primer cuadro no autoriza a hablar de techo de fps: {primero}"
522 );
523
524 // Y del segundo en adelante sí: ahí ya es régimen.
525 ULTIMO.with(|u| *u.borrow_mut() = None);
526 aplicar_view::<Lenta>(&200_000);
527 let aviso = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
528 assert!(aviso.contains("view()"), "no nombró la función: {aviso}");
529 assert!(aviso.contains("CADA cuadro"), "{aviso}");
530 assert!(aviso.contains("fps"), "el número accionable es el techo de fps: {aviso}");
531
532 // Control negativo: con un umbral que no se alcanza, ni una palabra.
533 UMBRAL_TEST.with(|c| c.set(Some(60_000.0)));
534 ULTIMO.with(|u| *u.borrow_mut() = None);
535 aplicar_view::<Lenta>(&200_000);
536 assert_eq!(ULTIMO.with(|u| u.borrow().clone()), None, "una view sana no habla");
537 }
538
539 #[test]
540 fn la_view_y_el_overlay_llevan_cuenta_separada() {
541 // Son dos funciones que la app escribe por separado. Si compartieran
542 // cuenta, la primera en avisar taparía a la otra y el aviso mandaría a
543 // leer el archivo equivocado.
544 let mut v = Vigia::default();
545 assert!(v.registrar_vista("view()", 40.0, 16.7).is_some());
546 let otro = v.registrar_vista("view_overlay()", 20.0, 16.7);
547 assert!(otro.is_some(), "el overlay tiene su propio aviso aunque tarde menos");
548 assert!(otro.unwrap().contains("view_overlay()"));
549
550 // Y la misma regla de callarse: sesenta cuadros por segundo son sesenta
551 // líneas por segundo si el vigía no se contuviera.
552 assert_eq!(v.registrar_vista("view()", 30.0, 16.7), None, "menos que lo peor: calla");
553 let peor = v.registrar_vista("view()", 90.0, 16.7).expect("empeoró: avisa");
554 assert!(peor.contains("peor de 3 cuadros"), "{peor}");
555 }
556
557 #[test]
558 fn el_umbral_de_view_deja_pasar_un_arbol_normal() {
559 // El umbral no se eligió por teoría: `LLIMPHI_PERF=1 stress_idle` mide
560 // `view` en 190-326 µs de media y **727 µs de p95** sobre 987 nodos. Si
561 // alguien bajara `UMBRAL_VIEW_MS` cerca de eso, el vigía empezaría a
562 // denunciar cuadros sanos y —por la regla de arriba— el aviso de verdad
563 // quedaría enterrado. Esto fija el margen medido: 20× sobre el p95.
564 const P95_MEDIDO_MS: f64 = 0.727;
565 assert!(
566 UMBRAL_VIEW_MS >= P95_MEDIDO_MS * 20.0,
567 "el umbral de view ({UMBRAL_VIEW_MS} ms) quedó a menos de 20× del peor \
568 cuadro medido de un árbol normal ({P95_MEDIDO_MS} ms)"
569 );
570 // Y por el otro lado: si subiera de un cuadro a 60 Hz dejaría de
571 // significar «esto solo ya no deja llegar a 60 fps».
572 assert!(UMBRAL_VIEW_MS <= 1000.0 / 60.0);
573 }
574}