tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-ui/src/eventloop/vigia.rs Rust · 573 lines · 24.5 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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        eprintln!("{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    eprintln!("{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        eprintln!("{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 = A::init(handle);
294    let ms = t0.elapsed().as_secs_f64() * 1e3;
295    let umbral = umbral_ms();
296    if umbral > 0.0 && ms >= umbral {
297        eprintln!(
298            "llimphi: init tardó {ms:.0} ms antes del primer cuadro — corre DENTRO de \
299             `resumed`, con la ventana ya creada, así que eso es la ventana congelada \
300             (ver COMPUTO-FUERA-DEL-HILO-UI.md)"
301        );
302    }
303    modelo
304}
305
306/// Construye el árbol de la app, cronometrando.
307///
308/// Es el **único** camino por el que el runtime llama a `App::view`: las dos
309/// llamadas viven en `helpers::resolve_layout_builders` (la segunda sólo
310/// cuando el árbol tiene `layout_builder`, y también se cronometra — es trabajo
311/// de la app en el mismo cuadro).
312///
313/// Acá no hace falta `#[track_caller]`: a diferencia de `update`, que llega por
314/// 33 caminos distintos y el sitio dice cuál, `view` es una sola función de una
315/// sola app por proceso. Nombrarla alcanza.
316pub(crate) fn aplicar_view<A: App>(model: &A::Model) -> crate::View<A::Msg> {
317    let umbral = umbral_view_ms();
318    if umbral <= 0.0 {
319        return A::view(model);
320    }
321    let t0 = Instant::now();
322    let vista = A::view(model);
323    let ms = t0.elapsed().as_secs_f64() * 1e3;
324    if ms >= umbral {
325        // **El primer cuadro no es el régimen**, y decirlo importa. Paga
326        // cachés frías —las fuentes sobre todo— y encima muchas apps repintan
327        // a demanda: una que quedó ociosa después del arranque no tiene ningún
328        // «techo de fps», tiene un arranque lento. Confundir las dos cosas
329        // manda a optimizar lo que no es.
330        //
331        // Lo encontró el propio vigía usándose (2026-08-06): en `chaka` avisó
332        // «116 ms, techo de 9 fps» y la app estaba ociosa — nunca llegó a 60
333        // cuadros en dieciocho segundos. El aviso era cierto en el número y
334        // falso en la conclusión.
335        if PRIMER_VIEW.with(|c| c.replace(false)) {
336            avisar_vista_primera("view()", ms);
337        } else {
338            avisar_vista("view()", ms, umbral);
339        }
340    }
341    vista
342}
343
344/// Ídem para el overlay, que corre en el mismo cuadro y en el mismo hilo.
345///
346/// Lleva cuenta aparte porque son dos funciones que la app escribe por
347/// separado: si la lenta es una, decirlo por el nombre de la otra manda a
348/// mirar el archivo equivocado.
349pub(crate) fn aplicar_view_overlay<A: App>(model: &A::Model) -> Option<crate::View<A::Msg>> {
350    let umbral = umbral_view_ms();
351    if umbral <= 0.0 {
352        return A::view_overlay(model);
353    }
354    let t0 = Instant::now();
355    let vista = A::view_overlay(model);
356    let ms = t0.elapsed().as_secs_f64() * 1e3;
357    if ms >= umbral {
358        avisar_vista("view_overlay()", ms, umbral);
359    }
360    vista
361}
362
363/// `src/eventloop/input.rs` en vez de la ruta absoluta del build.
364fn nombre_corto(ruta: &str) -> &str {
365    match ruta.rfind("llimphi-ui/") {
366        Some(i) => &ruta[i + "llimphi-ui/".len()..],
367        None => ruta,
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn lo_que_no_pasa_el_umbral_no_se_menciona() {
377        let mut v = Vigia::default();
378        assert_eq!(v.registrar("input.rs:1", 99.0, 100.0), None);
379        assert_eq!(v.registrar("input.rs:1", 0.02, 100.0), None);
380        // Umbral 0 = apagado, aunque el update haya tardado un minuto.
381        assert_eq!(v.registrar("input.rs:1", 60_000.0, 0.0), None);
382    }
383
384    #[test]
385    fn avisa_la_primera_vez_y_despues_solo_si_empeora() {
386        // Un update lento suele serlo en CADA evento. Si el vigía hablara cada
387        // vez, llenaría la terminal y el aviso se volvería invisible — que es
388        // la forma más común en que un aviso deja de servir.
389        let mut v = Vigia::default();
390        let primera = v.registrar("input.rs:189", 340.0, 100.0).expect("la primera avisa");
391        assert!(primera.contains("340 ms") && primera.contains("input.rs:189"));
392
393        assert_eq!(v.registrar("input.rs:189", 200.0, 100.0), None, "menos que lo peor: calla");
394        assert_eq!(v.registrar("input.rs:189", 340.0, 100.0), None, "empatar no es empeorar");
395
396        let peor = v.registrar("input.rs:189", 900.0, 100.0).expect("empeoró: avisa");
397        assert!(peor.contains("900 ms"), "{peor}");
398        assert!(peor.contains("peor de 4"), "tiene que contar todas las veces: {peor}");
399    }
400
401    #[test]
402    fn cada_sitio_lleva_su_propia_cuenta() {
403        // El teclado y el canal de ticks son caminos distintos; que uno ya haya
404        // avisado no puede tapar al otro.
405        let mut v = Vigia::default();
406        assert!(v.registrar("input.rs:189", 300.0, 100.0).is_some());
407        let otro = v.registrar("mod.rs:349", 150.0, 100.0);
408        assert!(otro.is_some(), "otro sitio, aviso propio");
409        assert!(otro.unwrap().contains("mod.rs:349"));
410    }
411
412    #[test]
413    fn el_sitio_sale_relativo_al_crate() {
414        // La ruta absoluta del build no le dice nada a nadie.
415        assert_eq!(
416            nombre_corto("/home/quien/tawasuyu/02_ruway/llimphi/llimphi-ui/src/eventloop/input.rs"),
417            "src/eventloop/input.rs"
418        );
419        assert_eq!(nombre_corto("otro/lado.rs"), "otro/lado.rs");
420    }
421
422    // ── El camino real, con una App de mentira ───────────────────────────
423
424    struct Lenta;
425
426    impl App for Lenta {
427        type Model = u32;
428        type Msg = u32;
429        fn init(_h: &Handle<Self::Msg>) -> Self::Model {
430            0
431        }
432        fn update(m: Self::Model, msg: Self::Msg, _h: &Handle<Self::Msg>) -> Self::Model {
433            // Muele: es justo el caso que el auditor estático no ve.
434            let mut acc = m;
435            for i in 0..msg {
436                acc = acc.wrapping_add(i).rotate_left(1);
437            }
438            acc
439        }
440        // Muele **en proporción al modelo**, igual que `update`. Así el mismo
441        // tipo sirve para las dos mitades del vigía y un test puede pedir una
442        // `view` cara (`&200_000`) o una barata (`&0`) sin otra App.
443        fn view(m: &Self::Model) -> crate::View<Self::Msg> {
444            let mut acc = 0u32;
445            for i in 0..*m {
446                acc = acc.wrapping_add(i).rotate_left(1);
447            }
448            // `black_box` para que el optimizador no borre el bucle entero
449            // —es un cómputo sin efecto— y deje al test midiendo la nada.
450            core::hint::black_box(acc);
451            crate::View::new(Default::default())
452        }
453
454        fn view_overlay(m: &Self::Model) -> Option<crate::View<Self::Msg>> {
455            let mut acc = 0u32;
456            for i in 0..*m {
457                acc = acc.wrapping_add(i).rotate_left(3);
458            }
459            core::hint::black_box(acc);
460            Some(crate::View::new(Default::default()))
461        }
462    }
463
464    #[test]
465    fn el_envoltorio_devuelve_el_modelo_que_devolvio_la_app() {
466        // Lo primero que no puede romper: cronometrar no puede cambiar lo que
467        // la app calcula.
468        let h = Handle::<u32>::for_test_with(|_| {});
469        assert_eq!(aplicar_update::<Lenta>(7, 0, &h), 7);
470        assert_eq!(aplicar_update::<Lenta>(0, 3, &h), Lenta::update(0, 3, &h));
471        assert_eq!(aplicar_init::<Lenta>(&h), 0);
472    }
473
474    #[test]
475    fn un_update_que_muele_dispara_el_aviso_y_nombra_el_sitio() {
476        // El test que cierra el agujero de verdad: acá NO hay IO ni `block_on`
477        // —lo único que el auditor estático sabe ver— sino un bucle que muele.
478        // Eso es exactamente lo que las efemérides de cosmos le hacían al hilo
479        // de UI, y lo que dejó nueve apps en `[~]`.
480        UMBRAL_TEST.with(|c| c.set(Some(0.000_1)));
481        let h = Handle::<u32>::for_test_with(|_| {});
482
483        aplicar_update::<Lenta>(0, 200_000, &h);
484        let aviso = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
485        assert!(aviso.contains("bloqueó el hilo de UI"), "{aviso}");
486        // `#[track_caller]` tiene que nombrar el sitio de llamada —esta línea—,
487        // no el interior del vigía: es lo que permite distinguir el camino del
488        // teclado del de los ticks sin etiquetar 33 sitios a mano.
489        assert!(aviso.contains("src/eventloop/vigia.rs"), "no nombró el sitio: {aviso}");
490
491        // Control negativo: con un umbral que no se alcanza, ni una palabra.
492        UMBRAL_TEST.with(|c| c.set(Some(60_000.0)));
493        ULTIMO.with(|u| *u.borrow_mut() = None);
494        aplicar_update::<Lenta>(0, 200_000, &h);
495        assert_eq!(ULTIMO.with(|u| u.borrow().clone()), None, "un update sano no habla");
496    }
497
498    // ── La mitad de `view` ───────────────────────────────────────────────
499
500    #[test]
501    fn una_view_que_muele_se_denuncia_como_techo_de_fps() {
502        // La diferencia con `update` no es de grado: un `update` lento congela
503        // la ventana **una vez**; una `view` lenta le pone un techo de fps
504        // mientras esté abierta. El aviso tiene que decir eso, porque es lo que
505        // manda a mirar el lugar correcto.
506        UMBRAL_TEST.with(|c| c.set(Some(0.000_1)));
507        ULTIMO.with(|u| *u.borrow_mut() = None);
508
509        // **El primer cuadro tiene su propio texto y su propia conclusión.**
510        // Paga cachés frías, y muchas apps repintan a demanda: una que quedó
511        // ociosa después del arranque no tiene techo de fps, tiene un arranque
512        // lento. Lo encontró el propio vigía en `chaka` (2026-08-06): avisó
513        // «116 ms, techo de 9 fps» y la app nunca llegó a 60 cuadros en
514        // dieciocho segundos.
515        aplicar_view::<Lenta>(&200_000);
516        let primero = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
517        assert!(primero.contains("primer"), "no distinguió el primer cuadro: {primero}");
518        assert!(
519            !primero.contains("techo"),
520            "el primer cuadro no autoriza a hablar de techo de fps: {primero}"
521        );
522
523        // Y del segundo en adelante sí: ahí ya es régimen.
524        ULTIMO.with(|u| *u.borrow_mut() = None);
525        aplicar_view::<Lenta>(&200_000);
526        let aviso = ULTIMO.with(|u| u.borrow().clone()).expect("tenía que avisar");
527        assert!(aviso.contains("view()"), "no nombró la función: {aviso}");
528        assert!(aviso.contains("CADA cuadro"), "{aviso}");
529        assert!(aviso.contains("fps"), "el número accionable es el techo de fps: {aviso}");
530
531        // Control negativo: con un umbral que no se alcanza, ni una palabra.
532        UMBRAL_TEST.with(|c| c.set(Some(60_000.0)));
533        ULTIMO.with(|u| *u.borrow_mut() = None);
534        aplicar_view::<Lenta>(&200_000);
535        assert_eq!(ULTIMO.with(|u| u.borrow().clone()), None, "una view sana no habla");
536    }
537
538    #[test]
539    fn la_view_y_el_overlay_llevan_cuenta_separada() {
540        // Son dos funciones que la app escribe por separado. Si compartieran
541        // cuenta, la primera en avisar taparía a la otra y el aviso mandaría a
542        // leer el archivo equivocado.
543        let mut v = Vigia::default();
544        assert!(v.registrar_vista("view()", 40.0, 16.7).is_some());
545        let otro = v.registrar_vista("view_overlay()", 20.0, 16.7);
546        assert!(otro.is_some(), "el overlay tiene su propio aviso aunque tarde menos");
547        assert!(otro.unwrap().contains("view_overlay()"));
548
549        // Y la misma regla de callarse: sesenta cuadros por segundo son sesenta
550        // líneas por segundo si el vigía no se contuviera.
551        assert_eq!(v.registrar_vista("view()", 30.0, 16.7), None, "menos que lo peor: calla");
552        let peor = v.registrar_vista("view()", 90.0, 16.7).expect("empeoró: avisa");
553        assert!(peor.contains("peor de 3 cuadros"), "{peor}");
554    }
555
556    #[test]
557    fn el_umbral_de_view_deja_pasar_un_arbol_normal() {
558        // El umbral no se eligió por teoría: `LLIMPHI_PERF=1 stress_idle` mide
559        // `view` en 190-326 µs de media y **727 µs de p95** sobre 987 nodos. Si
560        // alguien bajara `UMBRAL_VIEW_MS` cerca de eso, el vigía empezaría a
561        // denunciar cuadros sanos y —por la regla de arriba— el aviso de verdad
562        // quedaría enterrado. Esto fija el margen medido: 20× sobre el p95.
563        const P95_MEDIDO_MS: f64 = 0.727;
564        assert!(
565            UMBRAL_VIEW_MS >= P95_MEDIDO_MS * 20.0,
566            "el umbral de view ({UMBRAL_VIEW_MS} ms) quedó a menos de 20× del peor \
567             cuadro medido de un árbol normal ({P95_MEDIDO_MS} ms)"
568        );
569        // Y por el otro lado: si subiera de un cuadro a 60 Hz dejaría de
570        // significar «esto solo ya no deja llegar a 60 fps».
571        assert!(UMBRAL_VIEW_MS <= 1000.0 / 60.0);
572    }
573}