Skip to main content

shell_tunnel/execution/
executor.rs

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