shell_tunnel/execution/
executor.rs1use std::io::Read;
4use std::process::{Command as OsCommand, 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::session::{SessionState, SessionStore};
16use crate::Result;
17
18pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20
21const READ_BUFFER_SIZE: usize = 4096;
23
24const CONTROL_POLL: Duration = Duration::from_millis(5);
26
27const COLLECT_GRACE: Duration = Duration::from_millis(500);
31
32fn kill_tree(pid: u32) {
40 #[cfg(windows)]
41 {
42 let _ = OsCommand::new("taskkill")
43 .args(["/T", "/F", "/PID", &pid.to_string()])
44 .stdin(Stdio::null())
45 .stdout(Stdio::null())
46 .stderr(Stdio::null())
47 .status();
48 }
49 #[cfg(unix)]
50 {
51 unsafe {
54 libc::kill(-(pid as i32), libc::SIGKILL);
55 }
56 }
57}
58
59fn shell_command(command_line: &str) -> OsCommand {
61 #[cfg(windows)]
62 {
63 let mut c = OsCommand::new("cmd.exe");
64 c.arg("/c").arg(command_line);
65 c
66 }
67 #[cfg(unix)]
68 {
69 let mut c = OsCommand::new("/bin/sh");
70 c.arg("-c").arg(command_line);
71 c
72 }
73}
74
75fn spawn_pipe_reader<R: Read + Send + 'static>(
77 mut reader: R,
78 tx: std_mpsc::Sender<Vec<u8>>,
79) -> std::thread::JoinHandle<()> {
80 std::thread::spawn(move || {
81 let mut buf = [0u8; READ_BUFFER_SIZE];
82 loop {
83 match reader.read(&mut buf) {
84 Ok(0) => break, Ok(n) => {
86 if tx.send(buf[..n].to_vec()).is_err() {
87 break; }
89 }
90 Err(_) => break, }
92 }
93 })
94}
95
96fn run_command_streaming(
123 command: &Command,
124 mut on_chunk: impl FnMut(&[u8]),
125) -> Result<ExecutionResult> {
126 let start = Instant::now();
127 let timeout_duration = command.timeout.unwrap_or(DEFAULT_TIMEOUT);
128
129 let mut os_cmd = shell_command(&command.command_line);
130 os_cmd
131 .stdin(Stdio::null())
132 .stdout(Stdio::piped())
133 .stderr(Stdio::piped());
134 if let Some(dir) = &command.working_dir {
135 os_cmd.current_dir(dir);
136 }
137 for (key, value) in &command.env {
138 os_cmd.env(key, value);
139 }
140
141 #[cfg(unix)]
145 {
146 use std::os::unix::process::CommandExt;
147 unsafe {
150 os_cmd.pre_exec(|| {
151 libc::setsid();
152 Ok(())
153 });
154 }
155 }
156
157 let mut child = os_cmd.spawn().map_err(ShellTunnelError::Io)?;
158 let child_pid = child.id();
159
160 let stdout = child.stdout.take();
163 let stderr = child.stderr.take();
164 let (tx, rx) = std_mpsc::channel::<Vec<u8>>();
165 let out_handle = stdout.map(|s| spawn_pipe_reader(s, tx.clone()));
166 let err_handle = stderr.map(|s| spawn_pipe_reader(s, tx));
167
168 let mut raw_output = Vec::new();
170 let mut exit_status = None;
171 let mut timed_out = false;
172 loop {
173 while let Ok(chunk) = rx.try_recv() {
174 on_chunk(&chunk);
175 raw_output.extend_from_slice(&chunk);
176 }
177
178 match child.try_wait() {
179 Ok(Some(status)) => {
180 exit_status = Some(status);
181 break;
182 }
183 Ok(None) => {}
184 Err(e) => return Err(ShellTunnelError::Io(e)),
185 }
186
187 if start.elapsed() >= timeout_duration {
188 timed_out = true;
189 kill_tree(child_pid);
193 let _ = child.wait();
194 break;
195 }
196
197 std::thread::sleep(CONTROL_POLL);
198 }
199
200 drop(out_handle);
206 drop(err_handle);
207 let collect_deadline = Instant::now() + COLLECT_GRACE;
208 loop {
209 match rx.recv_timeout(Duration::from_millis(20)) {
210 Ok(chunk) => {
211 on_chunk(&chunk);
212 raw_output.extend_from_slice(&chunk);
213 }
214 Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
215 Err(std_mpsc::RecvTimeoutError::Timeout) => {
216 if Instant::now() >= collect_deadline {
217 break;
218 }
219 }
220 }
221 }
222
223 let duration = start.elapsed();
224 let text = OutputSanitizer::strip_ansi(&raw_output);
225
226 if timed_out {
227 return Ok(ExecutionResult::timeout(raw_output, text, duration));
228 }
229
230 let exit_code = exit_status.and_then(|s| s.code());
231 let mut result = ExecutionResult::new(raw_output, text, duration);
232 if let Some(code) = exit_code {
233 result = result.with_exit_code(code);
234 }
235 Ok(result)
236}
237
238fn run_command(command: &Command) -> Result<ExecutionResult> {
240 run_command_streaming(command, |_| {})
241}
242
243pub struct CommandExecutor {
245 store: Arc<SessionStore>,
246}
247
248impl CommandExecutor {
249 pub fn new(store: Arc<SessionStore>) -> Self {
251 Self { store }
252 }
253
254 pub fn execute_sync(&self, command: &Command) -> Result<ExecutionResult> {
260 run_command(command)
261 }
262
263 pub async fn execute(&self, command: &Command) -> Result<ExecutionResult> {
271 let command = command.clone();
272 tokio::task::spawn_blocking(move || run_command(&command))
273 .await
274 .map_err(|e| ShellTunnelError::Pty(format!("execution task failed: {e}")))?
275 }
276
277 pub async fn execute_async(
286 &self,
287 command: &Command,
288 ) -> Result<(
289 mpsc::Receiver<OutputChunk>,
290 tokio::task::JoinHandle<Result<ExecutionResult>>,
291 )> {
292 let (tx, rx) = mpsc::channel::<OutputChunk>(64);
293 let command = command.clone();
294
295 let handle = tokio::task::spawn_blocking(move || {
296 run_command_streaming(&command, |chunk| {
297 let _ = tx.blocking_send(OutputChunk::combined(chunk.to_vec()));
299 })
300 });
301
302 Ok((rx, handle))
303 }
304
305 pub async fn execute_in_session(
307 &self,
308 session_id: &crate::session::SessionId,
309 command: &Command,
310 ) -> Result<ExecutionResult> {
311 let session = self
313 .store
314 .get(session_id)?
315 .ok_or_else(|| ShellTunnelError::SessionNotFound(session_id.to_string()))?;
316
317 if !session.state.can_execute() {
318 return Err(ShellTunnelError::NotExecutable(session.state));
319 }
320
321 self.store.update(session_id, |s| {
323 let _ = s.state.transition_to(SessionState::Active);
324 s.touch();
325 })?;
326
327 let result = self.execute(command).await;
329
330 self.store.update(session_id, |s| {
332 let _ = s.state.transition_to(SessionState::Idle);
333 s.touch();
334 })?;
335
336 result
337 }
338}
339
340pub fn execute_simple(command_line: &str) -> Result<ExecutionResult> {
342 let cmd = Command::new(command_line);
343 let store = Arc::new(SessionStore::new());
344 let executor = CommandExecutor::new(store);
345 executor.execute_sync(&cmd)
346}
347
348pub fn execute_with_timeout(command_line: &str, timeout: Duration) -> Result<ExecutionResult> {
350 let cmd = Command::new(command_line).timeout(timeout);
351 let store = Arc::new(SessionStore::new());
352 let executor = CommandExecutor::new(store);
353 executor.execute_sync(&cmd)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_executor_new() {
362 let store = Arc::new(SessionStore::new());
363 let _executor = CommandExecutor::new(store);
364 }
365
366 #[test]
367 fn test_command_builder() {
368 let cmd = Command::new("echo hello")
369 .timeout(Duration::from_secs(5))
370 .capture_output(true);
371
372 assert_eq!(cmd.command_line, "echo hello");
373 assert_eq!(cmd.timeout, Some(Duration::from_secs(5)));
374 }
375
376 #[test]
377 #[ignore] fn test_execute_simple_echo() {
379 let result = execute_simple("echo test").unwrap();
380 assert!(result.text_output.contains("test"));
381 }
382
383 #[test]
384 #[ignore] fn test_execute_with_timeout() {
386 let result = execute_with_timeout("echo fast", Duration::from_secs(5)).unwrap();
387 assert!(!result.timed_out);
388 }
389
390 #[test]
391 fn test_default_timeout() {
392 assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(30));
393 }
394}