diff options
Diffstat (limited to 'crates/asist-core/src')
| -rw-r--r-- | crates/asist-core/src/config.rs | 49 | ||||
| -rw-r--r-- | crates/asist-core/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/asist-core/src/proc.rs | 228 | ||||
| -rw-r--r-- | crates/asist-core/src/tools.rs | 48 |
4 files changed, 292 insertions, 34 deletions
diff --git a/crates/asist-core/src/config.rs b/crates/asist-core/src/config.rs index 792ad3f..c2a9d55 100644 --- a/crates/asist-core/src/config.rs +++ b/crates/asist-core/src/config.rs @@ -19,6 +19,7 @@ pub struct Config { pub tools: ToolsConfig, pub search: SearchConfig, pub camera: CameraConfig, + pub screen: ScreenConfig, pub supervisor: SupervisorConfig, } @@ -430,6 +431,48 @@ impl Default for CameraConfig { } } +/// Mirar la pantalla. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ScreenConfig { + pub enabled: bool, + /// Programa de captura. `{ancho}` y `{salida}` se sustituyen antes de + /// ejecutar, y tiene que escribir un JPEG por la salida estándar. Está + /// fuera del binario para que añadir un compositor sea editar un guion. + pub command: Vec<String>, + /// Ancho al que se reduce la captura antes de mandarla al modelo. + /// + /// **No lo bajes a la ligera.** Medido con tipografía de interfaz de 13 px + /// y preguntando por datos concretos: a 1280 px acierta 3 de 3 en 7,6 s; a + /// 960, 2 de 3; a 640, 1 de 3. Y cuando falla no dice que no lo lee: se + /// inventa el contenido con aplomo. + pub width: u32, + /// Monitor concreto. Vacío = todo lo que haya. + pub output: String, + pub timeout_secs: u64, + /// Carpeta donde guardar las capturas. Vacío = no se guarda ninguna, que + /// es lo que corresponde: en una captura caben contraseñas y mensajes + /// privados. + pub save_dir: String, +} + +impl Default for ScreenConfig { + fn default() -> Self { + Self { + enabled: true, + command: vec![ + "scripts/capturar-pantalla.sh".into(), + "{ancho}".into(), + "{salida}".into(), + ], + width: 1280, + output: String::new(), + timeout_secs: 20, + save_dir: String::new(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SupervisorConfig { @@ -569,6 +612,12 @@ impl Config { fix(&mut self.supervisor.tts.binary); fix(&mut self.supervisor.tts.model); fix(&mut self.supervisor.tts.codec); + if let Some(program) = self.screen.command.first_mut() { + let path = PathBuf::from(&*program); + if path.is_relative() && program.contains('/') { + *program = base.join(path).to_string_lossy().into_owned(); + } + } if let Some(program) = self.search.command.first_mut() { let path = PathBuf::from(&*program); if path.is_relative() && program.contains('/') { diff --git a/crates/asist-core/src/lib.rs b/crates/asist-core/src/lib.rs index db3ebfb..87fbc75 100644 --- a/crates/asist-core/src/lib.rs +++ b/crates/asist-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod error; pub mod event; pub mod http; +pub mod proc; pub mod telemetry; pub mod text; pub mod tools; 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" + ); + } +} diff --git a/crates/asist-core/src/tools.rs b/crates/asist-core/src/tools.rs index ac08141..3911985 100644 --- a/crates/asist-core/src/tools.rs +++ b/crates/asist-core/src/tools.rs @@ -177,7 +177,8 @@ impl ToolRegistry { pub mod builtin { use super::*; - use std::process::{Command, Stdio}; + use std::path::Path; + use std::process::Command; /// Fecha y hora locales. Existe porque el modelo no las sabe y las inventa /// con aplomo, y de paso sirve de ejemplo mínimo de herramienta. @@ -361,40 +362,14 @@ pub mod builtin { // Sin shell de por medio: los argumentos van al `execve` tal cual, // así que un «; rm -rf /» es un argumento literal, no otra orden. - let mut command = Command::new(program); - command - .args(&arguments) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if !self.working_dir.is_empty() { - command.current_dir(&self.working_dir); - } - - let mut child = command.spawn().map_err(|e| fail(e.to_string()))?; - let deadline = std::time::Instant::now() + self.timeout; - loop { - match child.try_wait().map_err(|e| fail(e.to_string()))? { - Some(_) => break, - None if std::time::Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return Err(fail(format!( - "«{}» superó el plazo de {} s y se detuvo", - rendered.trim(), - self.timeout.as_secs() - ))); - } - None => std::thread::sleep(Duration::from_millis(20)), - } - } + let working_dir = (!self.working_dir.is_empty()).then(|| Path::new(&self.working_dir)); + let output = crate::proc::run(program, &arguments, self.timeout, working_dir) + .map_err(|e| fail(e.to_string()))?; - let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); let mut body = stdout.trim().to_string(); if body.is_empty() { - body = stderr.trim().to_string(); + body = output.stderr.trim().to_string(); } // La salida se va a leer en voz alta: pasado cierto punto sólo // sirve para aburrir a quien escucha. @@ -402,10 +377,10 @@ pub mod builtin { if body.chars().count() > MAX { body = body.chars().take(MAX).collect::<String>() + "… (salida recortada)"; } - if !output.status.success() { + if !output.success() { return Ok(format!( "la orden terminó con código {}: {body}", - output.status.code().unwrap_or(-1) + output.status.unwrap_or(-1) )); } Ok(if body.is_empty() { @@ -520,7 +495,12 @@ mod tests { let err = shell .call(&json!({ "programa": "sleep", "argumentos": ["30"] })) .unwrap_err(); - assert!(err.to_string().contains("plazo"), "{err}"); + let err = err.to_string(); + assert!(err.contains("tardó más de"), "{err}"); + assert!( + err.contains("sleep"), + "el error debe decir qué orden se colgó: {err}" + ); } #[test] |