1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
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"
);
}
}
|