use std::io::Read;
use std::process::Stdio;
use std::sync::mpsc as std_mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use super::command::Command;
use super::result::{ExecutionResult, OutputChunk};
use crate::error::ShellTunnelError;
use crate::output::OutputSanitizer;
use crate::process::{detach_process_group, kill_tree, shell_command};
use crate::session::{SessionState, SessionStore};
use crate::Result;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const READ_BUFFER_SIZE: usize = 4096;
const CONTROL_POLL: Duration = Duration::from_millis(5);
const COLLECT_GRACE: Duration = Duration::from_millis(500);
fn spawn_pipe_reader<R: Read + Send + 'static>(
mut reader: R,
tx: std_mpsc::Sender<Vec<u8>>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut buf = [0u8; READ_BUFFER_SIZE];
loop {
match reader.read(&mut buf) {
Ok(0) => break, Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break; }
}
Err(_) => break, }
}
})
}
fn run_command_streaming(
command: &Command,
mut on_chunk: impl FnMut(&[u8]),
) -> Result<ExecutionResult> {
let start = Instant::now();
let timeout_duration = command.timeout.unwrap_or(DEFAULT_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);
}
detach_process_group(&mut os_cmd);
let mut child = os_cmd.spawn().map_err(ShellTunnelError::Io)?;
let child_pid = child.id();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (tx, rx) = std_mpsc::channel::<Vec<u8>>();
let out_handle = stdout.map(|s| spawn_pipe_reader(s, tx.clone()));
let err_handle = stderr.map(|s| spawn_pipe_reader(s, tx));
let mut raw_output = Vec::new();
let mut exit_status = None;
let mut timed_out = false;
loop {
while let Ok(chunk) = rx.try_recv() {
on_chunk(&chunk);
raw_output.extend_from_slice(&chunk);
}
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_tree(child_pid);
let _ = child.wait();
break;
}
std::thread::sleep(CONTROL_POLL);
}
drop(out_handle);
drop(err_handle);
let collect_deadline = Instant::now() + COLLECT_GRACE;
loop {
match rx.recv_timeout(Duration::from_millis(20)) {
Ok(chunk) => {
on_chunk(&chunk);
raw_output.extend_from_slice(&chunk);
}
Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
Err(std_mpsc::RecvTimeoutError::Timeout) => {
if Instant::now() >= collect_deadline {
break;
}
}
}
}
let duration = start.elapsed();
let text = OutputSanitizer::strip_ansi(&raw_output);
if timed_out {
return Ok(ExecutionResult::timeout(raw_output, text, duration));
}
let exit_code = exit_status.and_then(|s| s.code());
let mut result = ExecutionResult::new(raw_output, text, duration);
if let Some(code) = exit_code {
result = result.with_exit_code(code);
}
Ok(result)
}
fn run_command(command: &Command) -> Result<ExecutionResult> {
run_command_streaming(command, |_| {})
}
pub struct CommandExecutor {
store: Arc<SessionStore>,
}
impl CommandExecutor {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
pub fn execute_sync(&self, command: &Command) -> Result<ExecutionResult> {
run_command(command)
}
pub async fn execute(&self, command: &Command) -> Result<ExecutionResult> {
let command = command.clone();
tokio::task::spawn_blocking(move || run_command(&command))
.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 handle = tokio::task::spawn_blocking(move || {
run_command_streaming(&command, |chunk| {
let _ = tx.blocking_send(OutputChunk::combined(chunk.to_vec()));
})
});
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));
}
self.store.update(session_id, |s| {
let _ = s.state.transition_to(SessionState::Active);
s.touch();
})?;
let result = self.execute(command).await;
self.store.update(session_id, |s| {
let _ = s.state.transition_to(SessionState::Idle);
s.touch();
})?;
result
}
}
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]
#[ignore] fn test_execute_simple_echo() {
let result = execute_simple("echo test").unwrap();
assert!(result.text_output.contains("test"));
}
#[test]
#[ignore] 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));
}
}