//! 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, pub stderr: String, pub status: Option, 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, args: impl IntoIterator>, timeout: Duration, working_dir: Option<&Path>, ) -> Result { 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" ); } }