Skip to main content

shell_tunnel/execution/
executor.rs

1//! Command execution engine.
2
3use std::process::Stdio;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use tokio::sync::mpsc;
8
9use super::command::Command;
10use super::pipe::PipeDrain;
11use super::result::{ExecutionResult, OutputChunk};
12use crate::error::ShellTunnelError;
13use crate::output::OutputSanitizer;
14use crate::process::{shell_command, KillGroup};
15use crate::session::{BusySession, SessionStore};
16use crate::Result;
17
18/// Default execution timeout.
19pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20
21/// The longest timeout a caller may ask for.
22///
23/// `docs/openapi.json` has declared `"maximum": 300` on `timeout_secs` since the
24/// route existed, and `security::validation::ValidationConfig` names the same
25/// figure — but nothing enforced either, so `timeout_secs: 999999999` was
26/// accepted and honoured. That is the published reference being false, which is
27/// the failure this repository has been bitten by repeatedly; the value here is
28/// the one the reference already promises rather than a new one invented to
29/// match the code.
30///
31/// It also bounds a resource that is not local to the request. One command holds
32/// one blocking thread for its whole deadline, the runtime is built with tokio's
33/// default blocking pool, and `AuditSink::record_async` and every filesystem
34/// route *await* `spawn_blocking` — so a saturated pool stalls routes that have
35/// nothing to do with the command holding it.
36///
37/// Clamped rather than refused, as [`MAX_OUTPUT_BYTES_CEILING`] is: the caller
38/// asked for "as long as possible", and `timed_out` plus `duration_ms` report
39/// what actually happened either way.
40pub const MAX_TIMEOUT: Duration = Duration::from_secs(300);
41
42/// The shortest timeout a caller may ask for.
43///
44/// The same declaration carries `"minimum": 1`, and it was equally unenforced:
45/// `timeout_secs: 0` produced a deadline that had already passed, so *every*
46/// command died on its first pass through the control loop having run nothing.
47/// A caller who sends zero has asked for the smallest timeout there is, so that
48/// is what they get — the alternative, treating zero as "no timeout", would read
49/// an opt-out into a field whose whole purpose is the opposite.
50pub const MIN_TIMEOUT: Duration = Duration::from_secs(1);
51
52/// How much output a command's result keeps, unless the caller asks for less.
53///
54/// Until 0.14.0 nothing bounded this: the only effective limit was the timeout,
55/// which bounds time rather than size, so a single `cat` of a large file was
56/// held whole in memory and then serialised into one JSON response. Behind a
57/// relay that response could not even be delivered.
58///
59/// 1 MiB sits well under every ceiling downstream of it, so a capped result
60/// behaves the same locally and across a relay — a limit that only bites on one
61/// path is worse than none, because it is discovered in production.
62///
63/// The cap governs what a *result* carries; a streaming consumer is not subject
64/// to it. That is not the same as receiving everything unconditionally — a
65/// consumer that stops draining its receiver can miss chunks produced after the
66/// command's deadline, because [`forward_chunk`] stops waiting on it there
67/// rather than letting a stalled reader hold the command past its timeout.
68/// `total_bytes` counts what the command produced either way.
69pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
70
71/// The largest cap a caller may ask for.
72///
73/// A request may lower [`DEFAULT_MAX_OUTPUT_BYTES`] or raise it to here, but
74/// not past it: the point of the cap is that a response stays deliverable, and
75/// a caller opting out entirely would restore exactly the failure it exists to
76/// prevent.
77pub const MAX_OUTPUT_BYTES_CEILING: u64 = 8 * 1024 * 1024;
78
79/// Poll interval for the non-blocking control loop.
80const CONTROL_POLL: Duration = Duration::from_millis(5);
81
82/// Hard backstop for collecting trailing output after the process has ended.
83/// Bounds the tail so a lingering grandchild that inherited a pipe cannot hold
84/// the return past this grace period.
85const COLLECT_GRACE: Duration = Duration::from_millis(500);
86
87/// Most bytes taken from one pipe in one pass of the control loop.
88///
89/// The loop must reach its `try_wait` and deadline checks on every pass, so a
90/// command producing output faster than it is consumed — `cat` of a large file,
91/// a build log — must not be able to keep the loop inside a drain. Large enough
92/// that streaming megabytes costs a handful of passes rather than thousands.
93const DRAIN_BUDGET: usize = 256 * 1024;
94
95/// Run a non-interactive command with an *enforceable* timeout.
96///
97/// This is the blocking core shared by both the sync and async entry points.
98///
99/// Non-interactive commands are executed via a piped [`std::process::Command`]
100/// rather than a PTY. This is deliberate: a PTY (Windows ConPTY in particular)
101/// does not signal EOF or report child exit for a one-shot command until the
102/// pseudoconsole itself is torn down, so there is no reliable way to tell when
103/// the command finished — every command would run to the full timeout, and each
104/// hung read leaked a `conhost.exe`. A piped child gives real EOF on pipe close
105/// and a working `try_wait()`/`kill()`, which is exactly what a deterministic
106/// "run command, capture output, get exit code, honor timeout" contract needs.
107/// (This is every path, streaming included: nothing here allocates a terminal.
108/// The crate's PTY module was removed in 0.20.0 having gone uncalled since this
109/// decision was made. A feature that genuinely needs a TTY brings one back —
110/// the reasons above are what it would have to answer for.)
111///
112/// Nothing here ever blocks on a `read()`, which is what lets one loop own
113/// everything:
114/// - both pipes are drained by [`PipeDrain`], which reads only what is already
115///   there. Draining both every pass is what keeps one pipe's buffer from
116///   filling — and deadlocking the child — while the other is attended to.
117/// - the same loop polls `try_wait()` and checks the deadline, so the timeout is
118///   actually honored. Each drain pass is bounded by [`DRAIN_BUDGET`] so a
119///   command producing output faster than it is read cannot postpone either.
120/// - the loop can therefore *give up* on a pipe. That is the property the
121///   previous design lacked: a dedicated reader thread per pipe had EOF as its
122///   only exit, EOF needs every write end closed, and a grandchild that
123///   inherited the pipes holds one open for as long as it runs. Those threads
124///   were never joined, so each such command leaked one — measured, unbounded,
125///   for the life of the process. Here the deadline below closes the read ends
126///   and returns; a pipe nobody is blocked on cannot be leaked.
127///
128/// Note what this does *not* claim by default: a surviving grandchild is still
129/// surviving. It keeps its inherited handles and keeps running, and on the
130/// success path nothing kills it — a background process a command deliberately
131/// started is not the server's to end unless the operator says so. What is
132/// guaranteed unconditionally is narrower, and is the part that belongs to this
133/// crate: **the server's own resources are released either way.**
134///
135/// `kill_orphans` is that operator's say-so ([`CommandExecutor::kill_orphans`],
136/// `--kill-orphans`). With it set, the group is reaped when it drops — which is
137/// after the collection loop below, so output the background process already
138/// wrote is still collected — and every exit path reaps alike, including an
139/// early `?` and the timeout branch.
140///
141/// `on_chunk` is invoked for every output chunk as it arrives, which is what
142/// lets the streaming (WebSocket) path forward output live; the non-streaming
143/// callers pass a no-op.
144fn run_command_streaming(
145    command: &Command,
146    kill_orphans: bool,
147    mut on_chunk: impl FnMut(&[u8]),
148) -> Result<ExecutionResult> {
149    let start = Instant::now();
150    let timeout_duration = command.effective_timeout();
151
152    let mut os_cmd = shell_command(&command.command_line);
153    os_cmd
154        .stdin(Stdio::null())
155        .stdout(Stdio::piped())
156        .stderr(Stdio::piped());
157    if let Some(dir) = &command.working_dir {
158        os_cmd.current_dir(dir);
159    }
160    for (key, value) in &command.env {
161        os_cmd.env(key, value);
162    }
163
164    // Group the child with everything it spawns so that on timeout we can kill
165    // the whole tree (a shell that spawned grandchildren) at once. `prepare`
166    // must run before the spawn and `adopt` immediately after it.
167    let kill_group = KillGroup::prepare(&mut os_cmd);
168    if kill_orphans {
169        // Set before the spawn can fail, so no exit path can skip it. Dropping
170        // the group is what reaps, and the group outlives the collection loop
171        // below — output a background process already wrote is still collected.
172        kill_group.reap_on_drop();
173    }
174
175    let mut child = os_cmd.spawn().map_err(ShellTunnelError::Io)?;
176    kill_group.adopt(&child);
177
178    // stdout and stderr are merged into one output stream. True interleaving is
179    // not guaranteed (nor is it with a TTY), but clients consume a single stream.
180    let mut out_pipe = child.stdout.take().map(PipeDrain::new);
181    let mut err_pipe = child.stderr.take().map(PipeDrain::new);
182
183    // Non-blocking control loop.
184    let cap = command
185        .max_output_bytes
186        .unwrap_or(DEFAULT_MAX_OUTPUT_BYTES)
187        .min(MAX_OUTPUT_BYTES_CEILING);
188    let mut raw_output = Vec::new();
189    let mut total_bytes: u64 = 0;
190    let mut exit_status = None;
191    let mut timed_out = false;
192
193    // Every chunk goes to `on_chunk` and counts toward `total_bytes`; only what
194    // fits under the cap is kept. Streaming consumers therefore still see the
195    // whole stream — the cap governs the collected result, not the pipe — and
196    // `total_bytes` stays the true figure rather than the kept one.
197    //
198    // Draining continues after the cap is reached rather than stopping: the
199    // pipes must keep being emptied, or a child writing more than the cap would
200    // block on a full pipe buffer and never exit.
201    let mut absorb = |chunk: &[u8], raw_output: &mut Vec<u8>, total: &mut u64| {
202        on_chunk(chunk);
203        *total += chunk.len() as u64;
204        let kept = raw_output.len() as u64;
205        if kept < cap {
206            let room = (cap - kept) as usize;
207            let take = room.min(chunk.len());
208            raw_output.extend_from_slice(&chunk[..take]);
209        }
210    };
211
212    // Both pipes, one bounded pass each. Returns how many bytes moved, which is
213    // what tells the caller whether there is any point sleeping before the next
214    // pass.
215    macro_rules! drain_pass {
216        () => {{
217            let mut moved = 0;
218            if let Some(pipe) = out_pipe.as_mut() {
219                moved += pipe.drain(DRAIN_BUDGET, &mut |chunk: &[u8]| {
220                    absorb(chunk, &mut raw_output, &mut total_bytes)
221                });
222            }
223            if let Some(pipe) = err_pipe.as_mut() {
224                moved += pipe.drain(DRAIN_BUDGET, &mut |chunk: &[u8]| {
225                    absorb(chunk, &mut raw_output, &mut total_bytes)
226                });
227            }
228            moved
229        }};
230    }
231
232    /// Whether every pipe has reached its end and nothing more can arrive.
233    macro_rules! pipes_ended {
234        () => {
235            out_pipe.as_ref().map_or(true, |p| p.finished())
236                && err_pipe.as_ref().map_or(true, |p| p.finished())
237        };
238    }
239
240    loop {
241        let moved = drain_pass!();
242
243        match child.try_wait() {
244            Ok(Some(status)) => {
245                exit_status = Some(status);
246                break;
247            }
248            Ok(None) => {}
249            Err(e) => return Err(ShellTunnelError::Io(e)),
250        }
251
252        if start.elapsed() >= timeout_duration {
253            timed_out = true;
254            // Kill the whole tree: `cmd /c ...` / `sh -c ...` may have spawned
255            // grandchildren that would otherwise keep the output pipes open and
256            // stall our collection below (and keep running as orphans).
257            kill_group.kill();
258            let _ = child.wait();
259            break;
260        }
261
262        // Only idle when there was nothing to move. A command mid-flood is
263        // served every pass instead of being metered at one pass per interval.
264        if moved == 0 {
265            std::thread::sleep(CONTROL_POLL);
266        }
267    }
268
269    // Collect the tail. Both pipes end on their own once every write end has
270    // closed — normally as the child exits, and on timeout once the kill group
271    // has taken the tree with it. The grace deadline is the backstop for the case
272    // that has no other end: a grandchild inherited these pipes and is still
273    // holding them open. Reaching it closes our read ends and returns.
274    //
275    // That last step is the fix. The reader threads this replaced could not be
276    // told to stop — they were blocked in `read()` on a pipe held by a process
277    // this crate does not own, and dropping their `JoinHandle`s (which is all
278    // the old code could do) detached them rather than ending them. One thread
279    // and one handle leaked per such command, for the life of the server.
280    let collect_deadline = Instant::now() + COLLECT_GRACE;
281    loop {
282        let moved = drain_pass!();
283
284        if pipes_ended!() {
285            break;
286        }
287        if Instant::now() >= collect_deadline {
288            if let Some(pipe) = out_pipe.as_mut() {
289                pipe.release();
290            }
291            if let Some(pipe) = err_pipe.as_mut() {
292                pipe.release();
293            }
294            break;
295        }
296        if moved == 0 {
297            std::thread::sleep(CONTROL_POLL);
298        }
299    }
300
301    let duration = start.elapsed();
302    let text = OutputSanitizer::strip_ansi(&raw_output);
303    let truncated = total_bytes > raw_output.len() as u64;
304
305    if timed_out {
306        return Ok(ExecutionResult::timeout(raw_output, text, duration)
307            .with_output_extent(total_bytes, truncated));
308    }
309
310    let exit_code = exit_status.and_then(|s| s.code());
311    let mut result =
312        ExecutionResult::new(raw_output, text, duration).with_output_extent(total_bytes, truncated);
313    if let Some(code) = exit_code {
314        result = result.with_exit_code(code);
315    }
316    Ok(result)
317}
318
319/// Run a non-interactive command, collecting all output (no streaming).
320fn run_command(command: &Command, kill_orphans: bool) -> Result<ExecutionResult> {
321    run_command_streaming(command, kill_orphans, |_| {})
322}
323
324/// How long to nap between attempts at a full channel.
325const FORWARD_RETRY: Duration = Duration::from_millis(2);
326
327/// Hand one chunk to a streaming consumer, waiting while the channel is full —
328/// but never past `stop_waiting_at`.
329///
330/// This is called from inside [`run_command_streaming`]'s control loop, the same
331/// loop that checks the deadline and reaps the child. Anything that parks here
332/// parks those checks too, which is why an unbounded `blocking_send` was wrong:
333/// a consumer that stopped receiving without dropping its receiver left the
334/// child running past its timeout and a blocking thread parked for good.
335///
336/// Backpressure is preserved for a consumer that is merely slow — it only stops
337/// applying once the command has outlived the window in which it could still
338/// have been delivered, and at that point the control loop is about to kill the
339/// tree anyway. Chunks dropped here are still counted in `total_bytes` and still
340/// collected into the result under its cap; only the live stream loses them, and
341/// only for a consumer that is no longer reading it.
342fn forward_chunk(tx: &mpsc::Sender<OutputChunk>, chunk: &[u8], stop_waiting_at: Instant) {
343    let mut pending = OutputChunk::combined(chunk.to_vec());
344    loop {
345        match tx.try_send(pending) {
346            Ok(()) => return,
347            Err(mpsc::error::TrySendError::Closed(_)) => return,
348            Err(mpsc::error::TrySendError::Full(returned)) => {
349                if Instant::now() >= stop_waiting_at {
350                    return;
351                }
352                pending = returned;
353                std::thread::sleep(FORWARD_RETRY);
354            }
355        }
356    }
357}
358
359/// Command executor for running commands in shell sessions.
360pub struct CommandExecutor {
361    store: Arc<SessionStore>,
362    kill_orphans: bool,
363}
364
365impl CommandExecutor {
366    /// Create a new command executor.
367    ///
368    /// Commands run under it leave their background processes running; see
369    /// [`kill_orphans`](Self::kill_orphans) to change that.
370    pub fn new(store: Arc<SessionStore>) -> Self {
371        Self {
372            store,
373            kill_orphans: false,
374        }
375    }
376
377    /// Kill whatever a command leaves running when the command ends.
378    ///
379    /// This is server policy rather than a property of any one request, which is
380    /// why it lives here and not on [`Command`]: the same command line means the
381    /// same thing whoever sends it, and what changes is the machine's rule about
382    /// what may outlive a request. Consumers of this crate opt in the same way
383    /// the binary does, with `--kill-orphans`.
384    ///
385    /// Off by default. A command that deliberately starts a daemon expects it to
386    /// survive, so switching this on by default would break working callers
387    /// silently — the reason it is a flag at all.
388    pub fn kill_orphans(mut self, kill: bool) -> Self {
389        self.kill_orphans = kill;
390        self
391    }
392
393    /// Execute a command synchronously (blocking).
394    ///
395    /// This runs the command and waits for completion or timeout. Prefer
396    /// [`CommandExecutor::execute`] from async contexts — this blocking variant
397    /// must never be called directly on a tokio worker thread.
398    pub fn execute_sync(&self, command: &Command) -> Result<ExecutionResult> {
399        run_command(command, self.kill_orphans)
400    }
401
402    /// Execute a command, keeping the async runtime responsive.
403    ///
404    /// The blocking work runs on a dedicated blocking thread via
405    /// `spawn_blocking`, so the tokio worker pool (and therefore `/health` and
406    /// the accept loop) is never starved by a slow or hung command. The
407    /// underlying [`run_command`] enforces its own timeout, so this always
408    /// completes without leaking runtime capacity.
409    pub async fn execute(&self, command: &Command) -> Result<ExecutionResult> {
410        let command = command.clone();
411        let kill_orphans = self.kill_orphans;
412        tokio::task::spawn_blocking(move || run_command(&command, kill_orphans))
413            .await
414            .map_err(|e| ShellTunnelError::Pty(format!("execution task failed: {e}")))?
415    }
416
417    /// Execute a command asynchronously, streaming output chunks as they arrive.
418    ///
419    /// Returns a receiver that yields [`OutputChunk`]s live, plus a join handle
420    /// resolving to the final [`ExecutionResult`]. Backed by the same piped
421    /// [`run_command_streaming`] core as the non-streaming paths, so it inherits
422    /// real completion detection, enforceable timeout, and process-tree kill —
423    /// none of which the previous PTY implementation could provide for
424    /// non-interactive commands (see [`run_command_streaming`]).
425    ///
426    /// **A consumer that stops receiving should drop the receiver.** Holding it
427    /// while awaiting the join handle is a deadlock in waiting: the channel is
428    /// bounded, and the producer runs inside the control loop that enforces the
429    /// timeout, so a full channel stops that loop from checking anything. Both
430    /// WebSocket handlers used to do exactly this when their client hung up, and
431    /// the command then outlived its own timeout — verified by watching a child
432    /// with a five-second timeout run to completion.
433    ///
434    /// Dropping the receiver frees the producer immediately. As a backstop for
435    /// the consumer that forgets, forwarding gives up once the command's own
436    /// deadline has passed — timeout enforcement is a guarantee of this crate,
437    /// not something each consumer re-earns.
438    pub async fn execute_async(
439        &self,
440        command: &Command,
441    ) -> Result<(
442        mpsc::Receiver<OutputChunk>,
443        tokio::task::JoinHandle<Result<ExecutionResult>>,
444    )> {
445        let (tx, rx) = mpsc::channel::<OutputChunk>(64);
446        let command = command.clone();
447        let kill_orphans = self.kill_orphans;
448        // The same deadline the blocking core will enforce, from the same place
449        // it gets it — see `Command::effective_timeout` for why that matters
450        // here in particular.
451        let budget = command.effective_timeout();
452
453        let handle = tokio::task::spawn_blocking(move || {
454            // Past this instant the command is due to be killed anyway, so no
455            // chunk is worth waiting on: see `forward_chunk`.
456            let stop_waiting_at = Instant::now() + budget;
457            run_command_streaming(&command, kill_orphans, |chunk| {
458                forward_chunk(&tx, chunk, stop_waiting_at);
459            })
460        });
461
462        Ok((rx, handle))
463    }
464
465    /// Execute a command in an existing session.
466    pub async fn execute_in_session(
467        &self,
468        session_id: &crate::session::SessionId,
469        command: &Command,
470    ) -> Result<ExecutionResult> {
471        // Verify session exists and is executable
472        let session = self
473            .store
474            .get(session_id)?
475            .ok_or_else(|| ShellTunnelError::SessionNotFound(session_id.to_string()))?;
476
477        if !session.state.can_execute() {
478            return Err(ShellTunnelError::NotExecutable(session.state));
479        }
480
481        // Busy for as long as the guard lives. Held rather than written as a
482        // pair of transitions because the await below may never resume: a
483        // caller that hangs up mid-command has axum drop this future, and a
484        // hand-written "back to idle" line after the await would never run.
485        let _busy = BusySession::begin(&self.store, session_id)?;
486
487        // Execute command (off the async runtime workers)
488        self.execute(command).await
489    }
490}
491
492/// Simple one-shot command execution.
493pub fn execute_simple(command_line: &str) -> Result<ExecutionResult> {
494    let cmd = Command::new(command_line);
495    let store = Arc::new(SessionStore::new());
496    let executor = CommandExecutor::new(store);
497    executor.execute_sync(&cmd)
498}
499
500/// Execute a command with timeout.
501pub fn execute_with_timeout(command_line: &str, timeout: Duration) -> Result<ExecutionResult> {
502    let cmd = Command::new(command_line).timeout(timeout);
503    let store = Arc::new(SessionStore::new());
504    let executor = CommandExecutor::new(store);
505    executor.execute_sync(&cmd)
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_executor_new() {
514        let store = Arc::new(SessionStore::new());
515        let _executor = CommandExecutor::new(store);
516    }
517
518    #[test]
519    fn test_command_builder() {
520        let cmd = Command::new("echo hello")
521            .timeout(Duration::from_secs(5))
522            .capture_output(true);
523
524        assert_eq!(cmd.command_line, "echo hello");
525        assert_eq!(cmd.timeout, Some(Duration::from_secs(5)));
526    }
527
528    /// Ignored until 0.20.0 as "PTY tests need special handling" — a label left
529    /// over from before execution moved to pipes. Nothing on this path has
530    /// allocated a terminal since, and the PTY module it named is gone; both of
531    /// these run in a fresh `cmd /c` / `sh -c` like every other execute. Ran
532    /// green under `--ignored` before the gate came off.
533    #[test]
534    fn test_execute_simple_echo() {
535        let result = execute_simple("echo test").unwrap();
536        assert!(result.text_output.contains("test"));
537    }
538
539    #[test]
540    fn test_execute_with_timeout() {
541        let result = execute_with_timeout("echo fast", Duration::from_secs(5)).unwrap();
542        assert!(!result.timed_out);
543    }
544
545    #[test]
546    fn test_default_timeout() {
547        assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(30));
548    }
549}