gioser

tawasuyu/brahman

Rust 212TOML 64JSON 14HTML 12703 Definiciones

⎇ b4be5e1c72d728d5e3aaf2f294b35a3c34c7de99 crates/modules/shuma/shuma-exec/src/lib.rs Rust · 427 líneas · 14.1 KB Historia Quién tocó qué crudo

feat(shuma): captura acotada + reproceso de salidas vía stdin b4be5e1c7 · sergio ·

1//! `shuma-exec` — ejecución de comandos del shell con salida en streaming.
2//!
3//! Lanza una línea de comandos en un shell (`bash -c …`) dentro de un
4//! directorio, y entrega su salida **a medida que ocurre**: cada línea
5//! de stdout o stderr llega como un [`RunEvent`] por un canal, sin
6//! esperar a que el proceso termine.
7//!
8//! Esto es lo que `sandokan` no hace: el orquestador es poll-based y
9//! orquesta *Cards* de brahman (entidades aisladas y supervisadas). El
10//! shell, en cambio, corre líneas de shell ad-hoc y necesita ver la
11//! salida fluir. Dos capas distintas, a propósito.
12//!
13//! **Captura acotada.** Para no cargar en RAM un stream de gigabytes, la
14//! captura tiene un límite de bytes ([`CommandSpec::capture_limit`]):
15//! pasado el límite se emite [`RunEvent::Truncated`] una vez y el resto
16//! se **descarta** — pero el pipe se sigue drenando, así el proceso no
17//! se bloquea y termina normal.
18//!
19//! **Reproceso.** [`CommandSpec::stdin_data`] alimenta un texto por la
20//! entrada estándar del proceso: permite reprocesar la salida capturada
21//! de un comando previo con otra herramienta, sin volver a correr el
22//! comando original.
23//!
24//! El crate es agnóstico de frontend: el proceso y sus lectores corren
25//! en hilos; el consumidor (shell GPUI o TUI) drena el canal cuando
26//! quiere — sin `async`, sin acoplarse a ningún runtime.
27
28#![forbid(unsafe_code)]
29
30use std::io::{BufRead, BufReader, Read, Write};
31use std::process::{Child, Command, Stdio};
32use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
33use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
34use std::sync::{Arc, Mutex};
35use std::thread::JoinHandle;
36
37/// Qué ejecutar: una línea de comandos, en un directorio, con un shell.
38#[derive(Debug, Clone)]
39pub struct CommandSpec {
40    /// La línea completa — se pasa como `shell -c "<line>"`.
41    pub line: String,
42    /// Directorio de trabajo del proceso.
43    pub cwd: String,
44    /// Programa de shell — `"bash"`, `"sh"`, `"fish"`…
45    pub shell: String,
46    /// Tope de bytes a capturar; `0` = sin límite. Pasado el tope, la
47    /// salida se descarta (se emite [`RunEvent::Truncated`]).
48    pub capture_limit: usize,
49    /// Texto a alimentar por stdin — para reprocesar una salida previa.
50    pub stdin_data: Option<String>,
51}
52
53impl CommandSpec {
54    /// Spec con `bash` como shell, sin límite ni stdin.
55    pub fn bash(line: impl Into<String>, cwd: impl Into<String>) -> Self {
56        Self {
57            line: line.into(),
58            cwd: cwd.into(),
59            shell: "bash".into(),
60            capture_limit: 0,
61            stdin_data: None,
62        }
63    }
64
65    /// Fija el tope de captura en bytes (encadenable).
66    pub fn with_limit(mut self, bytes: usize) -> Self {
67        self.capture_limit = bytes;
68        self
69    }
70
71    /// Alimenta `data` por la entrada estándar del proceso (encadenable).
72    pub fn with_stdin(mut self, data: impl Into<String>) -> Self {
73        self.stdin_data = Some(data.into());
74        self
75    }
76}
77
78/// Un evento de la ejecución de un comando.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum RunEvent {
81    /// Una línea de salida estándar.
82    Stdout(String),
83    /// Una línea de salida de error.
84    Stderr(String),
85    /// La captura alcanzó su tope; lo que sigue se descarta.
86    Truncated,
87    /// El proceso terminó con este código de salida.
88    Exited(i32),
89    /// El proceso no pudo siquiera lanzarse.
90    Failed(String),
91}
92
93impl RunEvent {
94    /// `true` si el evento cierra la ejecución (`Exited` o `Failed`).
95    pub fn is_terminal(&self) -> bool {
96        matches!(self, RunEvent::Exited(_) | RunEvent::Failed(_))
97    }
98}
99
100/// Asa de un comando en ejecución. El consumidor la conserva y drena sus
101/// eventos cuando le conviene.
102pub struct RunHandle {
103    rx: Receiver<RunEvent>,
104    finished: bool,
105    /// El proceso, compartido con su hilo coordinador para poder matarlo.
106    child: Arc<Mutex<Option<Child>>>,
107}
108
109impl RunHandle {
110    /// Mata el proceso (envía la señal de terminación). No hace nada si
111    /// el proceso ya terminó o nunca llegó a lanzarse.
112    pub fn kill(&self) {
113        if let Ok(mut guard) = self.child.lock() {
114            if let Some(c) = guard.as_mut() {
115                let _ = c.kill();
116            }
117        }
118    }
119
120    /// Drena todos los eventos disponibles ahora mismo, sin bloquear.
121    /// Marca el asa como terminada al ver un evento terminal.
122    pub fn try_events(&mut self) -> Vec<RunEvent> {
123        let mut out = Vec::new();
124        loop {
125            match self.rx.try_recv() {
126                Ok(ev) => {
127                    if ev.is_terminal() {
128                        self.finished = true;
129                    }
130                    out.push(ev);
131                }
132                Err(TryRecvError::Empty) => break,
133                Err(TryRecvError::Disconnected) => {
134                    self.finished = true;
135                    break;
136                }
137            }
138        }
139        out
140    }
141
142    /// Bloquea hasta que el proceso termine y devuelve todos sus eventos
143    /// en orden. Pensado para tests y para usos sincrónicos.
144    pub fn wait_all(&mut self) -> Vec<RunEvent> {
145        let mut out = Vec::new();
146        while let Ok(ev) = self.rx.recv() {
147            let terminal = ev.is_terminal();
148            out.push(ev);
149            if terminal {
150                self.finished = true;
151            }
152        }
153        self.finished = true;
154        out
155    }
156
157    /// `true` si ya se observó el evento terminal.
158    pub fn is_finished(&self) -> bool {
159        self.finished
160    }
161}
162
163/// Lanza un hilo lector de un flujo. Cuenta los bytes contra `counter`;
164/// pasado `limit` emite `Truncated` una vez (vía `announced`) y descarta
165/// el resto, pero **sigue drenando** el pipe para no bloquear al proceso.
166fn spawn_reader<R: Read + Send + 'static>(
167    stream: R,
168    tx: Sender<RunEvent>,
169    make: fn(String) -> RunEvent,
170    limit: usize,
171    counter: Arc<AtomicUsize>,
172    announced: Arc<AtomicBool>,
173) -> JoinHandle<()> {
174    std::thread::spawn(move || {
175        for line in BufReader::new(stream).lines().map_while(Result::ok) {
176            let total = counter.fetch_add(line.len() + 1, Ordering::Relaxed) + line.len() + 1;
177            if limit != 0 && total > limit {
178                if !announced.swap(true, Ordering::Relaxed) {
179                    let _ = tx.send(RunEvent::Truncated);
180                }
181                continue; // descarta, pero sigue leyendo el pipe
182            }
183            if tx.send(make(line)).is_err() {
184                break;
185            }
186        }
187    })
188}
189
190/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la
191/// salida. La función vuelve de inmediato: el proceso corre en hilos.
192pub fn run(spec: &CommandSpec) -> RunHandle {
193    let (tx, rx) = mpsc::channel();
194    let spec = spec.clone();
195    let child_cell: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(None));
196    let cell = Arc::clone(&child_cell);
197
198    std::thread::spawn(move || {
199        let stdin_mode = if spec.stdin_data.is_some() {
200            Stdio::piped()
201        } else {
202            Stdio::null()
203        };
204        let spawned = Command::new(&spec.shell)
205            .arg("-c")
206            .arg(&spec.line)
207            .current_dir(&spec.cwd)
208            .stdin(stdin_mode)
209            .stdout(Stdio::piped())
210            .stderr(Stdio::piped())
211            .spawn();
212
213        let mut child = match spawned {
214            Ok(c) => c,
215            Err(e) => {
216                let _ = tx.send(RunEvent::Failed(e.to_string()));
217                return;
218            }
219        };
220
221        // Si hay datos para reprocesar, se escriben por stdin en su
222        // propio hilo (la escritura puede bloquear hasta que el proceso
223        // consuma); al terminar, `stdin` se cierra → EOF.
224        if let Some(data) = spec.stdin_data.clone() {
225            if let Some(mut stdin) = child.stdin.take() {
226                std::thread::spawn(move || {
227                    let _ = stdin.write_all(data.as_bytes());
228                });
229            }
230        }
231
232        let stdout = child.stdout.take();
233        let stderr = child.stderr.take();
234        // Comparte el proceso para que `RunHandle::kill` pueda alcanzarlo.
235        if let Ok(mut g) = cell.lock() {
236            *g = Some(child);
237        }
238
239        // Contador de bytes compartido: el tope vale para stdout+stderr.
240        let counter = Arc::new(AtomicUsize::new(0));
241        let announced = Arc::new(AtomicBool::new(false));
242        let limit = spec.capture_limit;
243
244        let out_reader = stdout.map(|s| {
245            spawn_reader(
246                s,
247                tx.clone(),
248                RunEvent::Stdout,
249                limit,
250                Arc::clone(&counter),
251                Arc::clone(&announced),
252            )
253        });
254        let err_reader = stderr.map(|s| {
255            spawn_reader(
256                s,
257                tx.clone(),
258                RunEvent::Stderr,
259                limit,
260                Arc::clone(&counter),
261                Arc::clone(&announced),
262            )
263        });
264
265        // Los lectores terminan cuando el proceso cierra sus pipes —sea
266        // por fin natural o por `kill`—; recién entonces se cosecha.
267        if let Some(h) = out_reader {
268            let _ = h.join();
269        }
270        if let Some(h) = err_reader {
271            let _ = h.join();
272        }
273        let code = cell
274            .lock()
275            .ok()
276            .and_then(|mut g| g.as_mut().and_then(|c| c.wait().ok()))
277            .and_then(|s| s.code())
278            .unwrap_or(-1);
279        let _ = tx.send(RunEvent::Exited(code));
280    });
281
282    RunHandle { rx, finished: false, child: child_cell }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    /// `sh` está en cualquier entorno POSIX — más portable que bash
290    /// para los tests.
291    fn sh(line: &str) -> CommandSpec {
292        CommandSpec { shell: "sh".into(), ..CommandSpec::bash(line, ".") }
293    }
294
295    #[test]
296    fn captures_stdout_and_exit_code() {
297        let mut h = run(&sh("echo hola-mundo"));
298        let events = h.wait_all();
299        assert!(events.contains(&RunEvent::Stdout("hola-mundo".into())));
300        assert!(events.contains(&RunEvent::Exited(0)));
301        assert!(h.is_finished());
302    }
303
304    #[test]
305    fn captures_stderr() {
306        let mut h = run(&sh("echo problema 1>&2"));
307        let events = h.wait_all();
308        assert!(events.contains(&RunEvent::Stderr("problema".into())));
309    }
310
311    #[test]
312    fn nonzero_exit_is_reported() {
313        let mut h = run(&sh("exit 3"));
314        let events = h.wait_all();
315        assert!(events.contains(&RunEvent::Exited(3)));
316    }
317
318    #[test]
319    fn multiple_output_lines_arrive_in_order() {
320        let mut h = run(&sh("echo uno; echo dos; echo tres"));
321        let lines: Vec<String> = h
322            .wait_all()
323            .into_iter()
324            .filter_map(|e| match e {
325                RunEvent::Stdout(l) => Some(l),
326                _ => None,
327            })
328            .collect();
329        assert_eq!(lines, vec!["uno", "dos", "tres"]);
330    }
331
332    #[test]
333    fn pipes_run_through_the_shell() {
334        let mut h = run(&sh("printf 'b\\na\\nc\\n' | sort"));
335        let lines: Vec<String> = h
336            .wait_all()
337            .into_iter()
338            .filter_map(|e| match e {
339                RunEvent::Stdout(l) => Some(l),
340                _ => None,
341            })
342            .collect();
343        assert_eq!(lines, vec!["a", "b", "c"]);
344    }
345
346    #[test]
347    fn missing_shell_fails_gracefully() {
348        let spec = CommandSpec { shell: "/no/existe/shell-xyz".into(), ..sh("echo x") };
349        let mut h = run(&spec);
350        let events = h.wait_all();
351        assert!(matches!(events.first(), Some(RunEvent::Failed(_))));
352    }
353
354    #[test]
355    fn terminal_event_detection() {
356        assert!(RunEvent::Exited(0).is_terminal());
357        assert!(RunEvent::Failed("x".into()).is_terminal());
358        assert!(!RunEvent::Stdout("x".into()).is_terminal());
359        assert!(!RunEvent::Truncated.is_terminal());
360    }
361
362    #[test]
363    fn kill_stops_a_long_running_process() {
364        let mut h = run(&sh("sleep 30"));
365        std::thread::sleep(std::time::Duration::from_millis(250));
366        h.kill();
367        let events = h.wait_all();
368        assert!(events.last().map(|e| e.is_terminal()).unwrap_or(false));
369        assert!(h.is_finished());
370    }
371
372    #[test]
373    fn capture_limit_truncates_but_process_finishes() {
374        // 20.000 líneas, pero la captura se corta a ~400 bytes.
375        let mut h = run(&sh("seq 1 20000").with_limit(400));
376        let events = h.wait_all();
377        // Se anunció el truncado…
378        assert!(events.contains(&RunEvent::Truncated));
379        // …pero el proceso terminó normal (no se bloqueó).
380        assert!(events.contains(&RunEvent::Exited(0)));
381        // Y lo capturado quedó acotado.
382        let captured = events
383            .iter()
384            .filter(|e| matches!(e, RunEvent::Stdout(_)))
385            .count();
386        assert!(captured < 20000, "la salida quedó acotada");
387    }
388
389    #[test]
390    fn no_limit_captures_everything() {
391        let mut h = run(&sh("seq 1 500")); // capture_limit = 0
392        let events = h.wait_all();
393        assert!(!events.contains(&RunEvent::Truncated));
394        let n = events.iter().filter(|e| matches!(e, RunEvent::Stdout(_))).count();
395        assert_eq!(n, 500);
396    }
397
398    #[test]
399    fn stdin_data_is_fed_to_the_process() {
400        // `cat` devuelve por stdout lo que recibe por stdin — es el
401        // reproceso más simple: tomar una salida y pasarla a otro filtro.
402        let mut h = run(&sh("cat").with_stdin("alfa\nbeta\ngamma"));
403        let lines: Vec<String> = h
404            .wait_all()
405            .into_iter()
406            .filter_map(|e| match e {
407                RunEvent::Stdout(l) => Some(l),
408                _ => None,
409            })
410            .collect();
411        assert_eq!(lines, vec!["alfa", "beta", "gamma"]);
412    }
413
414    #[test]
415    fn stdin_data_reprocessed_by_a_filter() {
416        let mut h = run(&sh("grep beta").with_stdin("alfa\nbeta\nbetabel\ngamma"));
417        let lines: Vec<String> = h
418            .wait_all()
419            .into_iter()
420            .filter_map(|e| match e {
421                RunEvent::Stdout(l) => Some(l),
422                _ => None,
423            })
424            .collect();
425        assert_eq!(lines, vec!["beta", "betabel"]);
426    }
427}