diff options
Diffstat (limited to 'crates/asist-core/src/proc.rs')
| -rw-r--r-- | crates/asist-core/src/proc.rs | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/crates/asist-core/src/proc.rs b/crates/asist-core/src/proc.rs new file mode 100644 index 0000000..8e38108 --- /dev/null +++ b/crates/asist-core/src/proc.rs @@ -0,0 +1,228 @@ +//! Ejecutar un programa con un plazo máximo. +//! +//! Tres sitios lo necesitaban con los mismos cuidados —no dejar nunca un +//! proceso colgado, no pasar por una shell, distinguir «falló» de «tardó +//! demasiado»— y cada uno lo tenía escrito a su manera. Aquí está una sola vez. +//! +//! Nada de esto pasa por `sh`: los argumentos van al `execve` tal cual. Importa +//! más de lo que parece, porque lo que acaba en ellos viene, en última +//! instancia, de lo que se ha oído por el micrófono. + +use std::ffi::OsStr; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Cómo terminó. +#[derive(Debug)] +pub struct Output { + pub stdout: Vec<u8>, + pub stderr: String, + pub status: Option<i32>, + pub took: Duration, +} + +impl Output { + pub fn success(&self) -> bool { + self.status == Some(0) + } + + /// Última línea del error, que es donde los programas suelen poner el + /// motivo de verdad. + pub fn last_error_line(&self) -> &str { + self.stderr + .trim() + .lines() + .next_back() + .unwrap_or("sin detalles") + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ProcError { + #[error("no se pudo ejecutar {program}: {source}")] + Spawn { + program: String, + #[source] + source: std::io::Error, + }, + #[error("{program} tardó más de {} s", timeout.as_secs())] + Timeout { program: String, timeout: Duration }, + #[error("fallo esperando a {program}: {source}")] + Wait { + program: String, + #[source] + source: std::io::Error, + }, +} + +/// Lanza `program`, espera hasta `timeout` y devuelve lo que haya escrito. +/// +/// Si vence el plazo, mata el proceso antes de rendirse: un ffmpeg contra una +/// cámara ocupada, o un buscador que no contesta, se quedarían ahí para +/// siempre. +pub fn run( + program: impl AsRef<Path>, + args: impl IntoIterator<Item = impl AsRef<OsStr>>, + timeout: Duration, + working_dir: Option<&Path>, +) -> Result<Output, ProcError> { + let program = program.as_ref(); + let name = program.display().to_string(); + let started = Instant::now(); + + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(dir) = working_dir { + command.current_dir(dir); + } + + let mut child = command.spawn().map_err(|source| ProcError::Spawn { + program: name.clone(), + source, + })?; + + // Las tuberías se vacían en hilos aparte, y no después de esperar. + // + // Esto no es una optimización: un hijo que escribe más de lo que cabe en el + // búfer de la tubería (64 KB en Linux) se queda bloqueado escribiendo, no + // termina nunca, y el bucle de espera acaba matándolo por plazo vencido + // aunque estuviera haciendo su trabajo. Costó descubrirlo porque los tres + // primeros usos —una fecha, un JSON de búsqueda, un fotograma de 9 KB— + // cabían de sobra; la primera captura de pantalla, de 180 KB, no. + let mut stdout = child.stdout.take(); + let mut stderr = child.stderr.take(); + let stdout_reader = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(pipe) = stdout.as_mut() { + let _ = pipe.read_to_end(&mut buf); + } + buf + }); + let stderr_reader = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(pipe) = stderr.as_mut() { + let _ = pipe.read_to_end(&mut buf); + } + buf + }); + + let deadline = started + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= deadline => { + // Matar cierra las tuberías, así que los lectores terminan y + // se pueden recoger sin quedarse colgados. + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(ProcError::Timeout { + program: name, + timeout, + }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(source) => { + return Err(ProcError::Wait { + program: name, + source, + }) + } + } + }; + + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + + Ok(Output { + stdout, + stderr: String::from_utf8_lossy(&stderr).into_owned(), + status: status.code(), + took: started.elapsed(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn se_recoge_la_salida_estandar() { + let out = run("echo", ["hola"], Duration::from_secs(5), None).unwrap(); + assert!(out.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hola"); + } + + #[test] + fn un_proceso_colgado_se_mata_al_vencer_el_plazo() { + let started = Instant::now(); + let err = run("sleep", ["30"], Duration::from_millis(300), None).unwrap_err(); + assert!(matches!(err, ProcError::Timeout { .. }), "{err}"); + assert!( + started.elapsed() < Duration::from_secs(3), + "debió cortar enseguida, tardó {:?}", + started.elapsed() + ); + } + + #[test] + fn una_salida_grande_no_bloquea_al_hijo() { + // La regresión que dejó colgada la captura de pantalla: sin vaciar la + // tubería mientras se espera, un hijo que escribe más de 64 KB se + // bloquea y acaba muriendo por plazo vencido. + let out = run( + "dd", + ["if=/dev/zero", "bs=1024", "count=512", "status=none"], + Duration::from_secs(10), + None, + ) + .expect("no debió vencer el plazo"); + assert!(out.success()); + assert_eq!(out.stdout.len(), 512 * 1024); + } + + #[test] + fn el_error_grande_tampoco_bloquea() { + // Mismo problema por la otra tubería: mucho ruido en stderr y poca + // salida es justo lo que hace ffmpeg cuando algo va mal. + let out = run( + "sh", + ["-c", "yes error | head -c 200000 >&2; echo ok"], + Duration::from_secs(10), + None, + ) + .expect("no debió vencer el plazo"); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok"); + assert!(out.stderr.len() > 100_000); + } + + #[test] + fn un_programa_inexistente_da_un_error_con_su_nombre() { + let err = run("/no/existe/nada", ["x"], Duration::from_secs(1), None).unwrap_err(); + assert!(err.to_string().contains("/no/existe/nada"), "{err}"); + } + + #[test] + fn un_fallo_conserva_el_codigo_y_el_mensaje() { + let out = run("ls", ["/no/existe"], Duration::from_secs(5), None).unwrap(); + assert!(!out.success()); + assert!(!out.last_error_line().is_empty()); + } + + #[test] + fn los_metacaracteres_no_los_interpreta_ninguna_shell() { + let out = run("echo", ["a; echo b"], Duration::from_secs(5), None).unwrap(); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "a; echo b", + "el punto y coma debe llegar como texto, no como separador de órdenes" + ); + } +} |