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