1use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use async_trait::async_trait;
31use tokio::sync::Mutex;
32use tokio_util::sync::CancellationToken;
33
34use crate::env::{
35 check_cancel_exec, check_cancel_file, ExecutionEnv, FileContent, FileInfo, FileKind,
36 FileSystem, Shell, ShellExecOptions, ShellOutput,
37};
38use crate::error::{
39 io_to_file_error, ExecutionError, ExecutionErrorCode, FileError, FileErrorCode,
40};
41use crate::file_mutation_queue::{MutatingEnv, MutationQueueRegistry};
42
43const MAX_TIMEOUT_MS: u64 = 2_147_483_647;
45const MAX_TIMEOUT_SECONDS: f64 = 2_147_483.647;
47const EXIT_STDIO_GRACE_MS: u64 = 100;
50
51#[derive(Clone, Copy)]
53enum CommandTransport {
54 Argv,
56 Stdin,
58}
59
60struct ShellConfig {
62 shell: PathBuf,
63 args: Vec<String>,
64 transport: CommandTransport,
65}
66
67pub struct OsExecutionEnv {
69 cwd: PathBuf,
70 shell_path: Option<PathBuf>,
71 shell_env: Option<HashMap<String, String>>,
72 active_pids: Arc<Mutex<HashSet<u32>>>,
73 registry: Arc<MutationQueueRegistry>,
74}
75
76impl OsExecutionEnv {
77 pub fn new() -> Self {
78 Self {
79 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
80 shell_path: None,
81 shell_env: None,
82 active_pids: Arc::new(Mutex::new(HashSet::new())),
83 registry: Arc::new(MutationQueueRegistry::new()),
84 }
85 }
86
87 pub fn with_cwd(cwd: PathBuf) -> Self {
88 Self {
89 cwd,
90 shell_path: None,
91 shell_env: None,
92 active_pids: Arc::new(Mutex::new(HashSet::new())),
93 registry: Arc::new(MutationQueueRegistry::new()),
94 }
95 }
96
97 pub fn with_shell_path(mut self, shell_path: PathBuf) -> Self {
98 self.shell_path = Some(shell_path);
99 self
100 }
101
102 pub fn with_shell_env(mut self, env: HashMap<String, String>) -> Self {
103 self.shell_env = Some(env);
104 self
105 }
106
107 fn resolve_path(cwd: &Path, path: &str) -> PathBuf {
109 let mut p = path.to_string();
110 if p == "~" {
112 p = home_dir_string();
113 } else if let Some(rest) = p.strip_prefix("~/") {
114 p = format!("{}/{}", home_dir_string(), rest);
115 } else if cfg!(windows) {
116 if let Some(rest) = p.strip_prefix("~\\") {
117 p = format!("{}\\{}", home_dir_string(), rest);
118 }
119 }
120 if p.starts_with("file://") {
122 if let Some(local) = file_url_to_path(&p) {
123 p = local;
124 }
125 }
126 let pb = PathBuf::from(&p);
127 if pb.is_absolute() {
128 normalize_absolute(&pb)
129 } else {
130 normalize_absolute(&cwd.join(pb))
131 }
132 }
133
134 async fn get_shell_config(&self) -> Result<ShellConfig, ExecutionError> {
137 if let Some(sp) = &self.shell_path {
139 if tokio::fs::metadata(sp).await.is_ok() {
140 return Ok(bash_shell_config(sp.clone()));
141 }
142 return Err(ExecutionError::new(
143 ExecutionErrorCode::ShellUnavailable,
144 format!("Custom shell path not found: {}", sp.display()),
145 ));
146 }
147 #[cfg(windows)]
149 {
150 if let Some(cfg) = windows_probe_bash().await {
151 return Ok(cfg);
152 }
153 return Err(ExecutionError::new(
154 ExecutionErrorCode::ShellUnavailable,
155 "bash not found. Install Git for Windows / MSYS2 / Cygwin, or set shellPath.",
156 ));
157 }
158 #[cfg(not(windows))]
160 {
161 if tokio::fs::metadata("/bin/bash").await.is_ok() {
162 return Ok(ShellConfig {
163 shell: PathBuf::from("/bin/bash"),
164 args: vec!["-c".to_string()],
165 transport: CommandTransport::Argv,
166 });
167 }
168 if let Some(found) =
170 run_command("which", &["bash"], std::time::Duration::from_millis(5000)).await
171 {
172 let trimmed = found.trim();
173 if !trimmed.is_empty() && tokio::fs::metadata(trimmed).await.is_ok() {
174 return Ok(ShellConfig {
175 shell: PathBuf::from(trimmed),
176 args: vec!["-c".to_string()],
177 transport: CommandTransport::Argv,
178 });
179 }
180 }
181 return Ok(ShellConfig {
183 shell: PathBuf::from("sh"),
184 args: vec!["-c".to_string()],
185 transport: CommandTransport::Argv,
186 });
187 }
188 }
189}
190
191impl Default for OsExecutionEnv {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197fn home_dir_string() -> String {
198 dirs::home_dir()
199 .map(|p| p.to_string_lossy().into_owned())
200 .unwrap_or_default()
201}
202
203fn normalize_absolute(p: &Path) -> PathBuf {
204 let mut out = PathBuf::new();
207 for comp in p.components() {
208 match comp {
209 std::path::Component::ParentDir => {
210 out.pop();
211 }
212 std::path::Component::CurDir => {}
213 other => out.push(other),
214 }
215 }
216 out
217}
218
219fn file_url_to_path(url: &str) -> Option<String> {
220 let rest = url.strip_prefix("file://")?;
223 if let Some(drive_rest) = rest.strip_prefix('/') {
224 if drive_rest.len() >= 2 && drive_rest.as_bytes()[1] == b':' {
226 return Some(drive_rest.to_string());
227 }
228 return Some(format!("/{}", drive_rest));
229 }
230 Some(rest.to_string())
231}
232
233fn bash_shell_config(shell: PathBuf) -> ShellConfig {
234 if is_legacy_wsl_bash_path(&shell) {
235 ShellConfig {
236 shell,
237 args: vec!["-s".to_string()],
238 transport: CommandTransport::Stdin,
239 }
240 } else {
241 ShellConfig {
242 shell,
243 args: vec!["-c".to_string()],
244 transport: CommandTransport::Argv,
245 }
246 }
247}
248
249fn is_legacy_wsl_bash_path(p: &Path) -> bool {
250 let s = p.to_string_lossy().replace('/', "\\").to_lowercase();
251 let re = regex::Regex::new(r"^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$").unwrap();
252 re.is_match(&s)
253}
254
255#[cfg(windows)]
256async fn windows_probe_bash() -> Option<ShellConfig> {
257 let pf = std::env::var("ProgramFiles").ok();
258 let pf86 = std::env::var("ProgramFiles(x86)").ok();
259 let mut candidates: Vec<PathBuf> = Vec::new();
260 if let Some(p) = pf {
261 candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
262 }
263 if let Some(p) = pf86 {
264 candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
265 }
266 for c in &candidates {
267 if tokio::fs::metadata(c).await.is_ok() {
268 return Some(bash_shell_config(c.clone()));
269 }
270 }
271 if let Some(found) = run_command(
273 "where",
274 &["bash.exe"],
275 std::time::Duration::from_millis(5000),
276 )
277 .await
278 {
279 let first = found.lines().next().unwrap_or("").trim().to_string();
280 if !first.is_empty() && tokio::fs::metadata(&first).await.is_ok() {
281 return Some(bash_shell_config(PathBuf::from(first)));
282 }
283 }
284 None
285}
286
287async fn run_command(cmd: &str, args: &[&str], timeout: std::time::Duration) -> Option<String> {
291 let mut command = tokio::process::Command::new(cmd);
292 command.args(args);
293 command.stdout(std::process::Stdio::piped());
294 command.stderr(std::process::Stdio::null());
295 command.stdin(std::process::Stdio::null());
296 #[cfg(windows)]
297 {
298 use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
300 command.creation_flags(CREATE_NO_WINDOW);
301 }
302 let child = command.spawn().ok()?;
303 let out = match tokio::time::timeout(timeout, child.wait_with_output()).await {
304 Ok(Ok(o)) => o,
305 _ => return None,
306 };
307 if !out.status.success() {
308 return None;
309 }
310 let s = String::from_utf8_lossy(&out.stdout).into_owned();
311 if s.trim().is_empty() {
312 None
313 } else {
314 Some(s)
315 }
316}
317
318async fn kill_process_tree(pid: u32) {
321 #[cfg(windows)]
322 {
323 use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
324 let mut cmd = tokio::process::Command::new("taskkill");
325 cmd.args(&["/F", "/T", "/PID", &pid.to_string()]);
326 cmd.creation_flags(CREATE_NO_WINDOW);
327 cmd.stdin(std::process::Stdio::null());
328 cmd.stdout(std::process::Stdio::null());
329 cmd.stderr(std::process::Stdio::null());
330 let _ = cmd.status().await;
335 }
336 #[cfg(unix)]
337 {
338 use nix::sys::signal::{kill, Signal};
339 use nix::unistd::Pid;
340 let pg = Pid::from_raw(-(pid as i32));
342 if kill(pg, Signal::SIGKILL).is_err() {
343 let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
344 }
345 }
346 #[cfg(not(any(windows, unix)))]
347 {
348 let _ = pid;
349 }
350}
351
352#[async_trait]
353impl FileSystem for OsExecutionEnv {
354 fn cwd(&self) -> &Path {
355 &self.cwd
356 }
357
358 async fn absolute_path(
359 &self,
360 path: &str,
361 cancel: Option<&CancellationToken>,
362 ) -> Result<PathBuf, FileError> {
363 check_cancel_file(cancel, Some(path))?;
364 Ok(Self::resolve_path(&self.cwd, path))
365 }
366
367 async fn join_path(
368 &self,
369 parts: &[&str],
370 cancel: Option<&CancellationToken>,
371 ) -> Result<PathBuf, FileError> {
372 check_cancel_file(cancel, None)?;
373 let mut out = PathBuf::new();
374 for p in parts {
375 out.push(p);
376 }
377 Ok(out)
378 }
379
380 async fn read_text_file(
381 &self,
382 path: &str,
383 cancel: Option<&CancellationToken>,
384 ) -> Result<String, FileError> {
385 check_cancel_file(cancel, Some(path))?;
386 let abs = Self::resolve_path(&self.cwd, path);
387 let bytes = read_cancellable(&abs, cancel).await?;
388 String::from_utf8(bytes).map_err(|e| {
389 FileError::new(FileErrorCode::Invalid, format!("invalid utf-8: {e}"))
390 .with_path(abs.to_string_lossy())
391 })
392 }
393
394 async fn read_text_lines(
395 &self,
396 path: &str,
397 max_lines: Option<usize>,
398 cancel: Option<&CancellationToken>,
399 ) -> Result<Vec<String>, FileError> {
400 check_cancel_file(cancel, Some(path))?;
401 if max_lines.map(|m| m == 0).unwrap_or(false) {
402 return Ok(Vec::new());
403 }
404 let text = self.read_text_file(path, cancel).await?;
405 let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
406 if text.ends_with('\n') {
407 if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
408 lines.pop();
409 }
410 }
411 if let Some(m) = max_lines {
412 lines.truncate(m);
413 }
414 Ok(lines)
415 }
416
417 async fn read_binary_file(
418 &self,
419 path: &str,
420 cancel: Option<&CancellationToken>,
421 ) -> Result<Vec<u8>, FileError> {
422 check_cancel_file(cancel, Some(path))?;
423 let abs = Self::resolve_path(&self.cwd, path);
424 read_cancellable(&abs, cancel).await
425 }
426
427 async fn write_file(
428 &self,
429 path: &str,
430 content: FileContent,
431 cancel: Option<&CancellationToken>,
432 ) -> Result<(), FileError> {
433 check_cancel_file(cancel, Some(path))?;
434 let abs = Self::resolve_path(&self.cwd, path);
435 if let Some(parent) = abs.parent() {
437 if !parent.as_os_str().is_empty() {
438 if let Err(e) = tokio::fs::create_dir_all(parent).await {
439 return Err(io_to_file_error(e, Some(&abs.to_string_lossy())));
440 }
441 }
442 }
443 check_cancel_file(cancel, Some(&abs.to_string_lossy()))?;
444 write_cancellable(&abs, content.as_bytes(), cancel).await
445 }
446
447 async fn append_file(
448 &self,
449 path: &str,
450 content: FileContent,
451 cancel: Option<&CancellationToken>,
452 ) -> Result<(), FileError> {
453 check_cancel_file(cancel, Some(path))?;
454 let abs = Self::resolve_path(&self.cwd, path);
455 if let Some(parent) = abs.parent() {
456 if !parent.as_os_str().is_empty() {
457 let _ = tokio::fs::create_dir_all(parent).await;
458 }
459 }
460 use tokio::io::AsyncWriteExt;
461 let res = match tokio::fs::OpenOptions::new()
462 .create(true)
463 .append(true)
464 .open(&abs)
465 .await
466 {
467 Ok(mut f) => f.write_all(content.as_bytes()).await,
468 Err(e) => return Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
469 };
470 res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
471 }
472
473 async fn rename_file(
474 &self,
475 source: &str,
476 dest: &str,
477 cancel: Option<&CancellationToken>,
478 ) -> Result<(), FileError> {
479 check_cancel_file(cancel, Some(source))?;
480 let src = Self::resolve_path(&self.cwd, source);
481 let dst = Self::resolve_path(&self.cwd, dest);
482 tokio::fs::rename(&src, &dst)
483 .await
484 .map_err(|e| io_to_file_error(e, Some(&src.to_string_lossy())))
485 }
486
487 async fn file_info(
488 &self,
489 path: &str,
490 cancel: Option<&CancellationToken>,
491 ) -> Result<FileInfo, FileError> {
492 check_cancel_file(cancel, Some(path))?;
493 let abs = Self::resolve_path(&self.cwd, path);
494 let meta = tokio::fs::symlink_metadata(&abs)
495 .await
496 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
497 let ft = meta.file_type();
498 let kind = if ft.is_file() {
499 FileKind::File
500 } else if ft.is_dir() {
501 FileKind::Directory
502 } else if ft.is_symlink() {
503 FileKind::Symlink
504 } else {
505 return Err(
506 FileError::new(FileErrorCode::Invalid, "Unsupported file type")
507 .with_path(abs.to_string_lossy()),
508 );
509 };
510 let mtime_ms = meta
511 .modified()
512 .ok()
513 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
514 .map(|d| d.as_millis() as i64)
515 .unwrap_or(0);
516 Ok(FileInfo {
517 name: abs
518 .file_name()
519 .map(|n| n.to_string_lossy().into_owned())
520 .unwrap_or_else(|| abs.to_string_lossy().into_owned()),
521 path: abs.clone(),
522 kind,
523 size: meta.len(),
524 mtime_ms,
525 })
526 }
527
528 async fn list_dir(
529 &self,
530 path: &str,
531 cancel: Option<&CancellationToken>,
532 ) -> Result<Vec<FileInfo>, FileError> {
533 check_cancel_file(cancel, Some(path))?;
534 let abs = Self::resolve_path(&self.cwd, path);
535 let mut entries = tokio::fs::read_dir(&abs)
536 .await
537 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
538 let mut out = Vec::new();
539 while let Some(entry) = entries
540 .next_entry()
541 .await
542 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?
543 {
544 if let Some(t) = cancel {
545 if t.is_cancelled() {
546 return Err(FileError::aborted().with_path(abs.to_string_lossy()));
547 }
548 }
549 let ep = entry.path();
550 let meta = match tokio::fs::symlink_metadata(&ep).await {
551 Ok(m) => m,
552 Err(e) => {
553 let code = crate::error::io_to_file_code(&e);
555 if code == FileErrorCode::NotSupported {
556 continue;
557 }
558 return Err(io_to_file_error(e, Some(&ep.to_string_lossy())));
559 }
560 };
561 let ft = meta.file_type();
562 let kind = if ft.is_file() {
563 FileKind::File
564 } else if ft.is_dir() {
565 FileKind::Directory
566 } else if ft.is_symlink() {
567 FileKind::Symlink
568 } else {
569 continue; };
571 let mtime_ms = meta
572 .modified()
573 .ok()
574 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
575 .map(|d| d.as_millis() as i64)
576 .unwrap_or(0);
577 out.push(FileInfo {
578 name: entry.file_name().to_string_lossy().into_owned(),
579 path: ep,
580 kind,
581 size: meta.len(),
582 mtime_ms,
583 });
584 }
585 Ok(out)
586 }
587
588 async fn canonical_path(
589 &self,
590 path: &str,
591 cancel: Option<&CancellationToken>,
592 ) -> Result<PathBuf, FileError> {
593 check_cancel_file(cancel, Some(path))?;
594 let abs = Self::resolve_path(&self.cwd, path);
595 tokio::fs::canonicalize(&abs)
596 .await
597 .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
598 }
599
600 async fn exists(
601 &self,
602 path: &str,
603 cancel: Option<&CancellationToken>,
604 ) -> Result<bool, FileError> {
605 check_cancel_file(cancel, Some(path))?;
606 let abs = Self::resolve_path(&self.cwd, path);
607 match tokio::fs::symlink_metadata(&abs).await {
608 Ok(_) => Ok(true),
609 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
610 Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
611 }
612 }
613
614 async fn create_dir(
615 &self,
616 path: &str,
617 recursive: bool,
618 cancel: Option<&CancellationToken>,
619 ) -> Result<(), FileError> {
620 check_cancel_file(cancel, Some(path))?;
621 let abs = Self::resolve_path(&self.cwd, path);
622 let res = if recursive {
623 tokio::fs::create_dir_all(&abs).await
624 } else {
625 tokio::fs::create_dir(&abs).await
626 };
627 res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
628 }
629
630 async fn remove(
631 &self,
632 path: &str,
633 recursive: bool,
634 force: bool,
635 cancel: Option<&CancellationToken>,
636 ) -> Result<(), FileError> {
637 check_cancel_file(cancel, Some(path))?;
638 let abs = Self::resolve_path(&self.cwd, path);
639 let res = if recursive {
640 tokio::fs::remove_dir_all(&abs).await
641 } else {
642 match tokio::fs::remove_file(&abs).await {
644 Ok(()) => Ok(()),
645 Err(_) => tokio::fs::remove_dir(&abs).await,
646 }
647 };
648 match res {
649 Ok(()) => Ok(()),
650 Err(e) if e.kind() == std::io::ErrorKind::NotFound && force => Ok(()),
651 Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
652 }
653 }
654
655 async fn create_temp_dir(
656 &self,
657 prefix: Option<&str>,
658 cancel: Option<&CancellationToken>,
659 ) -> Result<PathBuf, FileError> {
660 check_cancel_file(cancel, None)?;
661 let prefix = prefix.unwrap_or("tmp-");
662 let tmp =
664 tempfile::tempdir_in(std::env::temp_dir()).map_err(|e| io_to_file_error(e, None))?;
665 let path = tmp.path().to_path_buf();
667 std::mem::forget(tmp); let final_path = std::env::temp_dir().join(format!("{}{}", prefix, random_suffix()));
670 if let Err(e) = tokio::fs::rename(&path, &final_path).await {
672 let _ = e;
674 return Ok(path);
675 }
676 Ok(final_path)
677 }
678
679 async fn create_temp_file(
680 &self,
681 prefix: &str,
682 suffix: &str,
683 cancel: Option<&CancellationToken>,
684 ) -> Result<PathBuf, FileError> {
685 check_cancel_file(cancel, None)?;
686 let dir = self.create_temp_dir(Some("tmp-"), cancel).await?;
689 let name = format!("{}{}{}", prefix, random_suffix(), suffix);
690 let path = dir.join(name);
691 tokio::fs::File::create(&path)
692 .await
693 .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
694 Ok(path)
695 }
696
697 async fn cleanup(&self) {
698 let pids: Vec<u32> = self.active_pids.lock().await.iter().copied().collect();
699 for pid in pids {
700 kill_process_tree(pid).await;
701 }
702 self.active_pids.lock().await.clear();
703 }
704}
705
706async fn read_cancellable(
708 path: &Path,
709 cancel: Option<&CancellationToken>,
710) -> Result<Vec<u8>, FileError> {
711 let read = tokio::fs::read(path);
712 let res = match cancel {
713 Some(t) => tokio::select! {
714 r = read => r,
715 _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
716 },
717 None => read.await,
718 };
719 res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
720}
721
722async fn write_cancellable(
724 path: &Path,
725 bytes: &[u8],
726 cancel: Option<&CancellationToken>,
727) -> Result<(), FileError> {
728 use tokio::io::AsyncWriteExt;
729 let f = tokio::fs::File::create(path)
730 .await
731 .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
732 let write = async {
733 let mut f = f;
734 f.write_all(bytes).await?;
735 f.flush().await?;
736 Ok::<(), std::io::Error>(())
737 };
738 let res = match cancel {
739 Some(t) => tokio::select! {
740 r = write => r,
741 _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
742 },
743 None => write.await,
744 };
745 res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
746}
747
748fn random_suffix() -> String {
749 uuid::Uuid::new_v4().simple().to_string()
751}
752
753#[async_trait]
754impl Shell for OsExecutionEnv {
755 async fn exec<'a>(
756 &'a self,
757 command: &str,
758 options: ShellExecOptions<'a>,
759 ) -> Result<ShellOutput, ExecutionError> {
760 check_cancel_exec(options.cancel)?;
762 let timeout = match resolve_timeout(options.timeout) {
764 Ok(t) => t,
765 Err(e) => return Err(e),
766 };
767 let cwd = match &options.cwd {
769 Some(c) => Self::resolve_path(&self.cwd, &c.to_string_lossy()),
770 None => self.cwd.clone(),
771 };
772 let shell_cfg = self.get_shell_config().await?;
774 if !cwd.exists() {
776 return Err(ExecutionError::new(
777 ExecutionErrorCode::SpawnError,
778 format!(
779 "Working directory does not exist: {}\nCannot execute bash commands.",
780 cwd.display()
781 ),
782 ));
783 }
784
785 let mut cmd = tokio::process::Command::new(&shell_cfg.shell);
787 cmd.current_dir(&cwd);
788 cmd.env_clear();
789 if options.inherit_env {
791 for (k, v) in std::env::vars() {
792 cmd.env(k, v);
793 }
794 }
795 if let Some(base) = &self.shell_env {
796 for (k, v) in base {
797 cmd.env(k, v);
798 }
799 }
800 if let Some(extra) = &options.env {
801 for (k, v) in extra {
802 cmd.env(k, v);
803 }
804 }
805 cmd.stdin(std::process::Stdio::piped());
807 cmd.stdout(std::process::Stdio::piped());
808 cmd.stderr(std::process::Stdio::piped());
809 #[cfg(unix)]
810 {
811 use std::os::unix::process::CommandExt;
812 cmd.process_group(0); }
814 #[cfg(windows)]
815 {
816 use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
817 cmd.creation_flags(CREATE_NO_WINDOW);
818 }
819
820 let transport = shell_cfg.transport;
822 cmd.args(&shell_cfg.args);
823 if matches!(transport, CommandTransport::Argv) {
824 cmd.arg(command);
825 }
826
827 let mut child = match cmd.spawn() {
828 Ok(c) => c,
829 Err(e) => {
830 return Err(ExecutionError::new(
831 ExecutionErrorCode::SpawnError,
832 format!("failed to spawn shell: {e}"),
833 ));
834 }
835 };
836 let pid = child.id();
837 if let Some(pid) = pid {
838 self.active_pids.lock().await.insert(pid);
839 }
840
841 if matches!(transport, CommandTransport::Stdin) {
843 if let Some(mut stdin) = child.stdin.take() {
844 use tokio::io::AsyncWriteExt;
845 let _ = stdin.write_all(command.as_bytes()).await;
846 let _ = stdin.flush().await;
847 drop(stdin);
848 }
849 }
850
851 let mut stdout_buf = String::new();
853 let mut stderr_buf = String::new();
854 let cancel = options.cancel;
855 let mut on_stdout = options.on_stdout;
856 let mut on_stderr = options.on_stderr;
857
858 let mut stdout = child.stdout.take();
859 let mut stderr = child.stderr.take();
860
861 let stdout_task = async {
862 if let Some(s) = stdout.as_mut() {
863 let mut buf = vec![0u8; 8192];
864 use tokio::io::AsyncReadExt;
865 loop {
866 let read = s.read(&mut buf);
867 let read_result = match cancel {
868 Some(token) => tokio::select! {
869 result = read => result,
870 _ = token.cancelled() => break,
871 },
872 None => read.await,
873 };
874 let n = match read_result {
875 Ok(0) => break,
876 Ok(n) => n,
877 Err(_) => break,
878 };
879 let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
880 if let Some(cb) = on_stdout.as_mut() {
881 cb(&chunk);
884 }
885 stdout_buf.push_str(&chunk);
886 }
887 }
888 };
889 let stderr_task = async {
890 if let Some(s) = stderr.as_mut() {
891 let mut buf = vec![0u8; 8192];
892 use tokio::io::AsyncReadExt;
893 loop {
894 let read = s.read(&mut buf);
895 let read_result = match cancel {
896 Some(token) => tokio::select! {
897 result = read => result,
898 _ = token.cancelled() => break,
899 },
900 None => read.await,
901 };
902 let n = match read_result {
903 Ok(0) => break,
904 Ok(n) => n,
905 Err(_) => break,
906 };
907 let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
908 if let Some(cb) = on_stderr.as_mut() {
909 cb(&chunk);
910 }
911 stderr_buf.push_str(&chunk);
912 }
913 }
914 };
915
916 let mut timed_out = false;
918 let exit_status = {
919 let status_fut = async {
924 match (timeout, cancel) {
925 (Some(to), Some(token)) => {
926 tokio::select! {
927 status = child.wait() => status,
928 _ = tokio::time::sleep(to) => {
929 timed_out = true;
930 if let Some(pid) = child.id() {
931 kill_process_tree(pid).await;
932 }
933 child.wait().await
934 }
935 _ = token.cancelled() => {
936 if let Some(pid) = child.id() {
937 kill_process_tree(pid).await;
938 }
939 child.wait().await
940 }
941 }
942 }
943 (Some(to), None) => match tokio::time::timeout(to, child.wait()).await {
944 Ok(status) => status,
945 Err(_) => {
946 timed_out = true;
947 if let Some(pid) = child.id() {
948 kill_process_tree(pid).await;
949 }
950 child.wait().await
951 }
952 },
953 (None, Some(token)) => {
954 tokio::select! {
955 status = child.wait() => status,
956 _ = token.cancelled() => {
957 if let Some(pid) = child.id() {
958 kill_process_tree(pid).await;
959 }
960 child.wait().await
961 }
962 }
963 }
964 (None, None) => child.wait().await,
965 }
966 };
967 tokio::pin!(status_fut);
969 let (_, _, status_res) = tokio::join!(stdout_task, stderr_task, status_fut);
970 status_res
971 };
972
973 if timed_out {
976 let _ = tokio::time::sleep(std::time::Duration::from_millis(EXIT_STDIO_GRACE_MS)).await;
977 }
978
979 if let Some(pid) = pid {
981 self.active_pids.lock().await.remove(&pid);
982 }
983
984 let status = match exit_status {
986 Ok(s) => s,
987 Err(e) => {
988 return Err(ExecutionError::new(
989 ExecutionErrorCode::SpawnError,
990 format!("child wait failed: {e}"),
991 ));
992 }
993 };
994 if timed_out {
995 return Err(ExecutionError::timeout(
996 options.timeout.map(|f| f as u64).unwrap_or(0),
997 ));
998 }
999 if cancel.map(|t| t.is_cancelled()).unwrap_or(false) {
1000 return Err(ExecutionError::aborted());
1001 }
1002 let exit_code = status.code().unwrap_or(0);
1003 Ok(ShellOutput {
1004 stdout: stdout_buf,
1005 stderr: stderr_buf,
1006 exit_code,
1007 })
1008 }
1009
1010 async fn cleanup(&self) {
1011 FileSystem::cleanup(self).await
1012 }
1013}
1014
1015#[async_trait]
1016impl ExecutionEnv for OsExecutionEnv {}
1017
1018#[async_trait]
1019impl MutatingEnv for OsExecutionEnv {
1020 fn as_env(&self) -> &dyn ExecutionEnv {
1021 self
1022 }
1023 fn mutation_registry(&self) -> &MutationQueueRegistry {
1024 &self.registry
1025 }
1026}
1027
1028fn resolve_timeout(timeout: Option<f64>) -> Result<Option<std::time::Duration>, ExecutionError> {
1030 match timeout {
1031 None => Ok(None),
1032 Some(secs) => {
1033 if !secs.is_finite() || secs <= 0.0 {
1034 return Err(ExecutionError::new(
1035 ExecutionErrorCode::Timeout,
1036 "Invalid timeout: must be a finite number of seconds",
1037 ));
1038 }
1039 let ms = secs * 1000.0;
1040 if ms > MAX_TIMEOUT_MS as f64 {
1041 return Err(ExecutionError::new(
1042 ExecutionErrorCode::Timeout,
1043 format!(
1044 "Invalid timeout: maximum is {} seconds",
1045 MAX_TIMEOUT_SECONDS
1046 ),
1047 ));
1048 }
1049 Ok(Some(std::time::Duration::from_secs_f64(secs)))
1050 }
1051 }
1052}