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::{BusySession, 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 consumer is not subject
34/// to it. That is not the same as receiving everything unconditionally — a
35/// consumer that stops draining its receiver can miss chunks produced after the
36/// command's deadline, because [`forward_chunk`] stops waiting on it there
37/// rather than letting a stalled reader hold the command past its timeout.
38/// `total_bytes` counts what the command produced either way.
39pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
40
41/// The largest cap a caller may ask for.
42///
43/// A request may lower [`DEFAULT_MAX_OUTPUT_BYTES`] or raise it to here, but
44/// not past it: the point of the cap is that a response stays deliverable, and
45/// a caller opting out entirely would restore exactly the failure it exists to
46/// prevent.
47pub const MAX_OUTPUT_BYTES_CEILING: u64 = 8 * 1024 * 1024;
48
49/// Default buffer size for reading process output.
50const READ_BUFFER_SIZE: usize = 4096;
51
52/// Poll interval for the non-blocking control loop.
53const CONTROL_POLL: Duration = Duration::from_millis(5);
54
55/// Hard backstop for collecting trailing output after the process has ended.
56/// Bounds the tail so a lingering grandchild that inherited a pipe cannot block
57/// the return past this grace period.
58const COLLECT_GRACE: Duration = Duration::from_millis(500);
59
60/// Spawn a reader thread that pumps a pipe into `tx` until EOF.
61fn spawn_pipe_reader<R: Read + Send + 'static>(
62    mut reader: R,
63    tx: std_mpsc::Sender<Vec<u8>>,
64) -> std::thread::JoinHandle<()> {
65    std::thread::spawn(move || {
66        let mut buf = [0u8; READ_BUFFER_SIZE];
67        loop {
68            match reader.read(&mut buf) {
69                Ok(0) => break, // EOF: the process closed this pipe
70                Ok(n) => {
71                    if tx.send(buf[..n].to_vec()).is_err() {
72                        break; // control side went away
73                    }
74                }
75                Err(_) => break, // broken pipe / closed handle
76            }
77        }
78    })
79}
80
81/// Run a non-interactive command with an *enforceable* timeout.
82///
83/// This is the blocking core shared by both the sync and async entry points.
84///
85/// Non-interactive commands are executed via a piped [`std::process::Command`]
86/// rather than a PTY. This is deliberate: a PTY (Windows ConPTY in particular)
87/// does not signal EOF or report child exit for a one-shot command until the
88/// pseudoconsole itself is torn down, so there is no reliable way to tell when
89/// the command finished — every command would run to the full timeout, and each
90/// hung read leaked a `conhost.exe`. A piped child gives real EOF on pipe close
91/// and a working `try_wait()`/`kill()`, which is exactly what a deterministic
92/// "run command, capture output, get exit code, honor timeout" contract needs.
93/// (This is every path, streaming included: nothing here allocates a terminal.
94/// The crate's PTY module was removed in 0.20.0 having gone uncalled since this
95/// decision was made. A feature that genuinely needs a TTY brings one back —
96/// the reasons above are what it would have to answer for.)
97///
98/// The design keeps a blocking `read()` from ever stalling progress:
99/// - stdout and stderr are each pumped by a dedicated reader thread (reading
100///   only one while the other's pipe buffer fills would deadlock the child).
101/// - the control loop here is fully non-blocking: it drains the channel, polls
102///   `try_wait()`, and checks the deadline, so the timeout is actually honored.
103/// - on timeout the child is killed; both pipes then close and the reader
104///   threads reach EOF, so nothing leaks.
105///
106/// `on_chunk` is invoked for every output chunk as it arrives, which is what
107/// lets the streaming (WebSocket) path forward output live; the non-streaming
108/// callers pass a no-op.
109fn run_command_streaming(
110    command: &Command,
111    mut on_chunk: impl FnMut(&[u8]),
112) -> Result<ExecutionResult> {
113    let start = Instant::now();
114    let timeout_duration = command.timeout.unwrap_or(DEFAULT_TIMEOUT);
115
116    let mut os_cmd = shell_command(&command.command_line);
117    os_cmd
118        .stdin(Stdio::null())
119        .stdout(Stdio::piped())
120        .stderr(Stdio::piped());
121    if let Some(dir) = &command.working_dir {
122        os_cmd.current_dir(dir);
123    }
124    for (key, value) in &command.env {
125        os_cmd.env(key, value);
126    }
127
128    // Put the child in its own process group so that on timeout we can signal
129    // the whole tree (a shell that spawned grandchildren) at once.
130    detach_process_group(&mut os_cmd);
131
132    let mut child = os_cmd.spawn().map_err(ShellTunnelError::Io)?;
133    let child_pid = child.id();
134
135    // stdout and stderr are merged into one output stream. True interleaving is
136    // not guaranteed (nor is it with a TTY), but clients consume a single stream.
137    let stdout = child.stdout.take();
138    let stderr = child.stderr.take();
139    let (tx, rx) = std_mpsc::channel::<Vec<u8>>();
140    let out_handle = stdout.map(|s| spawn_pipe_reader(s, tx.clone()));
141    let err_handle = stderr.map(|s| spawn_pipe_reader(s, tx));
142
143    // Non-blocking control loop.
144    let cap = command
145        .max_output_bytes
146        .unwrap_or(DEFAULT_MAX_OUTPUT_BYTES)
147        .min(MAX_OUTPUT_BYTES_CEILING);
148    let mut raw_output = Vec::new();
149    let mut total_bytes: u64 = 0;
150    let mut exit_status = None;
151    let mut timed_out = false;
152
153    // Every chunk goes to `on_chunk` and counts toward `total_bytes`; only what
154    // fits under the cap is kept. Streaming consumers therefore still see the
155    // whole stream — the cap governs the collected result, not the pipe — and
156    // `total_bytes` stays the true figure rather than the kept one.
157    //
158    // Draining continues after the cap is reached rather than stopping: the
159    // reader threads must keep emptying the pipes, or a child writing more than
160    // the cap would block on a full pipe buffer and never exit.
161    let mut absorb = |chunk: &[u8], raw_output: &mut Vec<u8>, total: &mut u64| {
162        on_chunk(chunk);
163        *total += chunk.len() as u64;
164        let kept = raw_output.len() as u64;
165        if kept < cap {
166            let room = (cap - kept) as usize;
167            let take = room.min(chunk.len());
168            raw_output.extend_from_slice(&chunk[..take]);
169        }
170    };
171
172    loop {
173        while let Ok(chunk) = rx.try_recv() {
174            absorb(&chunk, &mut raw_output, &mut total_bytes);
175        }
176
177        match child.try_wait() {
178            Ok(Some(status)) => {
179                exit_status = Some(status);
180                break;
181            }
182            Ok(None) => {}
183            Err(e) => return Err(ShellTunnelError::Io(e)),
184        }
185
186        if start.elapsed() >= timeout_duration {
187            timed_out = true;
188            // Kill the whole tree: `cmd /c ...` / `sh -c ...` may have spawned
189            // grandchildren that would otherwise keep the output pipes open and
190            // stall our collection below (and keep running as orphans).
191            kill_tree(child_pid);
192            let _ = child.wait();
193            break;
194        }
195
196        std::thread::sleep(CONTROL_POLL);
197    }
198
199    // Collect any remaining output. Once the process (and, on timeout, its whole
200    // tree) is gone, both pipe handles close, the reader threads reach EOF and
201    // drop their senders, and `recv_timeout` returns `Disconnected`. The grace
202    // deadline is a hard backstop so a stray grandchild that inherited a pipe
203    // can never block us — we return the timed-out result regardless.
204    drop(out_handle);
205    drop(err_handle);
206    let collect_deadline = Instant::now() + COLLECT_GRACE;
207    loop {
208        match rx.recv_timeout(Duration::from_millis(20)) {
209            Ok(chunk) => absorb(&chunk, &mut raw_output, &mut total_bytes),
210            Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
211            Err(std_mpsc::RecvTimeoutError::Timeout) => {
212                if Instant::now() >= collect_deadline {
213                    break;
214                }
215            }
216        }
217    }
218
219    let duration = start.elapsed();
220    let text = OutputSanitizer::strip_ansi(&raw_output);
221    let truncated = total_bytes > raw_output.len() as u64;
222
223    if timed_out {
224        return Ok(ExecutionResult::timeout(raw_output, text, duration)
225            .with_output_extent(total_bytes, truncated));
226    }
227
228    let exit_code = exit_status.and_then(|s| s.code());
229    let mut result =
230        ExecutionResult::new(raw_output, text, duration).with_output_extent(total_bytes, truncated);
231    if let Some(code) = exit_code {
232        result = result.with_exit_code(code);
233    }
234    Ok(result)
235}
236
237/// Run a non-interactive command, collecting all output (no streaming).
238fn run_command(command: &Command) -> Result<ExecutionResult> {
239    run_command_streaming(command, |_| {})
240}
241
242/// How long to nap between attempts at a full channel.
243const FORWARD_RETRY: Duration = Duration::from_millis(2);
244
245/// Hand one chunk to a streaming consumer, waiting while the channel is full —
246/// but never past `stop_waiting_at`.
247///
248/// This is called from inside [`run_command_streaming`]'s control loop, the same
249/// loop that checks the deadline and reaps the child. Anything that parks here
250/// parks those checks too, which is why an unbounded `blocking_send` was wrong:
251/// a consumer that stopped receiving without dropping its receiver left the
252/// child running past its timeout and a blocking thread parked for good.
253///
254/// Backpressure is preserved for a consumer that is merely slow — it only stops
255/// applying once the command has outlived the window in which it could still
256/// have been delivered, and at that point the control loop is about to kill the
257/// tree anyway. Chunks dropped here are still counted in `total_bytes` and still
258/// collected into the result under its cap; only the live stream loses them, and
259/// only for a consumer that is no longer reading it.
260fn forward_chunk(tx: &mpsc::Sender<OutputChunk>, chunk: &[u8], stop_waiting_at: Instant) {
261    let mut pending = OutputChunk::combined(chunk.to_vec());
262    loop {
263        match tx.try_send(pending) {
264            Ok(()) => return,
265            Err(mpsc::error::TrySendError::Closed(_)) => return,
266            Err(mpsc::error::TrySendError::Full(returned)) => {
267                if Instant::now() >= stop_waiting_at {
268                    return;
269                }
270                pending = returned;
271                std::thread::sleep(FORWARD_RETRY);
272            }
273        }
274    }
275}
276
277/// Command executor for running commands in shell sessions.
278pub struct CommandExecutor {
279    store: Arc<SessionStore>,
280}
281
282impl CommandExecutor {
283    /// Create a new command executor.
284    pub fn new(store: Arc<SessionStore>) -> Self {
285        Self { store }
286    }
287
288    /// Execute a command synchronously (blocking).
289    ///
290    /// This runs the command and waits for completion or timeout. Prefer
291    /// [`CommandExecutor::execute`] from async contexts — this blocking variant
292    /// must never be called directly on a tokio worker thread.
293    pub fn execute_sync(&self, command: &Command) -> Result<ExecutionResult> {
294        run_command(command)
295    }
296
297    /// Execute a command, keeping the async runtime responsive.
298    ///
299    /// The blocking work runs on a dedicated blocking thread via
300    /// `spawn_blocking`, so the tokio worker pool (and therefore `/health` and
301    /// the accept loop) is never starved by a slow or hung command. The
302    /// underlying [`run_command`] enforces its own timeout, so this always
303    /// completes without leaking runtime capacity.
304    pub async fn execute(&self, command: &Command) -> Result<ExecutionResult> {
305        let command = command.clone();
306        tokio::task::spawn_blocking(move || run_command(&command))
307            .await
308            .map_err(|e| ShellTunnelError::Pty(format!("execution task failed: {e}")))?
309    }
310
311    /// Execute a command asynchronously, streaming output chunks as they arrive.
312    ///
313    /// Returns a receiver that yields [`OutputChunk`]s live, plus a join handle
314    /// resolving to the final [`ExecutionResult`]. Backed by the same piped
315    /// [`run_command_streaming`] core as the non-streaming paths, so it inherits
316    /// real completion detection, enforceable timeout, and process-tree kill —
317    /// none of which the previous PTY implementation could provide for
318    /// non-interactive commands (see [`run_command_streaming`]).
319    ///
320    /// **A consumer that stops receiving should drop the receiver.** Holding it
321    /// while awaiting the join handle is a deadlock in waiting: the channel is
322    /// bounded, and the producer runs inside the control loop that enforces the
323    /// timeout, so a full channel stops that loop from checking anything. Both
324    /// WebSocket handlers used to do exactly this when their client hung up, and
325    /// the command then outlived its own timeout — verified by watching a child
326    /// with a five-second timeout run to completion.
327    ///
328    /// Dropping the receiver frees the producer immediately. As a backstop for
329    /// the consumer that forgets, forwarding gives up once the command's own
330    /// deadline has passed — timeout enforcement is a guarantee of this crate,
331    /// not something each consumer re-earns.
332    pub async fn execute_async(
333        &self,
334        command: &Command,
335    ) -> Result<(
336        mpsc::Receiver<OutputChunk>,
337        tokio::task::JoinHandle<Result<ExecutionResult>>,
338    )> {
339        let (tx, rx) = mpsc::channel::<OutputChunk>(64);
340        let command = command.clone();
341        let budget = command.timeout.unwrap_or(DEFAULT_TIMEOUT);
342
343        let handle = tokio::task::spawn_blocking(move || {
344            // Past this instant the command is due to be killed anyway, so no
345            // chunk is worth waiting on: see `forward_chunk`.
346            let stop_waiting_at = Instant::now() + budget;
347            run_command_streaming(&command, |chunk| {
348                forward_chunk(&tx, chunk, stop_waiting_at);
349            })
350        });
351
352        Ok((rx, handle))
353    }
354
355    /// Execute a command in an existing session.
356    pub async fn execute_in_session(
357        &self,
358        session_id: &crate::session::SessionId,
359        command: &Command,
360    ) -> Result<ExecutionResult> {
361        // Verify session exists and is executable
362        let session = self
363            .store
364            .get(session_id)?
365            .ok_or_else(|| ShellTunnelError::SessionNotFound(session_id.to_string()))?;
366
367        if !session.state.can_execute() {
368            return Err(ShellTunnelError::NotExecutable(session.state));
369        }
370
371        // Busy for as long as the guard lives. Held rather than written as a
372        // pair of transitions because the await below may never resume: a
373        // caller that hangs up mid-command has axum drop this future, and a
374        // hand-written "back to idle" line after the await would never run.
375        let _busy = BusySession::begin(&self.store, session_id)?;
376
377        // Execute command (off the async runtime workers)
378        self.execute(command).await
379    }
380}
381
382/// Simple one-shot command execution.
383pub fn execute_simple(command_line: &str) -> Result<ExecutionResult> {
384    let cmd = Command::new(command_line);
385    let store = Arc::new(SessionStore::new());
386    let executor = CommandExecutor::new(store);
387    executor.execute_sync(&cmd)
388}
389
390/// Execute a command with timeout.
391pub fn execute_with_timeout(command_line: &str, timeout: Duration) -> Result<ExecutionResult> {
392    let cmd = Command::new(command_line).timeout(timeout);
393    let store = Arc::new(SessionStore::new());
394    let executor = CommandExecutor::new(store);
395    executor.execute_sync(&cmd)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_executor_new() {
404        let store = Arc::new(SessionStore::new());
405        let _executor = CommandExecutor::new(store);
406    }
407
408    #[test]
409    fn test_command_builder() {
410        let cmd = Command::new("echo hello")
411            .timeout(Duration::from_secs(5))
412            .capture_output(true);
413
414        assert_eq!(cmd.command_line, "echo hello");
415        assert_eq!(cmd.timeout, Some(Duration::from_secs(5)));
416    }
417
418    /// Ignored until 0.20.0 as "PTY tests need special handling" — a label left
419    /// over from before execution moved to pipes. Nothing on this path has
420    /// allocated a terminal since, and the PTY module it named is gone; both of
421    /// these run in a fresh `cmd /c` / `sh -c` like every other execute. Ran
422    /// green under `--ignored` before the gate came off.
423    #[test]
424    fn test_execute_simple_echo() {
425        let result = execute_simple("echo test").unwrap();
426        assert!(result.text_output.contains("test"));
427    }
428
429    #[test]
430    fn test_execute_with_timeout() {
431        let result = execute_with_timeout("echo fast", Duration::from_secs(5)).unwrap();
432        assert!(!result.timed_out);
433    }
434
435    #[test]
436    fn test_default_timeout() {
437        assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(30));
438    }
439}