tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-cable/src/vida.rs Rust · 401 siq'ikuna · 16.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! **La vida del huésped: arrancarlo, verlo morir, y decir qué pasó.**
2//!
3//! El cable de al lado mueve bytes. Esto decide cuándo hay alguien del otro
4//! lado, qué hacer cuando deja de haberlo, y —sobre todo— **qué se le muestra al
5//! operador mientras tanto**.
6//!
7//! ## La regla que ordena todo el módulo
8//!
9//! Un huésped muerto que deja su último cuadro pegado es la peor versión de
10//! esto: pinta la interfaz viva de un proceso que no existe, y el operador le
11//! hace clic. Así que la muerte **borra el cuadro** y **se dice con palabras**.
12//! Un tablado vacío tiene que decir que está vacío y por qué; si no, «todavía no
13//! arrancó», «se murió hace un segundo» y «me rendí hace diez minutos» son el
14//! mismo rectángulo gris.
15//!
16//! Por eso [`Estado`] no es un `bool` y [`Motivo`] no es un `String` suelto: son
17//! las cuatro cosas distintas que le pueden pasar a un huésped, cada una con lo
18//! que hay que hacer al respecto.
19//!
20//! ## Los reintentos, y las dos formas de equivocarse
21//!
22//! Reintentar sin freno convierte un huésped que no arranca en un bucle que se
23//! come la máquina, y el operador ve un parpadeo eterno sin ningún mensaje.
24//! **No** reintentar convierte un hipo en una app muerta.
25//!
26//! [`Reintentos`] es el medio, con dos detalles que se pagan caro si salen al
27//! revés:
28//!
29//! - **Se rinde, y lo dice.** Un tope callado se lee como «sigue intentando».
30//!   [`Estado::Rendido`] lleva cuántas veces y por qué.
31//! - **Un huésped que vivió se perdona.** Sin eso, uno que trabajó una hora y se
32//!   cayó una vez arrancaría con la espera del último desastre — o peor, ya
33//!   rendido. La cuenta se reinicia cuando estuvo vivo más que
34//!   [`Reintentos::umbral_perdon`]; abajo de eso no es un hipo, es un bucle.
35//!
36//! ## Por qué el caño va por un socket con nombre y no por un fd heredado
37//!
38//! Pasarle el socket al hijo por un descriptor fijo (`dup2` en el `pre_exec`)
39//! es más elegante y este repo ya lo hace en `shared/foreign-av`… y ya se cortó
40//! con eso: `dup2(fd, N)` con `fd == N` es un no-op, y el `close` que viene
41//! después mata el descriptor recién instalado. Se dispara sólo cuando el
42//! proceso tiene pocos fds abiertos, así que anda en la app real y falla en el
43//! caso chico. Un socket con nombre no tiene esa clase de trampa y no pide una
44//! sola línea de `unsafe` de ninguno de los dos lados.
45
46use core::time::Duration;
47
48/// Por qué se terminó la vida de un huésped.
49///
50/// Son cuatro y no un `String` porque llevan a arreglos distintos: uno que no
51/// arranca es un problema de instalación, uno que sale con código es un problema
52/// de la app, uno que corta el caño se murió sin avisar, y uno que manda
53/// basura es una incompatibilidad de versión.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Motivo {
56    /// No se pudo lanzar el proceso (no está el binario, sin permiso).
57    NoArranco(String),
58    /// Salió por su cuenta. `None` = lo mató una señal.
59    Salio(Option<i32>),
60    /// El caño se cerró sin que el proceso avisara nada.
61    CortoElCano,
62    /// Llegó algo que no es del protocolo: marco imposible, postcard inválido.
63    /// Casi siempre es un huésped compilado contra otra versión del IR.
64    Protocolo(String),
65}
66
67impl Motivo {
68    /// La frase que se le muestra al operador. Corta, y **sin nombres de
69    /// código**: la lee alguien que quiere saber si tiene que hacer algo.
70    pub fn frase(&self) -> String {
71        match self {
72            Motivo::NoArranco(e) => format!("no se pudo abrir: {e}"),
73            Motivo::Salio(Some(0)) => "se cerró solo".to_string(),
74            Motivo::Salio(Some(c)) => format!("terminó con error ({c})"),
75            Motivo::Salio(None) => "lo interrumpieron".to_string(),
76            Motivo::CortoElCano => "se cortó sin avisar".to_string(),
77            Motivo::Protocolo(e) => format!("habla otro idioma: {e}"),
78        }
79    }
80}
81
82/// En qué anda el huésped. Es lo que hay que **pintar**.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum Estado {
85    /// Se lanzó y todavía no mandó su primer cuadro.
86    Arrancando,
87    /// Mandó al menos un cuadro y el caño sigue abierto.
88    Vivo,
89    /// Se murió y se va a reintentar. `en` es cuánto falta para el próximo
90    /// intento — se muestra para que la espera no parezca un cuelgue.
91    Murio { motivo: Motivo, intentos: u32, en: Duration },
92    /// Se dejó de reintentar. **Dice cuántas veces y por qué**: un tope callado
93    /// se lee como «sigue intentando».
94    Rendido { motivo: Motivo, intentos: u32 },
95}
96
97impl Estado {
98    /// ¿Hay alguien del otro lado ahora mismo?
99    pub fn vivo(&self) -> bool {
100        matches!(self, Estado::Vivo | Estado::Arrancando)
101    }
102
103    /// ¿Se acabó, para siempre? Distinto de [`Estado::Murio`], que va a volver.
104    pub fn rendido(&self) -> bool {
105        matches!(self, Estado::Rendido { .. })
106    }
107
108    /// La frase para el operador. Es la razón por la que este enum existe:
109    /// sin ella, los cuatro estados son el mismo rectángulo vacío.
110    pub fn frase(&self) -> String {
111        match self {
112            Estado::Arrancando => "abriendo…".to_string(),
113            Estado::Vivo => String::new(),
114            Estado::Murio { motivo, intentos, en } => format!(
115                "{} · reintento {} en {} s",
116                motivo.frase(),
117                intentos + 1,
118                en.as_secs().max(1)
119            ),
120            Estado::Rendido { motivo, intentos } => {
121                format!("{} · me rendí después de {intentos} intentos", motivo.frase())
122            }
123        }
124    }
125}
126
127/// La política de reintentos: espera creciente, tope, y perdón.
128///
129/// Todo el tiempo entra **por argumento**: este módulo no tiene reloj, igual que
130/// el resto de la familia. Así se puede probar el año de reintentos en un test
131/// que corre en microsegundos.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Reintentos {
134    intentos: u32,
135    /// Cuántos se toleran antes de rendirse.
136    pub tope: u32,
137    /// La primera espera. Después se duplica.
138    pub base: Duration,
139    /// La espera no crece más allá de esto.
140    pub techo: Duration,
141    /// Vivir más que esto perdona la cuenta. Ver la doc del módulo.
142    pub umbral_perdon: Duration,
143}
144
145impl Default for Reintentos {
146    /// Valores para una app de escritorio: seis intentos, de 1 s a 30 s, y se
147    /// perdona al que vivió más de medio minuto.
148    fn default() -> Self {
149        Self {
150            intentos: 0,
151            tope: 6,
152            base: Duration::from_secs(1),
153            techo: Duration::from_secs(30),
154            umbral_perdon: Duration::from_secs(30),
155        }
156    }
157}
158
159impl Reintentos {
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Cuántos van.
165    pub fn intentos(&self) -> u32 {
166        self.intentos
167    }
168
169    /// Cuánto esperar antes del próximo intento: `base * 2^n`, con techo.
170    ///
171    /// La saturación importa: sin ella, el intento 64 desborda el corrimiento y
172    /// la espera vuelve a ser cero — un bucle de reintentos disfrazado de
173    /// backoff, que es peor que no tener backoff porque nadie lo busca ahí.
174    pub fn espera(&self) -> Duration {
175        let factor = 1u64.checked_shl(self.intentos).unwrap_or(u64::MAX);
176        let ms = self.base.as_millis().saturating_mul(factor as u128);
177        let ms = ms.min(self.techo.as_millis()) as u64;
178        Duration::from_millis(ms)
179    }
180
181    /// Anota una muerte. `vivio` es cuánto duró **este** huésped.
182    ///
183    /// Devuelve el [`Estado`] que corresponde: `Murio` si queda crédito,
184    /// `Rendido` si se acabó.
185    pub fn murio(&mut self, motivo: Motivo, vivio: Duration) -> Estado {
186        // El perdón va ANTES de contar: uno que trabajó y se cayó una vez
187        // arranca de cero, no con la espera del último desastre.
188        if vivio >= self.umbral_perdon {
189            self.intentos = 0;
190        }
191        if self.intentos >= self.tope {
192            return Estado::Rendido { motivo, intentos: self.intentos };
193        }
194        let en = self.espera();
195        self.intentos += 1;
196        Estado::Murio { motivo, intentos: self.intentos, en }
197    }
198
199    /// Vuelve a cero. Lo llama quien decide reintentar a mano — el operador
200    /// apretando «reintentar» no tiene por qué heredar la cuenta.
201    pub fn perdonar(&mut self) {
202        self.intentos = 0;
203    }
204}
205
206/// Lanza un huésped y le tiende el caño.
207///
208/// El socket lleva **nombre**, en el directorio de ejecución del usuario, y el
209/// nombre es único por lanzamiento. Del otro lado, el huésped lo recibe por
210/// argumento y se conecta.
211///
212/// El archivo se borra apenas el huésped se conecta —no hace falta más, y así no
213/// queda basura si el proceso se muere de cualquier forma—. Es la lección del
214/// lockfile de `SDD-IDE.md`, donde un archivo huérfano no rompía nada sino algo
215/// peor: apagaba una función sin parecer un error.
216///
217/// `preparar` recibe el path del socket y devuelve el comando ya armado. Se
218/// pide así, y no un `Command` a medio hacer, para que el llamador decida cómo
219/// se lo pasa al huésped (bandera, variable de ambiente) sin que este módulo
220/// tenga que adivinar la interfaz de nadie.
221#[cfg(feature = "proceso")]
222pub fn lanzar(
223    nombre: &str,
224    preparar: impl FnOnce(&std::path::Path) -> std::process::Command,
225) -> Result<(std::process::Child, std::os::unix::net::UnixStream), Motivo> {
226    use std::os::unix::net::UnixListener;
227
228    let dir = std::env::var_os("XDG_RUNTIME_DIR")
229        .map(std::path::PathBuf::from)
230        .unwrap_or_else(std::env::temp_dir);
231    // El pid + el nombre hacen la unicidad: dos tablados del mismo huésped en la
232    // misma shuma no pueden pisarse, y una shuma vieja tampoco.
233    let ruta = dir.join(format!("llimphi-cable-{}-{}.sock", std::process::id(), nombre));
234    let _ = std::fs::remove_file(&ruta);
235
236    let escucha = UnixListener::bind(&ruta).map_err(|e| Motivo::NoArranco(e.to_string()))?;
237    let hijo = preparar(&ruta)
238        .spawn()
239        .map_err(|e| Motivo::NoArranco(e.to_string()))?;
240
241    // `accept` bloquea. Si el huésped no llega —binario que arranca y muere—, lo
242    // que corresponde es que esto falle, no que cuelgue al anfitrión para
243    // siempre; el plazo lo pone el llamador cerrando el listener desde su lado o
244    // matando al hijo. Se deja anotado y no resuelto acá porque resolverlo con un
245    // hilo escondido sería peor: el que sabe cuánto puede esperar es quien pinta.
246    let (flujo, _) = escucha.accept().map_err(|e| Motivo::NoArranco(e.to_string()))?;
247    let _ = std::fs::remove_file(&ruta);
248    Ok((hijo, flujo))
249}
250
251/// Qué le pasó al hijo, sin bloquear.
252///
253/// `None` = sigue vivo. Se pregunta así y no esperando: el anfitrión pinta a
254/// sesenta cuadros por segundo y no puede quedarse esperando a nadie.
255#[cfg(feature = "proceso")]
256pub fn como_murio(hijo: &mut std::process::Child) -> Option<Motivo> {
257    match hijo.try_wait() {
258        Ok(Some(estado)) => Some(Motivo::Salio(estado.code())),
259        Ok(None) => None,
260        Err(e) => Some(Motivo::NoArranco(e.to_string())),
261    }
262}
263
264#[cfg(test)]
265mod pruebas {
266    use super::*;
267
268    const S: fn(u64) -> Duration = Duration::from_secs;
269
270    /// La espera crece y se planta en el techo. Un backoff que no se planta deja
271    /// al operador esperando horas sin decir nada.
272    #[test]
273    fn la_espera_crece_y_se_planta() {
274        let mut r = Reintentos::new();
275        assert_eq!(r.espera(), S(1));
276        r.murio(Motivo::CortoElCano, S(0));
277        assert_eq!(r.espera(), S(2));
278        r.murio(Motivo::CortoElCano, S(0));
279        assert_eq!(r.espera(), S(4));
280        for _ in 0..10 {
281            r.murio(Motivo::CortoElCano, S(0));
282        }
283        assert_eq!(r.espera(), S(30), "no pasa del techo");
284    }
285
286    /// **El desborde del corrimiento.** Sin saturar, el intento 64 daría espera
287    /// cero: un bucle disfrazado de backoff, que es peor que no tenerlo porque
288    /// nadie lo busca ahí.
289    #[test]
290    fn muchos_intentos_no_devuelven_la_espera_a_cero() {
291        let r = Reintentos { intentos: 64, tope: u32::MAX, ..Reintentos::new() };
292        assert_eq!(r.espera(), S(30), "sigue en el techo, no en cero");
293        let r = Reintentos { intentos: 200, tope: u32::MAX, ..Reintentos::new() };
294        assert_eq!(r.espera(), S(30));
295    }
296
297    /// Se rinde al llegar al tope, y **lo dice con el número**.
298    #[test]
299    fn se_rinde_y_cuenta_cuantas_veces() {
300        let mut r = Reintentos { tope: 2, ..Reintentos::new() };
301        assert!(matches!(r.murio(Motivo::CortoElCano, S(0)), Estado::Murio { .. }));
302        assert!(matches!(r.murio(Motivo::CortoElCano, S(0)), Estado::Murio { .. }));
303        match r.murio(Motivo::CortoElCano, S(0)) {
304            Estado::Rendido { intentos, .. } => assert_eq!(intentos, 2),
305            otro => panic!("tendría que haberse rendido, dio {otro:?}"),
306        }
307    }
308
309    /// **El perdón.** Uno que vivió media hora y se cayó una vez arranca de
310    /// cero. Sin esto, la app que anduvo todo el día arrancaría mañana ya
311    /// rendida.
312    #[test]
313    fn un_huesped_que_vivio_bastante_arranca_de_cero() {
314        let mut r = Reintentos { tope: 2, ..Reintentos::new() };
315        r.murio(Motivo::CortoElCano, S(0));
316        r.murio(Motivo::CortoElCano, S(0));
317        assert_eq!(r.intentos(), 2, "está al borde de rendirse");
318
319        // Ahora vivió de sobra: la cuenta se perdona y vuelve a haber crédito.
320        match r.murio(Motivo::CortoElCano, S(120)) {
321            Estado::Murio { intentos, .. } => assert_eq!(intentos, 1, "empezó de nuevo"),
322            otro => panic!("no tendría que rendirse: {otro:?}"),
323        }
324    }
325
326    /// Brazo de control del perdón: por debajo del umbral **no** se perdona.
327    /// Sin este test, un perdón incondicional pasaría el anterior y el bucle de
328    /// reintentos sería eterno.
329    #[test]
330    fn un_huesped_que_murio_enseguida_no_se_perdona() {
331        let mut r = Reintentos { tope: 2, umbral_perdon: S(30), ..Reintentos::new() };
332        r.murio(Motivo::CortoElCano, S(29));
333        r.murio(Motivo::CortoElCano, S(29));
334        assert!(
335            matches!(r.murio(Motivo::CortoElCano, S(29)), Estado::Rendido { .. }),
336            "29 s no alcanzan para el perdón: esto es un bucle, no un hipo"
337        );
338    }
339
340    /// Los cuatro estados **suenan distinto**. Es toda la razón por la que
341    /// `Estado` no es un `bool`: si no, el operador ve el mismo rectángulo gris
342    /// cuando está arrancando, cuando se murió y cuando nos rendimos.
343    #[test]
344    fn cada_estado_dice_algo_distinto() {
345        let frases = [
346            Estado::Arrancando.frase(),
347            Estado::Murio { motivo: Motivo::CortoElCano, intentos: 1, en: S(2) }.frase(),
348            Estado::Rendido { motivo: Motivo::CortoElCano, intentos: 6 }.frase(),
349        ];
350        for f in &frases {
351            assert!(!f.is_empty(), "un estado sin frase es un rectángulo mudo");
352        }
353        assert_eq!(
354            frases.iter().collect::<std::collections::HashSet<_>>().len(),
355            3,
356            "y las tres tienen que ser distintas entre sí"
357        );
358        assert!(frases[2].contains('6'), "al rendirse se dice CUÁNTAS veces");
359    }
360
361    /// Los cuatro motivos también. Uno que no arranca y uno que salió con error
362    /// llevan a arreglos distintos, así que no pueden leerse igual.
363    #[test]
364    fn cada_motivo_dice_algo_distinto() {
365        let m = [
366            Motivo::NoArranco("no such file".into()).frase(),
367            Motivo::Salio(Some(0)).frase(),
368            Motivo::Salio(Some(101)).frase(),
369            Motivo::Salio(None).frase(),
370            Motivo::CortoElCano.frase(),
371            Motivo::Protocolo("marco de 4 GiB".into()).frase(),
372        ];
373        assert_eq!(
374            m.iter().collect::<std::collections::HashSet<_>>().len(),
375            6,
376            "seis situaciones, seis frases"
377        );
378    }
379
380    /// Estar vivo y estar rendido son preguntas distintas de la frase, y se
381    /// contestan sin parsear texto.
382    #[test]
383    fn vivo_y_rendido_se_preguntan_sin_leer_la_frase() {
384        assert!(Estado::Arrancando.vivo());
385        assert!(Estado::Vivo.vivo());
386        assert!(!Estado::Murio { motivo: Motivo::CortoElCano, intentos: 1, en: S(1) }.vivo());
387        assert!(!Estado::Rendido { motivo: Motivo::CortoElCano, intentos: 6 }.vivo());
388        assert!(Estado::Rendido { motivo: Motivo::CortoElCano, intentos: 6 }.rendido());
389        assert!(!Estado::Murio { motivo: Motivo::CortoElCano, intentos: 1, en: S(1) }.rendido());
390    }
391
392    /// El operador que aprieta «reintentar» no hereda la cuenta.
393    #[test]
394    fn perdonar_a_mano_devuelve_el_credito() {
395        let mut r = Reintentos { tope: 1, ..Reintentos::new() };
396        r.murio(Motivo::CortoElCano, S(0));
397        assert!(matches!(r.murio(Motivo::CortoElCano, S(0)), Estado::Rendido { .. }));
398        r.perdonar();
399        assert!(matches!(r.murio(Motivo::CortoElCano, S(0)), Estado::Murio { .. }));
400    }
401}