aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-core
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-core')
-rw-r--r--crates/asist-core/src/config.rs49
-rw-r--r--crates/asist-core/src/lib.rs1
-rw-r--r--crates/asist-core/src/proc.rs228
-rw-r--r--crates/asist-core/src/tools.rs48
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]