aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core/src/proc.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-core/src/proc.rs')
-rw-r--r--crates/asist-core/src/proc.rs228
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"
+ );
+ }
+}