Skip to main content

shell_tunnel/execution/
executor.rs

1//! Command execution engine.
2
3use std::io::Read;
4use std::process::{Command as OsCommand, Stdio};
5use std::sync::mpsc as std_mpsc;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use tokio::sync::mpsc;
10
11use super::command::Command;
12use super::result::{ExecutionResult, OutputChunk};
13use crate::error::ShellTunnelError;
14use crate::output::OutputSanitizer;
15use crate::session::{SessionState, SessionStore};
16use crate::Result;
17
18/// Default execution timeout.
19pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20
21/// Default buffer size for reading process output.
22const READ_BUFFER_SIZE: usize = 4096;
23
24/// Poll interval for the non-blocking control loop.
25const CONTROL_POLL: Duration = Duration::from_millis(5);
26
27/// Hard backstop for collecting trailing output after the process has ended.
28/// Bounds the tail so a lingering grandchild that inherited a pipe cannot block
29/// the return past this grace period.
30const COLLECT_GRACE: Duration = Duration::from_millis(500);
31
32/// Forcibly terminate a process and all of its descendants.
33///
34/// A shell command (`cmd /c ...` / `sh -c ...`) commonly spawns grandchildren;
35/// killing only the direct child would leave them running and holding the
36/// output pipes open. On Windows this shells out to `taskkill /T /F`; on Unix
37/// it signals the child's process group (the child is made a group leader via
38/// `setsid` at spawn time).
39fn kill_tree(pid: u32) {
40    #[cfg(windows)]
41    {
42        let _ = OsCommand::new("taskkill")
43            .args(["/T", "/F", "/PID", &pid.to_string()])
44            .stdin(Stdio::null())
45            .stdout(Stdio::null())
46            .stderr(Stdio::null())
47            .status();
48    }
49    #[cfg(unix)]
50    {
51        // SAFETY: kill with a negative pid targets the process group; harmless
52        // if the group is already gone (returns ESRCH).
53        unsafe {
54            libc::kill(-(pid as i32), libc::SIGKILL);
55        }
56    }
57}
58
59/// Build the platform shell command that runs `command_line` non-interactively.
60fn shell_command(command_line: &str) -> OsCommand {
61    #[cfg(windows)]
62    {
63        let mut c = OsCommand::new("cmd.exe");
64        c.arg("/c").arg(command_line);
65        c
66    }
67    #[cfg(unix)]
68    {
69        let mut c = OsCommand::new("/bin/sh");
70        c.arg("-c").arg(command_line);
71        c
72    }
73}
74
75/// Spawn a reader thread that pumps a pipe into `tx` until EOF.
76fn spawn_pipe_reader<R: Read + Send + 'static>(
77    mut reader: R,
78    tx: std_mpsc::Sender<Vec<u8>>,
79) -> std::thread::JoinHandle<()> {
80    std::thread::spawn(move || {
81        let mut buf = [0u8; READ_BUFFER_SIZE];
82        loop {
83            match reader.read(&mut buf) {
84                Ok(0) => break, // EOF: the process closed this pipe
85                Ok(n) => {
86                    if tx.send(buf[..n].to_vec()).is_err() {
87                        break; // control side went away
88                    }
89                }
90                Err(_) => break, // broken pipe / closed handle
91            }
92        }
93    })
94}
95
96/// Run a non-interactive command with an *enforceable* timeout.
97///
98/// This is the blocking core shared by both the sync and async entry points.
99///
100/// Non-interactive commands are executed via a piped [`std::process::Command`]
101/// rather than a PTY. This is deliberate: a PTY (Windows ConPTY in particular)
102/// does not signal EOF or report child exit for a one-shot command until the
103/// pseudoconsole itself is torn down, so there is no reliable way to tell when
104/// the command finished — every command would run to the full timeout, and each
105/// hung read leaked a `conhost.exe`. A piped child gives real EOF on pipe close
106/// and a working `try_wait()`/`kill()`, which is exactly what a deterministic
107/// "run command, capture output, get exit code, honor timeout" contract needs.
108/// (Interactive/streaming sessions that genuinely need a TTY keep using the PTY
109/// path in [`super::executor::CommandExecutor::execute_async`].)
110///
111/// The design keeps a blocking `read()` from ever stalling progress:
112/// - stdout and stderr are each pumped by a dedicated reader thread (reading
113///   only one while the other's pipe buffer fills would deadlock the child).
114/// - the control loop here is fully non-blocking: it drains the channel, polls
115///   `try_wait()`, and checks the deadline, so the timeout is actually honored.
116/// - on timeout the child is killed; both pipes then close and the reader
117///   threads reach EOF, so nothing leaks.
118///
119/// `on_chunk` is invoked for every output chunk as it arrives, which is what
120/// lets the streaming (WebSocket) path forward output live; the non-streaming
121/// callers pass a no-op.
122fn run_command_streaming(
123    command: &Command,
124    mut on_chunk: impl FnMut(&[u8]),
125) -> Result<ExecutionResult> {
126    let start = Instant::now();
127    let timeout_duration = command.timeout.unwrap_or(DEFAULT_TIMEOUT);
128
129    let mut os_cmd = shell_command(&command.command_line);
130    os_cmd
131        .stdin(Stdio::null())
132        .stdout(Stdio::piped())
133        .stderr(Stdio::piped());
134    if let Some(dir) = &command.working_dir {
135        os_cmd.current_dir(dir);
136    }
137    for (key, value) in &command.env {
138        os_cmd.env(key, value);
139    }
140
141    // On Unix, put the child in its own process group so that on timeout we can
142    // signal the whole tree (a shell that spawned grandchildren) with one
143    // `kill(-pgid)`. Windows tree termination is handled via `taskkill /T`.
144    #[cfg(unix)]
145    {
146        use std::os::unix::process::CommandExt;
147        // SAFETY: setsid only detaches the child into a new session/group; it
148        // touches no shared state in the forked child before exec.
149        unsafe {
150            os_cmd.pre_exec(|| {
151                libc::setsid();
152                Ok(())
153            });
154        }
155    }
156
157    let mut child = os_cmd.spawn().map_err(ShellTunnelError::Io)?;
158    let child_pid = child.id();
159
160    // stdout and stderr are merged into one output stream. True interleaving is
161    // not guaranteed (nor is it with a TTY), but clients consume a single stream.
162    let stdout = child.stdout.take();
163    let stderr = child.stderr.take();
164    let (tx, rx) = std_mpsc::channel::<Vec<u8>>();
165    let out_handle = stdout.map(|s| spawn_pipe_reader(s, tx.clone()));
166    let err_handle = stderr.map(|s| spawn_pipe_reader(s, tx));
167
168    // Non-blocking control loop.
169    let mut raw_output = Vec::new();
170    let mut exit_status = None;
171    let mut timed_out = false;
172    loop {
173        while let Ok(chunk) = rx.try_recv() {
174            on_chunk(&chunk);
175            raw_output.extend_from_slice(&chunk);
176        }
177
178        match child.try_wait() {
179            Ok(Some(status)) => {
180                exit_status = Some(status);
181                break;
182            }
183            Ok(None) => {}
184            Err(e) => return Err(ShellTunnelError::Io(e)),
185        }
186
187        if start.elapsed() >= timeout_duration {
188            timed_out = true;
189            // Kill the whole tree: `cmd /c ...` / `sh -c ...` may have spawned
190            // grandchildren that would otherwise keep the output pipes open and
191            // stall our collection below (and keep running as orphans).
192            kill_tree(child_pid);
193            let _ = child.wait();
194            break;
195        }
196
197        std::thread::sleep(CONTROL_POLL);
198    }
199
200    // Collect any remaining output. Once the process (and, on timeout, its whole
201    // tree) is gone, both pipe handles close, the reader threads reach EOF and
202    // drop their senders, and `recv_timeout` returns `Disconnected`. The grace
203    // deadline is a hard backstop so a stray grandchild that inherited a pipe
204    // can never block us — we return the timed-out result regardless.
205    drop(out_handle);
206    drop(err_handle);
207    let collect_deadline = Instant::now() + COLLECT_GRACE;
208    loop {
209        match rx.recv_timeout(Duration::from_millis(20)) {
210            Ok(chunk) => {
211                on_chunk(&chunk);
212                raw_output.extend_from_slice(&chunk);
213            }
214            Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
215            Err(std_mpsc::RecvTimeoutError::Timeout) => {
216                if Instant::now() >= collect_deadline {
217                    break;
218                }
219            }
220        }
221    }
222
223    let duration = start.elapsed();
224    let text = OutputSanitizer::strip_ansi(&raw_output);
225
226    if timed_out {
227        return Ok(ExecutionResult::timeout(raw_output, text, duration));
228    }
229
230    let exit_code = exit_status.and_then(|s| s.code());
231    let mut result = ExecutionResult::new(raw_output, text, duration);
232    if let Some(code) = exit_code {
233        result = result.with_exit_code(code);
234    }
235    Ok(result)
236}
237
238/// Run a non-interactive command, collecting all output (no streaming).
239fn run_command(command: &Command) -> Result<ExecutionResult> {
240    run_command_streaming(command, |_| {})
241}
242
243/// Command executor for running commands in shell sessions.
244pub struct CommandExecutor {
245    store: Arc<SessionStore>,
246}
247
248impl CommandExecutor {
249    /// Create a new command executor.
250    pub fn new(store: Arc<SessionStore>) -> Self {
251        Self { store }
252    }
253
254    /// Execute a command synchronously (blocking).
255    ///
256    /// This runs the command and waits for completion or timeout. Prefer
257    /// [`CommandExecutor::execute`] from async contexts — this blocking variant
258    /// must never be called directly on a tokio worker thread.
259    pub fn execute_sync(&self, command: &Command) -> Result<ExecutionResult> {
260        run_command(command)
261    }
262
263    /// Execute a command, keeping the async runtime responsive.
264    ///
265    /// The blocking work runs on a dedicated blocking thread via
266    /// `spawn_blocking`, so the tokio worker pool (and therefore `/health` and
267    /// the accept loop) is never starved by a slow or hung command. The
268    /// underlying [`run_command`] enforces its own timeout, so this always
269    /// completes without leaking runtime capacity.
270    pub async fn execute(&self, command: &Command) -> Result<ExecutionResult> {
271        let command = command.clone();
272        tokio::task::spawn_blocking(move || run_command(&command))
273            .await
274            .map_err(|e| ShellTunnelError::Pty(format!("execution task failed: {e}")))?
275    }
276
277    /// Execute a command asynchronously, streaming output chunks as they arrive.
278    ///
279    /// Returns a receiver that yields [`OutputChunk`]s live, plus a join handle
280    /// resolving to the final [`ExecutionResult`]. Backed by the same piped
281    /// [`run_command_streaming`] core as the non-streaming paths, so it inherits
282    /// real completion detection, enforceable timeout, and process-tree kill —
283    /// none of which the previous PTY implementation could provide for
284    /// non-interactive commands (see [`run_command_streaming`]).
285    pub async fn execute_async(
286        &self,
287        command: &Command,
288    ) -> Result<(
289        mpsc::Receiver<OutputChunk>,
290        tokio::task::JoinHandle<Result<ExecutionResult>>,
291    )> {
292        let (tx, rx) = mpsc::channel::<OutputChunk>(64);
293        let command = command.clone();
294
295        let handle = tokio::task::spawn_blocking(move || {
296            run_command_streaming(&command, |chunk| {
297                // Forward the chunk live; ignore if the receiver was dropped.
298                let _ = tx.blocking_send(OutputChunk::combined(chunk.to_vec()));
299            })
300        });
301
302        Ok((rx, handle))
303    }
304
305    /// Execute a command in an existing session.
306    pub async fn execute_in_session(
307        &self,
308        session_id: &crate::session::SessionId,
309        command: &Command,
310    ) -> Result<ExecutionResult> {
311        // Verify session exists and is executable
312        let session = self
313            .store
314            .get(session_id)?
315            .ok_or_else(|| ShellTunnelError::SessionNotFound(session_id.to_string()))?;
316
317        if !session.state.can_execute() {
318            return Err(ShellTunnelError::NotExecutable(session.state));
319        }
320
321        // Mark session as active
322        self.store.update(session_id, |s| {
323            let _ = s.state.transition_to(SessionState::Active);
324            s.touch();
325        })?;
326
327        // Execute command (off the async runtime workers)
328        let result = self.execute(command).await;
329
330        // Mark session as idle
331        self.store.update(session_id, |s| {
332            let _ = s.state.transition_to(SessionState::Idle);
333            s.touch();
334        })?;
335
336        result
337    }
338}
339
340/// Simple one-shot command execution.
341pub fn execute_simple(command_line: &str) -> Result<ExecutionResult> {
342    let cmd = Command::new(command_line);
343    let store = Arc::new(SessionStore::new());
344    let executor = CommandExecutor::new(store);
345    executor.execute_sync(&cmd)
346}
347
348/// Execute a command with timeout.
349pub fn execute_with_timeout(command_line: &str, timeout: Duration) -> Result<ExecutionResult> {
350    let cmd = Command::new(command_line).timeout(timeout);
351    let store = Arc::new(SessionStore::new());
352    let executor = CommandExecutor::new(store);
353    executor.execute_sync(&cmd)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_executor_new() {
362        let store = Arc::new(SessionStore::new());
363        let _executor = CommandExecutor::new(store);
364    }
365
366    #[test]
367    fn test_command_builder() {
368        let cmd = Command::new("echo hello")
369            .timeout(Duration::from_secs(5))
370            .capture_output(true);
371
372        assert_eq!(cmd.command_line, "echo hello");
373        assert_eq!(cmd.timeout, Some(Duration::from_secs(5)));
374    }
375
376    #[test]
377    #[ignore] // PTY tests need special handling
378    fn test_execute_simple_echo() {
379        let result = execute_simple("echo test").unwrap();
380        assert!(result.text_output.contains("test"));
381    }
382
383    #[test]
384    #[ignore] // PTY tests need special handling
385    fn test_execute_with_timeout() {
386        let result = execute_with_timeout("echo fast", Duration::from_secs(5)).unwrap();
387        assert!(!result.timed_out);
388    }
389
390    #[test]
391    fn test_default_timeout() {
392        assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(30));
393    }
394}