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}