use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use super::command::Command;
use super::pipe::PipeDrain;
use super::result::{ExecutionResult, OutputChunk};
use crate::error::ShellTunnelError;
use crate::output::OutputSanitizer;
use crate::process::{shell_command, KillGroup};
use crate::session::{BusySession, SessionStore};
use crate::Result;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub const MAX_TIMEOUT: Duration = Duration::from_secs(300);
pub const MIN_TIMEOUT: Duration = Duration::from_secs(1);
pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
pub const MAX_OUTPUT_BYTES_CEILING: u64 = 8 * 1024 * 1024;
const CONTROL_POLL: Duration = Duration::from_millis(5);
const COLLECT_GRACE: Duration = Duration::from_millis(500);
const DRAIN_BUDGET: usize = 256 * 1024;
fn run_command_streaming(
command: &Command,
kill_orphans: bool,
mut on_chunk: impl FnMut(&[u8]),
) -> Result<ExecutionResult> {
let start = Instant::now();
let timeout_duration = command.effective_timeout();
let mut os_cmd = shell_command(&command.command_line);
os_cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = &command.working_dir {
os_cmd.current_dir(dir);
}
for (key, value) in &command.env {
os_cmd.env(key, value);
}
let kill_group = KillGroup::prepare(&mut os_cmd);
if kill_orphans {
kill_group.reap_on_drop();
}
let mut child = os_cmd.spawn().map_err(ShellTunnelError::Io)?;
kill_group.adopt(&child);
let mut out_pipe = child.stdout.take().map(PipeDrain::new);
let mut err_pipe = child.stderr.take().map(PipeDrain::new);
let cap = command
.max_output_bytes
.unwrap_or(DEFAULT_MAX_OUTPUT_BYTES)
.min(MAX_OUTPUT_BYTES_CEILING);
let mut raw_output = Vec::new();
let mut total_bytes: u64 = 0;
let mut exit_status = None;
let mut timed_out = false;
let mut absorb = |chunk: &[u8], raw_output: &mut Vec<u8>, total: &mut u64| {
on_chunk(chunk);
*total += chunk.len() as u64;
let kept = raw_output.len() as u64;
if kept < cap {
let room = (cap - kept) as usize;
let take = room.min(chunk.len());
raw_output.extend_from_slice(&chunk[..take]);
}
};
macro_rules! drain_pass {
() => {{
let mut moved = 0;
if let Some(pipe) = out_pipe.as_mut() {
moved += pipe.drain(DRAIN_BUDGET, &mut |chunk: &[u8]| {
absorb(chunk, &mut raw_output, &mut total_bytes)
});
}
if let Some(pipe) = err_pipe.as_mut() {
moved += pipe.drain(DRAIN_BUDGET, &mut |chunk: &[u8]| {
absorb(chunk, &mut raw_output, &mut total_bytes)
});
}
moved
}};
}
macro_rules! pipes_ended {
() => {
out_pipe.as_ref().map_or(true, |p| p.finished())
&& err_pipe.as_ref().map_or(true, |p| p.finished())
};
}
loop {
let moved = drain_pass!();
match child.try_wait() {
Ok(Some(status)) => {
exit_status = Some(status);
break;
}
Ok(None) => {}
Err(e) => return Err(ShellTunnelError::Io(e)),
}
if start.elapsed() >= timeout_duration {
timed_out = true;
kill_group.kill();
let _ = child.wait();
break;
}
if moved == 0 {
std::thread::sleep(CONTROL_POLL);
}
}
let collect_deadline = Instant::now() + COLLECT_GRACE;
loop {
let moved = drain_pass!();
if pipes_ended!() {
break;
}
if Instant::now() >= collect_deadline {
if let Some(pipe) = out_pipe.as_mut() {
pipe.release();
}
if let Some(pipe) = err_pipe.as_mut() {
pipe.release();
}
break;
}
if moved == 0 {
std::thread::sleep(CONTROL_POLL);
}
}
let duration = start.elapsed();
let text = OutputSanitizer::strip_ansi(&raw_output);
let truncated = total_bytes > raw_output.len() as u64;
if timed_out {
return Ok(ExecutionResult::timeout(raw_output, text, duration)
.with_output_extent(total_bytes, truncated));
}
let exit_code = exit_status.and_then(|s| s.code());
let mut result =
ExecutionResult::new(raw_output, text, duration).with_output_extent(total_bytes, truncated);
if let Some(code) = exit_code {
result = result.with_exit_code(code);
}
Ok(result)
}
fn run_command(command: &Command, kill_orphans: bool) -> Result<ExecutionResult> {
run_command_streaming(command, kill_orphans, |_| {})
}
const FORWARD_RETRY: Duration = Duration::from_millis(2);
fn forward_chunk(tx: &mpsc::Sender<OutputChunk>, chunk: &[u8], stop_waiting_at: Instant) {
let mut pending = OutputChunk::combined(chunk.to_vec());
loop {
match tx.try_send(pending) {
Ok(()) => return,
Err(mpsc::error::TrySendError::Closed(_)) => return,
Err(mpsc::error::TrySendError::Full(returned)) => {
if Instant::now() >= stop_waiting_at {
return;
}
pending = returned;
std::thread::sleep(FORWARD_RETRY);
}
}
}
}
pub struct CommandExecutor {
store: Arc<SessionStore>,
kill_orphans: bool,
}
impl CommandExecutor {
pub fn new(store: Arc<SessionStore>) -> Self {
Self {
store,
kill_orphans: false,
}
}
pub fn kill_orphans(mut self, kill: bool) -> Self {
self.kill_orphans = kill;
self
}
pub fn execute_sync(&self, command: &Command) -> Result<ExecutionResult> {
run_command(command, self.kill_orphans)
}
pub async fn execute(&self, command: &Command) -> Result<ExecutionResult> {
let command = command.clone();
let kill_orphans = self.kill_orphans;
tokio::task::spawn_blocking(move || run_command(&command, kill_orphans))
.await
.map_err(|e| ShellTunnelError::Pty(format!("execution task failed: {e}")))?
}
pub async fn execute_async(
&self,
command: &Command,
) -> Result<(
mpsc::Receiver<OutputChunk>,
tokio::task::JoinHandle<Result<ExecutionResult>>,
)> {
let (tx, rx) = mpsc::channel::<OutputChunk>(64);
let command = command.clone();
let kill_orphans = self.kill_orphans;
let budget = command.effective_timeout();
let handle = tokio::task::spawn_blocking(move || {
let stop_waiting_at = Instant::now() + budget;
run_command_streaming(&command, kill_orphans, |chunk| {
forward_chunk(&tx, chunk, stop_waiting_at);
})
});
Ok((rx, handle))
}
pub async fn execute_in_session(
&self,
session_id: &crate::session::SessionId,
command: &Command,
) -> Result<ExecutionResult> {
let session = self
.store
.get(session_id)?
.ok_or_else(|| ShellTunnelError::SessionNotFound(session_id.to_string()))?;
if !session.state.can_execute() {
return Err(ShellTunnelError::NotExecutable(session.state));
}
let _busy = BusySession::begin(&self.store, session_id)?;
self.execute(command).await
}
}
pub fn execute_simple(command_line: &str) -> Result<ExecutionResult> {
let cmd = Command::new(command_line);
let store = Arc::new(SessionStore::new());
let executor = CommandExecutor::new(store);
executor.execute_sync(&cmd)
}
pub fn execute_with_timeout(command_line: &str, timeout: Duration) -> Result<ExecutionResult> {
let cmd = Command::new(command_line).timeout(timeout);
let store = Arc::new(SessionStore::new());
let executor = CommandExecutor::new(store);
executor.execute_sync(&cmd)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_executor_new() {
let store = Arc::new(SessionStore::new());
let _executor = CommandExecutor::new(store);
}
#[test]
fn test_command_builder() {
let cmd = Command::new("echo hello")
.timeout(Duration::from_secs(5))
.capture_output(true);
assert_eq!(cmd.command_line, "echo hello");
assert_eq!(cmd.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn test_execute_simple_echo() {
let result = execute_simple("echo test").unwrap();
assert!(result.text_output.contains("test"));
}
#[test]
fn test_execute_with_timeout() {
let result = execute_with_timeout("echo fast", Duration::from_secs(5)).unwrap();
assert!(!result.timed_out);
}
#[test]
fn test_default_timeout() {
assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(30));
}
}