shell_tunnel/execution/
executor.rs1use 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
19pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
21
22const READ_BUFFER_SIZE: usize = 4096;
24
25const CONTROL_POLL: Duration = Duration::from_millis(5);
27
28const COLLECT_GRACE: Duration = Duration::from_millis(500);
32
33fn spawn_pipe_reader<R: Read + Send + 'static>(
35 mut reader: R,
36 tx: std_mpsc::Sender<Vec<u8>>,
37) -> std::thread::JoinHandle<()> {
38 std::thread::spawn(move || {
39 let mut buf = [0u8; READ_BUFFER_SIZE];
40 loop {
41 match reader.read(&mut buf) {
42 Ok(0) => break, Ok(n) => {
44 if tx.send(buf[..n].to_vec()).is_err() {
45 break; }
47 }
48 Err(_) => break, }
50 }
51 })
52}
53
54fn run_command_streaming(
81 command: &Command,
82 mut on_chunk: impl FnMut(&[u8]),
83) -> Result<ExecutionResult> {
84 let start = Instant::now();
85 let timeout_duration = command.timeout.unwrap_or(DEFAULT_TIMEOUT);
86
87 let mut os_cmd = shell_command(&command.command_line);
88 os_cmd
89 .stdin(Stdio::null())
90 .stdout(Stdio::piped())
91 .stderr(Stdio::piped());
92 if let Some(dir) = &command.working_dir {
93 os_cmd.current_dir(dir);
94 }
95 for (key, value) in &command.env {
96 os_cmd.env(key, value);
97 }
98
99 detach_process_group(&mut os_cmd);
102
103 let mut child = os_cmd.spawn().map_err(ShellTunnelError::Io)?;
104 let child_pid = child.id();
105
106 let stdout = child.stdout.take();
109 let stderr = child.stderr.take();
110 let (tx, rx) = std_mpsc::channel::<Vec<u8>>();
111 let out_handle = stdout.map(|s| spawn_pipe_reader(s, tx.clone()));
112 let err_handle = stderr.map(|s| spawn_pipe_reader(s, tx));
113
114 let mut raw_output = Vec::new();
116 let mut exit_status = None;
117 let mut timed_out = false;
118 loop {
119 while let Ok(chunk) = rx.try_recv() {
120 on_chunk(&chunk);
121 raw_output.extend_from_slice(&chunk);
122 }
123
124 match child.try_wait() {
125 Ok(Some(status)) => {
126 exit_status = Some(status);
127 break;
128 }
129 Ok(None) => {}
130 Err(e) => return Err(ShellTunnelError::Io(e)),
131 }
132
133 if start.elapsed() >= timeout_duration {
134 timed_out = true;
135 kill_tree(child_pid);
139 let _ = child.wait();
140 break;
141 }
142
143 std::thread::sleep(CONTROL_POLL);
144 }
145
146 drop(out_handle);
152 drop(err_handle);
153 let collect_deadline = Instant::now() + COLLECT_GRACE;
154 loop {
155 match rx.recv_timeout(Duration::from_millis(20)) {
156 Ok(chunk) => {
157 on_chunk(&chunk);
158 raw_output.extend_from_slice(&chunk);
159 }
160 Err(std_mpsc::RecvTimeoutError::Disconnected) => break,
161 Err(std_mpsc::RecvTimeoutError::Timeout) => {
162 if Instant::now() >= collect_deadline {
163 break;
164 }
165 }
166 }
167 }
168
169 let duration = start.elapsed();
170 let text = OutputSanitizer::strip_ansi(&raw_output);
171
172 if timed_out {
173 return Ok(ExecutionResult::timeout(raw_output, text, duration));
174 }
175
176 let exit_code = exit_status.and_then(|s| s.code());
177 let mut result = ExecutionResult::new(raw_output, text, duration);
178 if let Some(code) = exit_code {
179 result = result.with_exit_code(code);
180 }
181 Ok(result)
182}
183
184fn run_command(command: &Command) -> Result<ExecutionResult> {
186 run_command_streaming(command, |_| {})
187}
188
189pub struct CommandExecutor {
191 store: Arc<SessionStore>,
192}
193
194impl CommandExecutor {
195 pub fn new(store: Arc<SessionStore>) -> Self {
197 Self { store }
198 }
199
200 pub fn execute_sync(&self, command: &Command) -> Result<ExecutionResult> {
206 run_command(command)
207 }
208
209 pub async fn execute(&self, command: &Command) -> Result<ExecutionResult> {
217 let command = command.clone();
218 tokio::task::spawn_blocking(move || run_command(&command))
219 .await
220 .map_err(|e| ShellTunnelError::Pty(format!("execution task failed: {e}")))?
221 }
222
223 pub async fn execute_async(
232 &self,
233 command: &Command,
234 ) -> Result<(
235 mpsc::Receiver<OutputChunk>,
236 tokio::task::JoinHandle<Result<ExecutionResult>>,
237 )> {
238 let (tx, rx) = mpsc::channel::<OutputChunk>(64);
239 let command = command.clone();
240
241 let handle = tokio::task::spawn_blocking(move || {
242 run_command_streaming(&command, |chunk| {
243 let _ = tx.blocking_send(OutputChunk::combined(chunk.to_vec()));
245 })
246 });
247
248 Ok((rx, handle))
249 }
250
251 pub async fn execute_in_session(
253 &self,
254 session_id: &crate::session::SessionId,
255 command: &Command,
256 ) -> Result<ExecutionResult> {
257 let session = self
259 .store
260 .get(session_id)?
261 .ok_or_else(|| ShellTunnelError::SessionNotFound(session_id.to_string()))?;
262
263 if !session.state.can_execute() {
264 return Err(ShellTunnelError::NotExecutable(session.state));
265 }
266
267 self.store.update(session_id, |s| {
269 let _ = s.state.transition_to(SessionState::Active);
270 s.touch();
271 })?;
272
273 let result = self.execute(command).await;
275
276 self.store.update(session_id, |s| {
278 let _ = s.state.transition_to(SessionState::Idle);
279 s.touch();
280 })?;
281
282 result
283 }
284}
285
286pub fn execute_simple(command_line: &str) -> Result<ExecutionResult> {
288 let cmd = Command::new(command_line);
289 let store = Arc::new(SessionStore::new());
290 let executor = CommandExecutor::new(store);
291 executor.execute_sync(&cmd)
292}
293
294pub fn execute_with_timeout(command_line: &str, timeout: Duration) -> Result<ExecutionResult> {
296 let cmd = Command::new(command_line).timeout(timeout);
297 let store = Arc::new(SessionStore::new());
298 let executor = CommandExecutor::new(store);
299 executor.execute_sync(&cmd)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_executor_new() {
308 let store = Arc::new(SessionStore::new());
309 let _executor = CommandExecutor::new(store);
310 }
311
312 #[test]
313 fn test_command_builder() {
314 let cmd = Command::new("echo hello")
315 .timeout(Duration::from_secs(5))
316 .capture_output(true);
317
318 assert_eq!(cmd.command_line, "echo hello");
319 assert_eq!(cmd.timeout, Some(Duration::from_secs(5)));
320 }
321
322 #[test]
323 #[ignore] fn test_execute_simple_echo() {
325 let result = execute_simple("echo test").unwrap();
326 assert!(result.text_output.contains("test"));
327 }
328
329 #[test]
330 #[ignore] fn test_execute_with_timeout() {
332 let result = execute_with_timeout("echo fast", Duration::from_secs(5)).unwrap();
333 assert!(!result.timed_out);
334 }
335
336 #[test]
337 fn test_default_timeout() {
338 assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(30));
339 }
340}