tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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}