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}