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}