|
| 1 | +//! Ejecución de comandos del proyecto: streaming línea a línea, timeout, |
| 2 | +//! cancelación por Ctrl-C, kill del árbol de procesos y captura del código de |
| 3 | +//! salida REAL. Extraído de `fs`. |
| 4 | +
|
| 5 | +use std::path::Path; |
| 6 | + |
| 7 | +use super::cap_tail; |
| 8 | + |
| 9 | +/// Tiempo máximo de ejecución de un comando (`dpx:run` y búsquedas). Un proceso |
| 10 | +/// que no termina (servidor, watch) se corta y se le explica al modelo. |
| 11 | +pub const RUN_TIMEOUT_SECS: u64 = 180; |
| 12 | + |
| 13 | +/// Resultado de ejecutar un comando. |
| 14 | +pub struct RunResult { |
| 15 | + pub output: String, |
| 16 | + /// True si el usuario lo interrumpió (Ctrl-C): el turno debería abortarse. |
| 17 | + pub cancelled: bool, |
| 18 | + /// Código de salida REAL del proceso (`Some(0)` = éxito determinista). `None` |
| 19 | + /// si ni siquiera arrancó. Es la fuente de verdad para el green-gate: el texto |
| 20 | + /// `exit code: N` puede perderse si `cap_tail` recorta una salida muy larga. |
| 21 | + pub exit_code: Option<i32>, |
| 22 | +} |
| 23 | + |
| 24 | +enum StreamLine { |
| 25 | + Out(String), |
| 26 | + Err(String), |
| 27 | +} |
| 28 | + |
| 29 | +/// Bombea un pipe del hijo al canal, línea a línea y tolerante a no-UTF-8 |
| 30 | +/// (la salida de Maven/Gradle en Windows trae acentos en la codepage local). |
| 31 | +fn pump_lines<R: std::io::Read + Send + 'static>( |
| 32 | + reader: R, |
| 33 | + tx: std::sync::mpsc::Sender<StreamLine>, |
| 34 | + wrap: fn(String) -> StreamLine, |
| 35 | +) { |
| 36 | + use std::io::BufRead; |
| 37 | + std::thread::spawn(move || { |
| 38 | + let mut reader = std::io::BufReader::new(reader); |
| 39 | + let mut buf = Vec::new(); |
| 40 | + loop { |
| 41 | + buf.clear(); |
| 42 | + match reader.read_until(b'\n', &mut buf) { |
| 43 | + Ok(0) | Err(_) => break, |
| 44 | + Ok(_) => { |
| 45 | + let line = String::from_utf8_lossy(&buf) |
| 46 | + .trim_end_matches(['\r', '\n']) |
| 47 | + .to_string(); |
| 48 | + if tx.send(wrap(line)).is_err() { |
| 49 | + break; |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | + } |
| 54 | + }); |
| 55 | +} |
| 56 | + |
| 57 | +/// Mata el proceso y todo su árbol. En Windows hace falta `taskkill /T`: matar |
| 58 | +/// solo el `cmd` dejaría vivo al build/servidor que lanzó. |
| 59 | +fn kill_tree(child: &mut std::process::Child) { |
| 60 | + if cfg!(windows) { |
| 61 | + let _ = std::process::Command::new("taskkill") |
| 62 | + .args(["/PID", &child.id().to_string(), "/T", "/F"]) |
| 63 | + .output(); |
| 64 | + } |
| 65 | + let _ = child.kill(); |
| 66 | + let _ = child.wait(); |
| 67 | +} |
| 68 | + |
| 69 | +/// Ejecuta un comando en la raíz del proyecto entregando cada línea de salida |
| 70 | +/// por `on_line` según llega. Se corta por `timeout_secs` o cuando |
| 71 | +/// `should_cancel` devuelve true (Ctrl-C), matando el árbol de procesos. |
| 72 | +/// El stdin va a null: un comando que espere entrada no congela el REPL. |
| 73 | +pub fn run_command_streaming( |
| 74 | + cwd: &Path, |
| 75 | + cmd: &str, |
| 76 | + timeout_secs: u64, |
| 77 | + on_line: &mut dyn FnMut(&str), |
| 78 | + should_cancel: &dyn Fn() -> bool, |
| 79 | +) -> RunResult { |
| 80 | + use std::process::{Command, Stdio}; |
| 81 | + use std::sync::mpsc; |
| 82 | + use std::time::{Duration, Instant}; |
| 83 | + |
| 84 | + let (shell, flag) = if cfg!(windows) { ("cmd", "/C") } else { ("sh", "-c") }; |
| 85 | + let spawned = Command::new(shell) |
| 86 | + .args([flag, cmd]) |
| 87 | + .current_dir(cwd) |
| 88 | + .stdin(Stdio::null()) |
| 89 | + .stdout(Stdio::piped()) |
| 90 | + .stderr(Stdio::piped()) |
| 91 | + .spawn(); |
| 92 | + let mut child = match spawned { |
| 93 | + Ok(c) => c, |
| 94 | + Err(e) => { |
| 95 | + return RunResult { |
| 96 | + output: format!("error al ejecutar el comando: {e}"), |
| 97 | + cancelled: false, |
| 98 | + exit_code: None, |
| 99 | + }; |
| 100 | + } |
| 101 | + }; |
| 102 | + |
| 103 | + let (tx, rx) = mpsc::channel(); |
| 104 | + if let Some(out) = child.stdout.take() { |
| 105 | + pump_lines(out, tx.clone(), StreamLine::Out); |
| 106 | + } |
| 107 | + if let Some(err) = child.stderr.take() { |
| 108 | + pump_lines(err, tx.clone(), StreamLine::Err); |
| 109 | + } |
| 110 | + drop(tx); |
| 111 | + |
| 112 | + let start = Instant::now(); |
| 113 | + let mut stdout_buf = String::new(); |
| 114 | + let mut stderr_buf = String::new(); |
| 115 | + let (mut cancelled, mut timed_out) = (false, false); |
| 116 | + |
| 117 | + loop { |
| 118 | + match rx.recv_timeout(Duration::from_millis(100)) { |
| 119 | + Ok(line) => { |
| 120 | + let (text, buf) = match line { |
| 121 | + StreamLine::Out(t) => (t, &mut stdout_buf), |
| 122 | + StreamLine::Err(t) => (t, &mut stderr_buf), |
| 123 | + }; |
| 124 | + on_line(&text); |
| 125 | + buf.push_str(&text); |
| 126 | + buf.push('\n'); |
| 127 | + } |
| 128 | + Err(mpsc::RecvTimeoutError::Timeout) => {} |
| 129 | + // Ambos pipes cerrados: el proceso terminó. |
| 130 | + Err(mpsc::RecvTimeoutError::Disconnected) => break, |
| 131 | + } |
| 132 | + if should_cancel() { |
| 133 | + kill_tree(&mut child); |
| 134 | + cancelled = true; |
| 135 | + break; |
| 136 | + } |
| 137 | + if start.elapsed().as_secs() >= timeout_secs { |
| 138 | + kill_tree(&mut child); |
| 139 | + timed_out = true; |
| 140 | + break; |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + // Lo que quedara encolado tras cortar. |
| 145 | + while let Ok(line) = rx.try_recv() { |
| 146 | + match line { |
| 147 | + StreamLine::Out(t) => { |
| 148 | + stdout_buf.push_str(&t); |
| 149 | + stdout_buf.push('\n'); |
| 150 | + } |
| 151 | + StreamLine::Err(t) => { |
| 152 | + stderr_buf.push_str(&t); |
| 153 | + stderr_buf.push('\n'); |
| 154 | + } |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + let code = child.wait().ok().and_then(|s| s.code()).unwrap_or(-1); |
| 159 | + let mut s = format!("exit code: {code}\n"); |
| 160 | + if !stdout_buf.trim().is_empty() { |
| 161 | + s.push_str(&format!("--- stdout ---\n{}\n", stdout_buf.trim_end())); |
| 162 | + } |
| 163 | + if !stderr_buf.trim().is_empty() { |
| 164 | + s.push_str(&format!("--- stderr ---\n{}\n", stderr_buf.trim_end())); |
| 165 | + } |
| 166 | + if timed_out { |
| 167 | + s.push_str(&format!( |
| 168 | + "[TIMEOUT: el comando superó {timeout_secs}s y fue terminado. Si es un proceso de larga \ |
| 169 | + duración (servidor, watch), NO lo ejecutes con dpx:run: pídele al usuario que lo corra \ |
| 170 | + en su propia terminal.]\n" |
| 171 | + )); |
| 172 | + } |
| 173 | + if cancelled { |
| 174 | + s.push_str("[interrumpido por el usuario con Ctrl-C]\n"); |
| 175 | + } |
| 176 | + RunResult { output: cap_tail(&s, 200), cancelled, exit_code: Some(code) } |
| 177 | +} |
| 178 | + |
| 179 | +/// Ejecuta un comando y devuelve su salida acotada (sin streaming ni cancelación; |
| 180 | +/// para tareas internas rápidas como las búsquedas). |
| 181 | +pub fn run_command(cwd: &Path, cmd: &str) -> String { |
| 182 | + run_command_streaming(cwd, cmd, RUN_TIMEOUT_SECS, &mut |_| {}, &|| false).output |
| 183 | +} |
0 commit comments