1#[cfg(any(feature = "async", feature = "sync"))]
32use std::time::Duration;
33
34#[cfg(feature = "async")]
35use tokio::io::AsyncReadExt;
36#[cfg(feature = "async")]
37use tokio::process::Command;
38#[cfg(any(feature = "async", feature = "sync"))]
39use tracing::{debug, warn};
40
41use crate::Claude;
42#[cfg(any(feature = "async", feature = "sync"))]
43use crate::error::{Error, Result};
44
45pub(crate) fn full_command_args(claude: &Claude, args: Vec<String>) -> Vec<String> {
52 let mut command_args = claude.global_args.clone();
53 command_args.extend(args);
54 command_args
55}
56
57#[cfg(any(feature = "async", feature = "sync"))]
64pub(crate) fn apply_child_environment(
65 cmd: &mut std::process::Command,
66 clear_env: bool,
67 env: &std::collections::HashMap<String, String>,
68) {
69 if clear_env {
70 cmd.env_clear();
71 }
72 cmd.env_remove("CLAUDECODE");
73 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
74 cmd.envs(env);
75}
76
77#[cfg(any(feature = "async", feature = "sync"))]
87pub(crate) fn span_command(args: &[String]) -> &str {
88 args.first().map(String::as_str).unwrap_or("<none>")
89}
90
91#[cfg(any(feature = "async", feature = "sync"))]
97pub(crate) fn exec_span(claude: &Claude, args: &[String], mode: &'static str) -> tracing::Span {
98 tracing::debug_span!(
99 "claude.exec",
100 command = span_command(args),
101 mode,
102 binary = %claude.binary.display(),
103 cwd = claude.working_dir.as_deref().map(|d| d.display().to_string()),
104 exit_code = tracing::field::Empty,
105 duration_ms = tracing::field::Empty,
106 )
107}
108
109#[cfg(any(feature = "async", feature = "sync"))]
111pub(crate) fn record_exec_outcome(
112 span: &tracing::Span,
113 exit_code: i32,
114 started: std::time::Instant,
115) {
116 span.record("exit_code", exit_code);
117 span.record("duration_ms", started.elapsed().as_millis() as u64);
118}
119
120#[derive(Debug, Clone)]
122pub struct CommandOutput {
123 pub stdout: String,
125 pub stderr: String,
127 pub exit_code: i32,
129 pub success: bool,
131}
132
133#[cfg(any(feature = "async", feature = "sync"))]
162#[derive(Clone, Copy)]
163pub(crate) struct SpawnPolicy<'a> {
164 pub(crate) process_group: bool,
165 pub(crate) kill_grace: Option<Duration>,
166 pub(crate) die_with_parent: bool,
167 pub(crate) on_spawn: Option<&'a crate::SpawnObserver>,
168}
169
170#[cfg(any(feature = "async", feature = "sync"))]
171impl SpawnPolicy<'_> {
172 pub(crate) fn of(claude: &Claude) -> SpawnPolicy<'_> {
174 SpawnPolicy {
175 process_group: claude.process_group,
176 kill_grace: claude.kill_grace,
177 die_with_parent: claude.die_with_parent,
178 on_spawn: claude.on_spawn.as_ref(),
179 }
180 }
181}
182
183#[cfg(any(feature = "async", feature = "sync"))]
184pub(crate) fn arm_and_notify(
185 process_group: bool,
186 pid: Option<u32>,
187 on_spawn: Option<&crate::SpawnObserver>,
188) -> GroupKillGuard {
189 if let (Some(pid), Some(observer)) = (pid, on_spawn) {
190 observer(crate::SpawnInfo {
191 pid,
192 pgid: process_group.then_some(pid),
193 });
194 }
195 GroupKillGuard::new_if(process_group, pid)
196}
197
198#[cfg(any(feature = "async", feature = "sync"))]
199pub(crate) struct GroupKillGuard {
200 #[cfg(unix)]
201 pgid: Option<i32>,
202}
203
204#[cfg(any(feature = "async", feature = "sync"))]
205impl GroupKillGuard {
206 pub(crate) fn new_if(enabled: bool, pid: Option<u32>) -> Self {
211 Self::new(if enabled { pid } else { None })
212 }
213
214 pub(crate) fn new(pid: Option<u32>) -> Self {
218 #[cfg(unix)]
219 {
220 Self {
221 pgid: pid.and_then(|p| i32::try_from(p).ok()),
222 }
223 }
224 #[cfg(not(unix))]
225 {
226 let _ = pid;
227 Self {}
228 }
229 }
230
231 pub(crate) fn disarm(&mut self) {
234 #[cfg(unix)]
235 {
236 self.pgid = None;
237 }
238 }
239
240 pub(crate) fn is_armed(&self) -> bool {
242 #[cfg(unix)]
243 {
244 self.pgid.is_some()
245 }
246 #[cfg(not(unix))]
247 {
248 false
249 }
250 }
251
252 pub(crate) fn term_now(&self) {
256 #[cfg(unix)]
257 if let Some(pgid) = self.pgid {
258 let _ = unsafe { libc::killpg(pgid, libc::SIGTERM) };
261 }
262 }
263
264 pub(crate) fn kill_now(&mut self) {
266 #[cfg(unix)]
267 if let Some(pgid) = self.pgid.take() {
268 let _ = unsafe { libc::killpg(pgid, libc::SIGKILL) };
271 }
272 }
273}
274
275#[cfg(any(feature = "async", feature = "sync"))]
276impl Drop for GroupKillGuard {
277 fn drop(&mut self) {
278 self.kill_now();
279 }
280}
281
282#[must_use]
291pub const fn die_with_parent_supported() -> bool {
292 cfg!(target_os = "linux")
293}
294
295#[cfg(all(unix, any(feature = "async", feature = "sync")))]
311fn pdeathsig_hook() -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static {
312 let parent = std::process::id();
315 move || {
316 #[cfg(target_os = "linux")]
317 {
318 unsafe {
320 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
321 return Err(std::io::Error::last_os_error());
322 }
323 if libc::getppid() as u32 != parent {
325 libc::_exit(1);
326 }
327 }
328 }
329 #[cfg(not(target_os = "linux"))]
330 {
331 let _ = parent;
332 }
333 Ok(())
334 }
335}
336
337#[cfg(feature = "async")]
340pub(crate) fn apply_die_with_parent(cmd: &mut Command, enabled: bool) {
341 #[cfg(unix)]
342 if enabled {
343 unsafe {
345 cmd.pre_exec(pdeathsig_hook());
346 }
347 }
348 #[cfg(not(unix))]
349 {
350 let _ = (cmd, enabled);
351 }
352}
353
354#[cfg(feature = "sync")]
356pub(crate) fn apply_die_with_parent_sync(cmd: &mut std::process::Command, enabled: bool) {
357 #[cfg(unix)]
358 if enabled {
359 use std::os::unix::process::CommandExt;
360 unsafe {
362 cmd.pre_exec(pdeathsig_hook());
363 }
364 }
365 #[cfg(not(unix))]
366 {
367 let _ = (cmd, enabled);
368 }
369}
370
371#[cfg(feature = "async")]
375pub(crate) fn apply_process_group(cmd: &mut Command, enabled: bool) {
376 #[cfg(unix)]
377 if enabled {
378 cmd.process_group(0);
379 }
380 #[cfg(not(unix))]
381 {
382 let _ = (cmd, enabled);
383 }
384}
385
386#[cfg(feature = "sync")]
388pub(crate) fn apply_process_group_sync(cmd: &mut std::process::Command, enabled: bool) {
389 #[cfg(unix)]
390 if enabled {
391 use std::os::unix::process::CommandExt;
392 cmd.process_group(0);
393 }
394 #[cfg(not(unix))]
395 {
396 let _ = (cmd, enabled);
397 }
398}
399
400#[cfg(feature = "async")]
408pub(crate) async fn kill_group_with_grace(group: &mut GroupKillGuard, grace: Option<Duration>) {
409 if let Some(g) = grace
410 && !g.is_zero()
411 && group.is_armed()
412 {
413 group.term_now();
414 tokio::time::sleep(g).await;
415 }
416 group.kill_now();
417}
418
419#[cfg(feature = "async")]
420enum StopReason {
421 Timeout { timeout_seconds: u64 },
422 Cancelled,
423}
424
425#[cfg(feature = "async")]
426impl StopReason {
427 fn into_error(self) -> Error {
428 match self {
429 Self::Timeout { timeout_seconds } => Error::Timeout { timeout_seconds },
430 Self::Cancelled => Error::Cancelled,
431 }
432 }
433}
434
435#[cfg(feature = "async")]
436type StopFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = StopReason> + Send + 'a>>;
437
438#[cfg(feature = "async")]
439fn timeout_stop(timeout: Duration) -> StopFuture<'static> {
440 Box::pin(async move {
441 tokio::time::sleep(timeout).await;
442 StopReason::Timeout {
443 timeout_seconds: timeout.as_secs(),
444 }
445 })
446}
447
448#[cfg(feature = "async")]
449fn cancellation_or_timeout<'a, C>(cancel: C, timeout: Option<Duration>) -> StopFuture<'a>
450where
451 C: std::future::Future<Output = ()> + Send + 'a,
452{
453 Box::pin(async move {
454 match timeout {
455 Some(timeout) => tokio::select! {
456 () = cancel => StopReason::Cancelled,
457 () = tokio::time::sleep(timeout) => StopReason::Timeout {
458 timeout_seconds: timeout.as_secs(),
459 },
460 },
461 None => {
462 cancel.await;
463 StopReason::Cancelled
464 }
465 }
466 })
467}
468
469#[cfg(feature = "async")]
470async fn stop_and_reap(
471 child: &mut tokio::process::Child,
472 group: &mut GroupKillGuard,
473 grace: Option<Duration>,
474 working_dir: Option<&std::path::Path>,
475) -> Result<()> {
476 kill_group_with_grace(group, grace).await;
477 if child
478 .try_wait()
479 .map_err(|e| wait_error(e, working_dir))?
480 .is_none()
481 && let Err(error) = child.start_kill()
482 && error.kind() != std::io::ErrorKind::InvalidInput
483 {
484 return Err(wait_error(error, working_dir));
485 }
486 child.wait().await.map_err(|e| wait_error(e, working_dir))?;
487 Ok(())
488}
489
490#[cfg(feature = "async")]
491fn wait_error(error: std::io::Error, working_dir: Option<&std::path::Path>) -> Error {
492 Error::Io {
493 message: "failed to wait for claude process".to_string(),
494 source: error,
495 working_dir: working_dir.map(|p| p.to_path_buf()),
496 }
497}
498
499#[cfg(feature = "sync")]
501pub(crate) fn kill_group_with_grace_sync(group: &mut GroupKillGuard, grace: Option<Duration>) {
502 if let Some(g) = grace
503 && !g.is_zero()
504 && group.is_armed()
505 {
506 group.term_now();
507 std::thread::sleep(g);
508 }
509 group.kill_now();
510}
511
512#[cfg(feature = "async")]
523pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
524 run_claude_with_retry(claude, args, None).await
525}
526
527#[cfg(feature = "async")]
533pub async fn run_claude_cancellable<C>(
534 claude: &Claude,
535 args: Vec<String>,
536 cancel: C,
537) -> Result<CommandOutput>
538where
539 C: std::future::Future<Output = ()> + Send,
540{
541 let command_args = full_command_args(claude, args);
542 let span = exec_span(claude, &command_args, "cancellable");
543 let _enter = span.enter();
544 let started = std::time::Instant::now();
545 let stop = cancellation_or_timeout(cancel, claude.timeout);
546 let output = run_with_stop(
547 &claude.binary,
548 &command_args,
549 &claude.env,
550 claude.clear_env,
551 claude.working_dir.as_deref(),
552 stop,
553 SpawnPolicy::of(claude),
554 )
555 .await?;
556 record_exec_outcome(&span, output.exit_code, started);
557 Ok(output)
558}
559
560#[cfg(feature = "async")]
564pub async fn run_claude_with_retry(
565 claude: &Claude,
566 args: Vec<String>,
567 retry_override: Option<&crate::retry::RetryPolicy>,
568) -> Result<CommandOutput> {
569 let policy = retry_override.or(claude.retry_policy.as_ref());
570
571 match policy {
572 Some(policy) => {
573 crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
574 }
575 None => run_claude_once(claude, args).await,
576 }
577}
578
579#[cfg(feature = "async")]
587pub async fn run_claude_with_stdin_prompt(
588 claude: &Claude,
589 args: Vec<String>,
590 stdin_content: String,
591) -> Result<CommandOutput> {
592 run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
593}
594
595#[cfg(feature = "async")]
600pub async fn run_claude_with_stdin_prompt_cancellable<C>(
601 claude: &Claude,
602 args: Vec<String>,
603 stdin_content: String,
604 cancel: C,
605) -> Result<CommandOutput>
606where
607 C: std::future::Future<Output = ()> + Send,
608{
609 let command_args = full_command_args(claude, args);
610 let span = exec_span(claude, &command_args, "stdin-cancellable");
611 let _enter = span.enter();
612 let started = std::time::Instant::now();
613 let stop = cancellation_or_timeout(cancel, claude.timeout);
614 let output = run_stdin_with_stop(
615 &claude.binary,
616 &command_args,
617 &claude.env,
618 claude.clear_env,
619 claude.working_dir.as_deref(),
620 stdin_content,
621 stop,
622 SpawnPolicy::of(claude),
623 )
624 .await?;
625 record_exec_outcome(&span, output.exit_code, started);
626 Ok(output)
627}
628
629#[cfg(feature = "async")]
630async fn run_claude_with_stdin_prompt_internal(
631 claude: &Claude,
632 args: Vec<String>,
633 stdin_content: String,
634) -> Result<CommandOutput> {
635 let command_args = full_command_args(claude, args);
636
637 let span = exec_span(claude, &command_args, "stdin");
638 let _enter = span.enter();
639 let started = std::time::Instant::now();
640 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
641
642 let binary = &claude.binary;
643 let env = &claude.env;
644 let clear_env = claude.clear_env;
645 let working_dir = claude.working_dir.as_deref();
646
647 let result = if let Some(timeout) = claude.timeout {
648 run_with_timeout_stdin(
649 binary,
650 &command_args,
651 env,
652 clear_env,
653 working_dir,
654 timeout,
655 stdin_content,
656 SpawnPolicy::of(claude),
657 )
658 .await
659 } else {
660 run_internal_stdin(
661 binary,
662 &command_args,
663 env,
664 clear_env,
665 working_dir,
666 stdin_content,
667 SpawnPolicy::of(claude),
668 )
669 .await
670 };
671
672 if let Ok(output) = &result {
673 record_exec_outcome(&span, output.exit_code, started);
674 }
675 result
676}
677
678#[cfg(feature = "async")]
679async fn run_internal_stdin(
680 binary: &std::path::Path,
681 args: &[String],
682 env: &std::collections::HashMap<String, String>,
683 clear_env: bool,
684 working_dir: Option<&std::path::Path>,
685 stdin_content: String,
686 policy: SpawnPolicy<'_>,
687) -> Result<CommandOutput> {
688 let SpawnPolicy {
689 process_group,
690 kill_grace,
691 die_with_parent,
692 on_spawn,
693 } = policy;
694 use tokio::io::AsyncWriteExt;
695
696 let mut cmd = Command::new(binary);
697 cmd.args(args);
698 cmd.stdin(std::process::Stdio::piped());
699 cmd.stdout(std::process::Stdio::piped());
700 cmd.stderr(std::process::Stdio::piped());
701 cmd.kill_on_drop(true);
704 apply_process_group(&mut cmd, process_group);
708 apply_die_with_parent(&mut cmd, die_with_parent);
709 apply_child_environment(cmd.as_std_mut(), clear_env, env);
710
711 if let Some(dir) = working_dir {
712 cmd.current_dir(dir);
713 }
714
715 let mut child = spawn_retrying_txtbsy(&mut cmd)
716 .await
717 .map_err(|e| Error::Io {
718 message: format!("failed to spawn claude: {e}"),
719 source: e,
720 working_dir: working_dir.map(|p| p.to_path_buf()),
721 })?;
722 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
723
724 if let Some(mut stdin) = child.stdin.take()
726 && let Err(error) = stdin.write_all(stdin_content.as_bytes()).await
727 {
728 let error = Error::Io {
729 message: format!("failed to write to claude stdin: {error}"),
730 source: error,
731 working_dir: working_dir.map(|p| p.to_path_buf()),
732 };
733 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
734 return Err(error);
735 }
736
737 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
738 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
739
740 let (status, stdout_str, stderr_str) = tokio::join!(
741 child.wait(),
742 drain(&mut stdout_handle),
743 drain(&mut stderr_handle),
744 );
745
746 let status = status.map_err(|e| Error::Io {
747 message: "failed to wait for claude process".to_string(),
748 source: e,
749 working_dir: working_dir.map(|p| p.to_path_buf()),
750 })?;
751 group.disarm();
752
753 let exit_code = status.code().unwrap_or(-1);
754
755 if !status.success() {
756 return Err(Error::from_command_failure(
757 format!("{} {}", binary.display(), args.join(" ")),
758 exit_code,
759 stdout_str,
760 stderr_str,
761 working_dir.map(|p| p.to_path_buf()),
762 ));
763 }
764
765 Ok(CommandOutput {
766 stdout: stdout_str,
767 stderr: stderr_str,
768 exit_code,
769 success: true,
770 })
771}
772
773#[cfg(feature = "async")]
774#[allow(clippy::too_many_arguments)]
775async fn run_with_timeout_stdin(
776 binary: &std::path::Path,
777 args: &[String],
778 env: &std::collections::HashMap<String, String>,
779 clear_env: bool,
780 working_dir: Option<&std::path::Path>,
781 timeout: Duration,
782 stdin_content: String,
783 policy: SpawnPolicy<'_>,
784) -> Result<CommandOutput> {
785 run_stdin_with_stop(
786 binary,
787 args,
788 env,
789 clear_env,
790 working_dir,
791 stdin_content,
792 timeout_stop(timeout),
793 policy,
794 )
795 .await
796}
797
798#[cfg(feature = "async")]
799#[allow(clippy::too_many_arguments)]
800async fn run_stdin_with_stop(
801 binary: &std::path::Path,
802 args: &[String],
803 env: &std::collections::HashMap<String, String>,
804 clear_env: bool,
805 working_dir: Option<&std::path::Path>,
806 stdin_content: String,
807 stop: StopFuture<'_>,
808 policy: SpawnPolicy<'_>,
809) -> Result<CommandOutput> {
810 let SpawnPolicy {
811 process_group,
812 kill_grace,
813 die_with_parent,
814 on_spawn,
815 } = policy;
816 use tokio::io::AsyncWriteExt;
817
818 let mut cmd = Command::new(binary);
819 cmd.args(args);
820 cmd.stdin(std::process::Stdio::piped());
821 cmd.stdout(std::process::Stdio::piped());
822 cmd.stderr(std::process::Stdio::piped());
823 cmd.kill_on_drop(true);
826 apply_process_group(&mut cmd, process_group);
830 apply_die_with_parent(&mut cmd, die_with_parent);
831 apply_child_environment(cmd.as_std_mut(), clear_env, env);
832
833 if let Some(dir) = working_dir {
834 cmd.current_dir(dir);
835 }
836
837 let mut child = spawn_retrying_txtbsy(&mut cmd)
838 .await
839 .map_err(|e| Error::Io {
840 message: format!("failed to spawn claude: {e}"),
841 source: e,
842 working_dir: working_dir.map(|p| p.to_path_buf()),
843 })?;
844 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
845
846 let child_stdin = child.stdin.take();
847 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
848 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
849 let write = async move {
850 let Some(mut stdin) = child_stdin else {
851 return Ok(());
852 };
853 stdin
854 .write_all(stdin_content.as_bytes())
855 .await
856 .map_err(|e| Error::Io {
857 message: format!("failed to write to claude stdin: {e}"),
858 source: e,
859 working_dir: working_dir.map(|p| p.to_path_buf()),
860 })
861 };
862 let read_stdout = async {
863 drain_result(&mut stdout_handle)
864 .await
865 .map_err(|e| Error::Io {
866 message: format!("failed to read claude stdout: {e}"),
867 source: e,
868 working_dir: working_dir.map(|p| p.to_path_buf()),
869 })
870 };
871 let read_stderr = async {
872 drain_result(&mut stderr_handle)
873 .await
874 .map_err(|e| Error::Io {
875 message: format!("failed to read claude stderr: {e}"),
876 source: e,
877 working_dir: working_dir.map(|p| p.to_path_buf()),
878 })
879 };
880 let wait = async { child.wait().await.map_err(|e| wait_error(e, working_dir)) };
881 let run = async {
882 let ((), status, stdout, stderr) = tokio::try_join!(write, wait, read_stdout, read_stderr)?;
883 Ok::<_, Error>((status, stdout, stderr))
884 };
885
886 match tokio::select! {
887 outcome = run => Ok(outcome),
888 reason = stop => Err(reason),
889 } {
890 Ok(Ok((status, stdout, stderr))) => {
891 group.disarm();
892 let exit_code = status.code().unwrap_or(-1);
893
894 if !status.success() {
895 return Err(Error::from_command_failure(
896 format!("{} {}", binary.display(), args.join(" ")),
897 exit_code,
898 stdout,
899 stderr,
900 working_dir.map(|p| p.to_path_buf()),
901 ));
902 }
903
904 Ok(CommandOutput {
905 stdout,
906 stderr,
907 exit_code,
908 success: true,
909 })
910 }
911 Ok(Err(error)) => {
912 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
913 Err(error)
914 }
915 Err(reason) => {
916 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
920 Err(reason.into_error())
921 }
922 }
923}
924
925#[cfg(feature = "async")]
926async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
927 let command_args = full_command_args(claude, args);
928
929 let span = exec_span(claude, &command_args, "oneshot");
930 let _enter = span.enter();
931 let started = std::time::Instant::now();
932 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
933
934 let output = if let Some(timeout) = claude.timeout {
935 run_with_timeout(
936 &claude.binary,
937 &command_args,
938 &claude.env,
939 claude.clear_env,
940 claude.working_dir.as_deref(),
941 timeout,
942 SpawnPolicy::of(claude),
943 )
944 .await?
945 } else {
946 run_internal(
947 &claude.binary,
948 &command_args,
949 &claude.env,
950 claude.clear_env,
951 claude.working_dir.as_deref(),
952 SpawnPolicy::of(claude),
953 )
954 .await?
955 };
956
957 record_exec_outcome(&span, output.exit_code, started);
958 Ok(output)
959}
960
961#[cfg(feature = "async")]
965pub async fn run_claude_allow_exit_codes(
966 claude: &Claude,
967 args: Vec<String>,
968 allowed_codes: &[i32],
969) -> Result<CommandOutput> {
970 let output = run_claude(claude, args).await;
971
972 match output {
973 Err(Error::CommandFailed {
974 exit_code,
975 stdout,
976 stderr,
977 ..
978 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
979 stdout,
980 stderr,
981 exit_code,
982 success: false,
983 }),
984 other => other,
985 }
986}
987
988#[cfg(feature = "async")]
989async fn run_internal(
990 binary: &std::path::Path,
991 args: &[String],
992 env: &std::collections::HashMap<String, String>,
993 clear_env: bool,
994 working_dir: Option<&std::path::Path>,
995 policy: SpawnPolicy<'_>,
996) -> Result<CommandOutput> {
997 let SpawnPolicy {
998 process_group,
999 kill_grace: _, die_with_parent,
1001 on_spawn,
1002 } = policy;
1003 let mut cmd = Command::new(binary);
1004 cmd.args(args);
1005
1006 cmd.stdin(std::process::Stdio::null());
1008 cmd.stdout(std::process::Stdio::piped());
1009 cmd.stderr(std::process::Stdio::piped());
1010
1011 cmd.kill_on_drop(true);
1014 apply_process_group(&mut cmd, process_group);
1018 apply_die_with_parent(&mut cmd, die_with_parent);
1019
1020 apply_child_environment(cmd.as_std_mut(), clear_env, env);
1021
1022 if let Some(dir) = working_dir {
1023 cmd.current_dir(dir);
1024 }
1025
1026 let mut child = spawn_retrying_txtbsy(&mut cmd)
1029 .await
1030 .map_err(|e| Error::Io {
1031 message: format!("failed to spawn claude: {e}"),
1032 source: e,
1033 working_dir: working_dir.map(|p| p.to_path_buf()),
1034 })?;
1035 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
1036
1037 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
1038 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
1039
1040 let (status, stdout, stderr) = tokio::join!(
1041 child.wait(),
1042 drain(&mut stdout_handle),
1043 drain(&mut stderr_handle),
1044 );
1045
1046 let status = status.map_err(|e| Error::Io {
1047 message: "failed to wait for claude process".to_string(),
1048 source: e,
1049 working_dir: working_dir.map(|p| p.to_path_buf()),
1050 })?;
1051 group.disarm();
1052
1053 let exit_code = status.code().unwrap_or(-1);
1054
1055 if !status.success() {
1056 return Err(Error::from_command_failure(
1057 format!("{} {}", binary.display(), args.join(" ")),
1058 exit_code,
1059 stdout,
1060 stderr,
1061 working_dir.map(|p| p.to_path_buf()),
1062 ));
1063 }
1064
1065 Ok(CommandOutput {
1066 stdout,
1067 stderr,
1068 exit_code,
1069 success: true,
1070 })
1071}
1072
1073#[cfg(feature = "async")]
1086async fn run_with_timeout(
1087 binary: &std::path::Path,
1088 args: &[String],
1089 env: &std::collections::HashMap<String, String>,
1090 clear_env: bool,
1091 working_dir: Option<&std::path::Path>,
1092 timeout: Duration,
1093 policy: SpawnPolicy<'_>,
1094) -> Result<CommandOutput> {
1095 run_with_stop(
1096 binary,
1097 args,
1098 env,
1099 clear_env,
1100 working_dir,
1101 timeout_stop(timeout),
1102 policy,
1103 )
1104 .await
1105}
1106
1107#[cfg(feature = "async")]
1108async fn run_with_stop(
1109 binary: &std::path::Path,
1110 args: &[String],
1111 env: &std::collections::HashMap<String, String>,
1112 clear_env: bool,
1113 working_dir: Option<&std::path::Path>,
1114 stop: StopFuture<'_>,
1115 policy: SpawnPolicy<'_>,
1116) -> Result<CommandOutput> {
1117 let SpawnPolicy {
1118 process_group,
1119 kill_grace,
1120 die_with_parent,
1121 on_spawn,
1122 } = policy;
1123 let mut cmd = Command::new(binary);
1124 cmd.args(args);
1125 cmd.stdin(std::process::Stdio::null());
1126 cmd.stdout(std::process::Stdio::piped());
1127 cmd.stderr(std::process::Stdio::piped());
1128 cmd.kill_on_drop(true);
1131 apply_process_group(&mut cmd, process_group);
1135 apply_die_with_parent(&mut cmd, die_with_parent);
1136 apply_child_environment(cmd.as_std_mut(), clear_env, env);
1137
1138 if let Some(dir) = working_dir {
1139 cmd.current_dir(dir);
1140 }
1141
1142 let mut child = spawn_retrying_txtbsy(&mut cmd)
1143 .await
1144 .map_err(|e| Error::Io {
1145 message: format!("failed to spawn claude: {e}"),
1146 source: e,
1147 working_dir: working_dir.map(|p| p.to_path_buf()),
1148 })?;
1149 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
1150
1151 let mut stdout = child.stdout.take().expect("stdout was piped");
1152 let mut stderr = child.stderr.take().expect("stderr was piped");
1153
1154 let wait_and_drain = async {
1159 let (status, stdout_str, stderr_str) =
1160 tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
1161 (status, stdout_str, stderr_str)
1162 };
1163
1164 match tokio::select! {
1165 outcome = wait_and_drain => Ok(outcome),
1166 reason = stop => Err(reason),
1167 } {
1168 Ok((Ok(status), stdout, stderr)) => {
1169 group.disarm();
1170 let exit_code = status.code().unwrap_or(-1);
1171
1172 if !status.success() {
1173 return Err(Error::from_command_failure(
1174 format!("{} {}", binary.display(), args.join(" ")),
1175 exit_code,
1176 stdout,
1177 stderr,
1178 working_dir.map(|p| p.to_path_buf()),
1179 ));
1180 }
1181
1182 Ok(CommandOutput {
1183 stdout,
1184 stderr,
1185 exit_code,
1186 success: true,
1187 })
1188 }
1189 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
1190 message: "failed to wait for claude process".to_string(),
1191 source: e,
1192 working_dir: working_dir.map(|p| p.to_path_buf()),
1193 }),
1194 Err(reason) => {
1195 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
1200 let drain_budget = Duration::from_millis(200);
1201 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
1202 .await
1203 .unwrap_or_default();
1204 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
1205 .await
1206 .unwrap_or_default();
1207 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1208 warn!(
1209 stdout = %stdout_str,
1210 stderr = %stderr_str,
1211 "partial output from stopped process",
1212 );
1213 }
1214 Err(reason.into_error())
1215 }
1216 }
1217}
1218
1219#[cfg(feature = "async")]
1220async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
1221 let mut buf = Vec::new();
1222 let _ = reader.read_to_end(&mut buf).await;
1223 String::from_utf8_lossy(&buf).into_owned()
1224}
1225
1226#[cfg(feature = "async")]
1227async fn drain_result<R: AsyncReadExt + Unpin>(reader: &mut R) -> std::io::Result<String> {
1228 let mut buf = Vec::new();
1229 reader.read_to_end(&mut buf).await?;
1230 Ok(String::from_utf8_lossy(&buf).into_owned())
1231}
1232
1233#[cfg(any(feature = "async", feature = "sync"))]
1242const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
1243
1244#[cfg(any(feature = "async", feature = "sync"))]
1251const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
1252
1253#[cfg(feature = "async")]
1264async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
1265 let start = std::time::Instant::now();
1266 let mut backoff = Duration::from_millis(1);
1267 loop {
1268 match cmd.spawn() {
1269 Err(e)
1270 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1271 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1272 {
1273 tokio::time::sleep(backoff).await;
1274 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1275 }
1276 other => return other,
1277 }
1278 }
1279}
1280
1281#[cfg(feature = "sync")]
1285pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1286 run_claude_with_retry_sync(claude, args, None)
1287}
1288
1289#[cfg(feature = "sync")]
1291pub fn run_claude_with_retry_sync(
1292 claude: &Claude,
1293 args: Vec<String>,
1294 retry_override: Option<&crate::retry::RetryPolicy>,
1295) -> Result<CommandOutput> {
1296 let policy = retry_override.or(claude.retry_policy.as_ref());
1297
1298 match policy {
1299 Some(policy) => {
1300 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
1301 }
1302 None => run_claude_once_sync(claude, args),
1303 }
1304}
1305
1306#[cfg(feature = "sync")]
1311pub fn run_claude_with_stdin_prompt_sync(
1312 claude: &Claude,
1313 args: Vec<String>,
1314 stdin_content: String,
1315) -> Result<CommandOutput> {
1316 let command_args = full_command_args(claude, args);
1317
1318 let span = exec_span(claude, &command_args, "stdin-sync");
1319 let _enter = span.enter();
1320 let started = std::time::Instant::now();
1321 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
1322
1323 let result = if let Some(timeout) = claude.timeout {
1324 run_with_timeout_stdin_sync(
1325 &claude.binary,
1326 &command_args,
1327 &claude.env,
1328 claude.clear_env,
1329 claude.working_dir.as_deref(),
1330 timeout,
1331 stdin_content,
1332 SpawnPolicy::of(claude),
1333 )
1334 } else {
1335 run_internal_stdin_sync(
1336 &claude.binary,
1337 &command_args,
1338 &claude.env,
1339 claude.clear_env,
1340 claude.working_dir.as_deref(),
1341 stdin_content,
1342 SpawnPolicy::of(claude),
1343 )
1344 };
1345
1346 if let Ok(output) = &result {
1347 record_exec_outcome(&span, output.exit_code, started);
1348 }
1349 result
1350}
1351
1352#[cfg(feature = "sync")]
1353fn run_internal_stdin_sync(
1354 binary: &std::path::Path,
1355 args: &[String],
1356 env: &std::collections::HashMap<String, String>,
1357 clear_env: bool,
1358 working_dir: Option<&std::path::Path>,
1359 stdin_content: String,
1360 policy: SpawnPolicy<'_>,
1361) -> Result<CommandOutput> {
1362 let SpawnPolicy {
1363 process_group,
1364 kill_grace: _, die_with_parent,
1366 on_spawn,
1367 } = policy;
1368 use std::io::Write;
1369 use std::process::{Command as StdCommand, Stdio};
1370
1371 let mut cmd = StdCommand::new(binary);
1372 cmd.args(args);
1373 cmd.stdin(Stdio::piped());
1374 cmd.stdout(Stdio::piped());
1375 cmd.stderr(Stdio::piped());
1376 apply_process_group_sync(&mut cmd, process_group);
1380 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1381 apply_child_environment(&mut cmd, clear_env, env);
1382
1383 if let Some(dir) = working_dir {
1384 cmd.current_dir(dir);
1385 }
1386
1387 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1388 message: format!("failed to spawn claude: {e}"),
1389 source: e,
1390 working_dir: working_dir.map(|p| p.to_path_buf()),
1391 })?;
1392 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1393
1394 if let Some(mut stdin) = child.stdin.take() {
1396 stdin
1397 .write_all(stdin_content.as_bytes())
1398 .map_err(|e| Error::Io {
1399 message: format!("failed to write to claude stdin: {e}"),
1400 source: e,
1401 working_dir: working_dir.map(|p| p.to_path_buf()),
1402 })?;
1403 stdin.flush().map_err(|e| Error::Io {
1404 message: format!("failed to flush claude stdin: {e}"),
1405 source: e,
1406 working_dir: working_dir.map(|p| p.to_path_buf()),
1407 })?;
1408 }
1410
1411 let output = child.wait_with_output().map_err(|e| Error::Io {
1412 message: "failed to wait for claude process".to_string(),
1413 source: e,
1414 working_dir: working_dir.map(|p| p.to_path_buf()),
1415 })?;
1416 group.disarm();
1417
1418 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1419 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1420 let exit_code = output.status.code().unwrap_or(-1);
1421
1422 if !output.status.success() {
1423 return Err(Error::from_command_failure(
1424 format!("{} {}", binary.display(), args.join(" ")),
1425 exit_code,
1426 stdout,
1427 stderr,
1428 working_dir.map(|p| p.to_path_buf()),
1429 ));
1430 }
1431
1432 Ok(CommandOutput {
1433 stdout,
1434 stderr,
1435 exit_code,
1436 success: true,
1437 })
1438}
1439
1440#[cfg(feature = "sync")]
1441#[allow(clippy::too_many_arguments)]
1442fn run_with_timeout_stdin_sync(
1443 binary: &std::path::Path,
1444 args: &[String],
1445 env: &std::collections::HashMap<String, String>,
1446 clear_env: bool,
1447 working_dir: Option<&std::path::Path>,
1448 timeout: Duration,
1449 stdin_content: String,
1450 policy: SpawnPolicy<'_>,
1451) -> Result<CommandOutput> {
1452 let SpawnPolicy {
1453 process_group,
1454 kill_grace,
1455 die_with_parent,
1456 on_spawn,
1457 } = policy;
1458 use std::io::Write;
1459 use std::process::{Command as StdCommand, Stdio};
1460 use std::thread;
1461 use wait_timeout::ChildExt;
1462
1463 let mut cmd = StdCommand::new(binary);
1464 cmd.args(args);
1465 cmd.stdin(Stdio::piped());
1466 cmd.stdout(Stdio::piped());
1467 cmd.stderr(Stdio::piped());
1468 apply_process_group_sync(&mut cmd, process_group);
1472 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1473 apply_child_environment(&mut cmd, clear_env, env);
1474
1475 if let Some(dir) = working_dir {
1476 cmd.current_dir(dir);
1477 }
1478
1479 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1480 message: format!("failed to spawn claude: {e}"),
1481 source: e,
1482 working_dir: working_dir.map(|p| p.to_path_buf()),
1483 })?;
1484 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1485
1486 if let Some(mut stdin) = child.stdin.take() {
1488 stdin
1489 .write_all(stdin_content.as_bytes())
1490 .map_err(|e| Error::Io {
1491 message: format!("failed to write to claude stdin: {e}"),
1492 source: e,
1493 working_dir: working_dir.map(|p| p.to_path_buf()),
1494 })?;
1495 stdin.flush().map_err(|e| Error::Io {
1496 message: format!("failed to flush claude stdin: {e}"),
1497 source: e,
1498 working_dir: working_dir.map(|p| p.to_path_buf()),
1499 })?;
1500 }
1502
1503 let stdout = child.stdout.take().expect("stdout was piped");
1504 let stderr = child.stderr.take().expect("stderr was piped");
1505
1506 let stdout_thread = thread::spawn(move || drain_sync(stdout));
1507 let stderr_thread = thread::spawn(move || drain_sync(stderr));
1508
1509 match child.wait_timeout(timeout).map_err(|e| Error::Io {
1510 message: "failed to wait for claude process".to_string(),
1511 source: e,
1512 working_dir: working_dir.map(|p| p.to_path_buf()),
1513 })? {
1514 Some(status) => {
1515 group.disarm();
1516 let stdout = stdout_thread.join().unwrap_or_default();
1517 let stderr = stderr_thread.join().unwrap_or_default();
1518 let exit_code = status.code().unwrap_or(-1);
1519
1520 if !status.success() {
1521 return Err(Error::from_command_failure(
1522 format!("{} {}", binary.display(), args.join(" ")),
1523 exit_code,
1524 stdout,
1525 stderr,
1526 working_dir.map(|p| p.to_path_buf()),
1527 ));
1528 }
1529
1530 Ok(CommandOutput {
1531 stdout,
1532 stderr,
1533 exit_code,
1534 success: true,
1535 })
1536 }
1537 None => {
1538 kill_group_with_grace_sync(&mut group, kill_grace);
1542 let _ = child.kill();
1543 let _ = child.wait();
1544 let (stdout_str, stderr_str) =
1545 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1546 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1547 warn!(
1548 stdout = %stdout_str,
1549 stderr = %stderr_str,
1550 "partial output from timed-out process",
1551 );
1552 }
1553 Err(Error::Timeout {
1554 timeout_seconds: timeout.as_secs(),
1555 })
1556 }
1557 }
1558}
1559
1560#[cfg(feature = "sync")]
1561fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1562 let command_args = full_command_args(claude, args);
1563
1564 let span = exec_span(claude, &command_args, "oneshot-sync");
1565 let _enter = span.enter();
1566 let started = std::time::Instant::now();
1567 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
1568
1569 let result = if let Some(timeout) = claude.timeout {
1570 run_with_timeout_sync(
1571 &claude.binary,
1572 &command_args,
1573 &claude.env,
1574 claude.clear_env,
1575 claude.working_dir.as_deref(),
1576 timeout,
1577 SpawnPolicy::of(claude),
1578 )
1579 } else {
1580 run_internal_sync(
1581 &claude.binary,
1582 &command_args,
1583 &claude.env,
1584 claude.clear_env,
1585 claude.working_dir.as_deref(),
1586 SpawnPolicy::of(claude),
1587 )
1588 };
1589
1590 if let Ok(output) = &result {
1591 record_exec_outcome(&span, output.exit_code, started);
1592 }
1593 result
1594}
1595
1596#[cfg(feature = "sync")]
1598pub fn run_claude_allow_exit_codes_sync(
1599 claude: &Claude,
1600 args: Vec<String>,
1601 allowed_codes: &[i32],
1602) -> Result<CommandOutput> {
1603 match run_claude_sync(claude, args) {
1604 Err(Error::CommandFailed {
1605 exit_code,
1606 stdout,
1607 stderr,
1608 ..
1609 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
1610 stdout,
1611 stderr,
1612 exit_code,
1613 success: false,
1614 }),
1615 other => other,
1616 }
1617}
1618
1619#[cfg(feature = "sync")]
1620fn run_internal_sync(
1621 binary: &std::path::Path,
1622 args: &[String],
1623 env: &std::collections::HashMap<String, String>,
1624 clear_env: bool,
1625 working_dir: Option<&std::path::Path>,
1626 policy: SpawnPolicy<'_>,
1627) -> Result<CommandOutput> {
1628 let SpawnPolicy {
1629 process_group,
1630 kill_grace: _, die_with_parent,
1632 on_spawn,
1633 } = policy;
1634 use std::process::{Command as StdCommand, Stdio};
1635
1636 let mut cmd = StdCommand::new(binary);
1637 cmd.args(args);
1638 cmd.stdin(Stdio::null());
1639 apply_process_group_sync(&mut cmd, process_group);
1644 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1645 apply_child_environment(&mut cmd, clear_env, env);
1646
1647 if let Some(dir) = working_dir {
1648 cmd.current_dir(dir);
1649 }
1650
1651 let output =
1652 output_retrying_txtbsy_sync_observed(&mut cmd, process_group, on_spawn).map_err(|e| {
1653 Error::Io {
1654 message: format!("failed to spawn claude: {e}"),
1655 source: e,
1656 working_dir: working_dir.map(|p| p.to_path_buf()),
1657 }
1658 })?;
1659
1660 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1661 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1662 let exit_code = output.status.code().unwrap_or(-1);
1663
1664 if !output.status.success() {
1665 return Err(Error::from_command_failure(
1666 format!("{} {}", binary.display(), args.join(" ")),
1667 exit_code,
1668 stdout,
1669 stderr,
1670 working_dir.map(|p| p.to_path_buf()),
1671 ));
1672 }
1673
1674 Ok(CommandOutput {
1675 stdout,
1676 stderr,
1677 exit_code,
1678 success: true,
1679 })
1680}
1681
1682#[cfg(feature = "sync")]
1690fn run_with_timeout_sync(
1691 binary: &std::path::Path,
1692 args: &[String],
1693 env: &std::collections::HashMap<String, String>,
1694 clear_env: bool,
1695 working_dir: Option<&std::path::Path>,
1696 timeout: Duration,
1697 policy: SpawnPolicy<'_>,
1698) -> Result<CommandOutput> {
1699 let SpawnPolicy {
1700 process_group,
1701 kill_grace,
1702 die_with_parent,
1703 on_spawn,
1704 } = policy;
1705 use std::process::{Command as StdCommand, Stdio};
1706 use std::thread;
1707 use wait_timeout::ChildExt;
1708
1709 let mut cmd = StdCommand::new(binary);
1710 cmd.args(args);
1711 cmd.stdin(Stdio::null());
1712 cmd.stdout(Stdio::piped());
1713 cmd.stderr(Stdio::piped());
1714 apply_process_group_sync(&mut cmd, process_group);
1718 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1719 apply_child_environment(&mut cmd, clear_env, env);
1720
1721 if let Some(dir) = working_dir {
1722 cmd.current_dir(dir);
1723 }
1724
1725 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1726 message: format!("failed to spawn claude: {e}"),
1727 source: e,
1728 working_dir: working_dir.map(|p| p.to_path_buf()),
1729 })?;
1730 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1731
1732 let stdout = child.stdout.take().expect("stdout was piped");
1737 let stderr = child.stderr.take().expect("stderr was piped");
1738
1739 let stdout_thread = thread::spawn(move || drain_sync(stdout));
1740 let stderr_thread = thread::spawn(move || drain_sync(stderr));
1741
1742 match child.wait_timeout(timeout).map_err(|e| Error::Io {
1743 message: "failed to wait for claude process".to_string(),
1744 source: e,
1745 working_dir: working_dir.map(|p| p.to_path_buf()),
1746 })? {
1747 Some(status) => {
1748 group.disarm();
1749 let stdout = stdout_thread.join().unwrap_or_default();
1750 let stderr = stderr_thread.join().unwrap_or_default();
1751 let exit_code = status.code().unwrap_or(-1);
1752
1753 if !status.success() {
1754 return Err(Error::from_command_failure(
1755 format!("{} {}", binary.display(), args.join(" ")),
1756 exit_code,
1757 stdout,
1758 stderr,
1759 working_dir.map(|p| p.to_path_buf()),
1760 ));
1761 }
1762
1763 Ok(CommandOutput {
1764 stdout,
1765 stderr,
1766 exit_code,
1767 success: true,
1768 })
1769 }
1770 None => {
1771 kill_group_with_grace_sync(&mut group, kill_grace);
1778 let _ = child.kill();
1779 let _ = child.wait();
1780
1781 let (stdout_str, stderr_str) =
1782 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1783
1784 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1785 warn!(
1786 stdout = %stdout_str,
1787 stderr = %stderr_str,
1788 "partial output from timed-out process",
1789 );
1790 }
1791
1792 Err(Error::Timeout {
1793 timeout_seconds: timeout.as_secs(),
1794 })
1795 }
1796 }
1797}
1798
1799#[cfg(feature = "sync")]
1800fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
1801 let mut buf = Vec::new();
1802 let _ = reader.read_to_end(&mut buf);
1803 String::from_utf8_lossy(&buf).into_owned()
1804}
1805
1806#[cfg(feature = "sync")]
1809fn spawn_retrying_txtbsy_sync(
1810 cmd: &mut std::process::Command,
1811) -> std::io::Result<std::process::Child> {
1812 let start = std::time::Instant::now();
1813 let mut backoff = Duration::from_millis(1);
1814 loop {
1815 match cmd.spawn() {
1816 Err(e)
1817 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1818 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1819 {
1820 std::thread::sleep(backoff);
1821 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1822 }
1823 other => return other,
1824 }
1825 }
1826}
1827
1828#[cfg(feature = "sync")]
1836#[cfg(feature = "sync")]
1842fn output_retrying_txtbsy_sync_observed(
1843 cmd: &mut std::process::Command,
1844 process_group: bool,
1845 on_spawn: Option<&crate::SpawnObserver>,
1846) -> std::io::Result<std::process::Output> {
1847 cmd.stdout(std::process::Stdio::piped());
1851 cmd.stderr(std::process::Stdio::piped());
1852
1853 let start = std::time::Instant::now();
1854 let mut backoff = Duration::from_millis(1);
1855 loop {
1856 let spawned = cmd.spawn().inspect(|child| {
1857 if let Some(observer) = on_spawn {
1858 let pid = child.id();
1859 observer(crate::SpawnInfo {
1860 pid,
1861 pgid: process_group.then_some(pid),
1862 });
1863 }
1864 });
1865 match spawned.and_then(std::process::Child::wait_with_output) {
1866 Err(e)
1867 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1868 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1869 {
1870 std::thread::sleep(backoff);
1871 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1872 }
1873 other => return other,
1874 }
1875 }
1876}
1877
1878#[cfg(feature = "sync")]
1884fn join_with_deadline(
1885 stdout_thread: std::thread::JoinHandle<String>,
1886 stderr_thread: std::thread::JoinHandle<String>,
1887 budget: Duration,
1888) -> (String, String) {
1889 use std::sync::mpsc;
1890 use std::thread;
1891
1892 let (tx, rx) = mpsc::channel::<(&'static str, String)>();
1893
1894 let tx_out = tx.clone();
1895 let tx_err = tx;
1896
1897 thread::spawn(move || {
1898 let s = stdout_thread.join().unwrap_or_default();
1899 let _ = tx_out.send(("stdout", s));
1900 });
1901 thread::spawn(move || {
1902 let s = stderr_thread.join().unwrap_or_default();
1903 let _ = tx_err.send(("stderr", s));
1904 });
1905
1906 let mut stdout = String::new();
1907 let mut stderr = String::new();
1908 let deadline = std::time::Instant::now() + budget;
1909
1910 for _ in 0..2 {
1911 let now = std::time::Instant::now();
1912 if now >= deadline {
1913 break;
1914 }
1915 match rx.recv_timeout(deadline - now) {
1916 Ok(("stdout", s)) => stdout = s,
1917 Ok(("stderr", s)) => stderr = s,
1918 Ok(_) => unreachable!(),
1919 Err(_) => break,
1920 }
1921 }
1922
1923 (stdout, stderr)
1924}
1925
1926#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1933mod tests {
1934 use super::*;
1935 use std::io::Write;
1936 use std::os::unix::fs::PermissionsExt;
1937
1938 use crate::Claude;
1939
1940 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1944 let dir = tempfile::tempdir().expect("tempdir");
1945 let path = dir.path().join("fake-claude.sh");
1946 {
1951 let mut f = std::fs::File::create(&path).expect("create script");
1952 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1953 f.sync_all().expect("sync script");
1954 }
1955 let perms = std::fs::Permissions::from_mode(0o755);
1956 std::fs::set_permissions(&path, perms).expect("chmod");
1957 (dir, path)
1958 }
1959
1960 fn client(path: &std::path::Path) -> Claude {
1961 Claude::builder()
1962 .binary(path)
1963 .build()
1964 .expect("build client")
1965 }
1966
1967 #[test]
1968 fn full_command_args_puts_global_args_first() {
1969 let claude = Claude::builder()
1970 .binary("/usr/local/bin/claude")
1971 .arg("--debug")
1972 .arg("--verbose")
1973 .build()
1974 .expect("build client");
1975 let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
1976 assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
1977 }
1978
1979 #[test]
1980 fn full_command_args_without_global_args_is_passthrough() {
1981 let claude = Claude::builder()
1982 .binary("/usr/local/bin/claude")
1983 .build()
1984 .expect("build client");
1985 let args = full_command_args(&claude, vec!["--print".to_string()]);
1986 assert_eq!(args, ["--print"]);
1987 }
1988
1989 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1991
1992 fn set_scrub_vars() {
1993 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1994 unsafe {
1997 std::env::set_var("CLAUDECODE", "1");
1998 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1999 }
2000 }
2001
2002 fn clear_scrub_vars() {
2003 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2004 unsafe {
2006 std::env::remove_var("CLAUDECODE");
2007 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
2008 }
2009 }
2010
2011 #[cfg(feature = "async")]
2014 #[tokio::test]
2015 async fn async_success_maps_output() {
2016 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
2017 let out = run_claude(&client(&path), vec!["--version".into()])
2018 .await
2019 .expect("success");
2020 assert!(out.success);
2021 assert_eq!(out.exit_code, 0);
2022 assert!(out.stdout.contains("hi there"));
2023 }
2024
2025 #[cfg(feature = "async")]
2026 #[tokio::test]
2027 async fn async_nonzero_exit_maps_command_failed() {
2028 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2029 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2030 match err {
2031 Error::CommandFailed {
2032 exit_code, stderr, ..
2033 } => {
2034 assert_eq!(exit_code, 3);
2035 assert!(stderr.contains("boom"));
2036 }
2037 other => panic!("expected CommandFailed, got {other:?}"),
2038 }
2039 }
2040
2041 #[cfg(feature = "async")]
2042 #[tokio::test]
2043 async fn async_rail_stop_maps_max_turns() {
2044 let (_dir, path) = fake_script(
2045 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
2046 );
2047 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2048 assert!(
2049 matches!(
2050 err,
2051 Error::MaxTurnsExceeded {
2052 max_turns: Some(2),
2053 ..
2054 }
2055 ),
2056 "got: {err:?}"
2057 );
2058 }
2059
2060 #[cfg(feature = "async")]
2061 #[tokio::test]
2062 async fn async_auth_shaped_stderr_maps_auth() {
2063 let (_dir, path) =
2064 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
2065 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2066 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
2067 }
2068
2069 #[cfg(feature = "async")]
2070 #[tokio::test]
2071 async fn async_scrubs_claude_env_vars() {
2072 let (_dir, path) =
2073 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2074 set_scrub_vars();
2080 let out = run_claude(&client(&path), vec![]).await.expect("success");
2081 clear_scrub_vars();
2082 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2083 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2084 }
2085
2086 #[cfg(feature = "async")]
2087 #[tokio::test]
2088 async fn async_applies_working_dir() {
2089 let (_dir, path) = fake_script(r#"pwd"#);
2090 let workdir = tempfile::tempdir().expect("workdir");
2091 let claude = Claude::builder()
2092 .binary(&path)
2093 .working_dir(workdir.path())
2094 .build()
2095 .expect("build");
2096 let out = run_claude(&claude, vec![]).await.expect("success");
2097 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
2098 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
2099 assert_eq!(got, want);
2100 }
2101
2102 #[cfg(feature = "async")]
2103 #[tokio::test]
2104 async fn async_stdin_prompt_round_trips() {
2105 let (_dir, path) = fake_script(r#"cat"#);
2106 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
2107 .await
2108 .expect("success");
2109 assert!(out.stdout.contains("hello via stdin"));
2110 }
2111
2112 #[cfg(feature = "async")]
2117 #[tokio::test]
2118 async fn async_spawn_retry_passes_through_non_txtbsy_error() {
2119 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
2120 let err = spawn_retrying_txtbsy(&mut cmd)
2121 .await
2122 .expect_err("spawn of missing binary should fail");
2123 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2124 }
2125
2126 #[cfg(feature = "async")]
2127 #[tokio::test]
2128 async fn async_allow_exit_codes_permits_listed_code() {
2129 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2130 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
2131 .await
2132 .expect("allowed code is Ok");
2133 assert!(!out.success);
2134 assert_eq!(out.exit_code, 2);
2135 assert!(out.stdout.contains("out"));
2136 }
2137
2138 #[cfg(feature = "async")]
2139 #[tokio::test]
2140 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
2141 let (_dir, path) = fake_script(r#"exit 2"#);
2142 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
2143 .await
2144 .unwrap_err();
2145 assert!(
2146 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
2147 "got: {err:?}"
2148 );
2149 }
2150
2151 #[cfg(feature = "async")]
2152 #[tokio::test]
2153 async fn async_timeout_fires_on_slow_child() {
2154 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2155 let claude = Claude::builder()
2156 .binary(&path)
2157 .timeout(Duration::from_millis(300))
2158 .build()
2159 .expect("build");
2160 let err = run_claude(&claude, vec![]).await.unwrap_err();
2161 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2162 }
2163
2164 #[cfg(feature = "async")]
2165 #[tokio::test]
2166 async fn async_timeout_path_returns_output_when_fast() {
2167 let (_dir, path) = fake_script(r#"echo quick"#);
2168 let claude = Claude::builder()
2169 .binary(&path)
2170 .timeout(Duration::from_secs(30))
2171 .build()
2172 .expect("build");
2173 let out = run_claude(&claude, vec![]).await.expect("success");
2174 assert!(out.stdout.contains("quick"));
2175 }
2176
2177 #[cfg(feature = "async")]
2178 #[tokio::test]
2179 async fn async_timeout_path_maps_command_failed() {
2180 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
2181 let claude = Claude::builder()
2182 .binary(&path)
2183 .timeout(Duration::from_secs(30))
2184 .build()
2185 .expect("build");
2186 let err = run_claude(&claude, vec![]).await.unwrap_err();
2187 assert!(
2188 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
2189 "got: {err:?}"
2190 );
2191 }
2192
2193 #[cfg(feature = "async")]
2194 #[tokio::test]
2195 async fn async_stdin_with_timeout_round_trips() {
2196 let (_dir, path) = fake_script(r#"cat"#);
2197 let claude = Claude::builder()
2198 .binary(&path)
2199 .timeout(Duration::from_secs(30))
2200 .build()
2201 .expect("build");
2202 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
2203 .await
2204 .expect("success");
2205 assert!(out.stdout.contains("piped under timeout"));
2206 }
2207
2208 #[cfg(feature = "async")]
2209 #[tokio::test]
2210 async fn async_stdin_timeout_fires_on_slow_child() {
2211 let (_dir, path) = fake_script(r#"sleep 3"#);
2212 let claude = Claude::builder()
2213 .binary(&path)
2214 .timeout(Duration::from_millis(300))
2215 .build()
2216 .expect("build");
2217 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
2218 .await
2219 .unwrap_err();
2220 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2221 }
2222
2223 #[cfg(feature = "async")]
2226 async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
2227 where
2228 F: std::future::Future,
2229 F::Output: std::fmt::Debug,
2230 {
2231 tokio::pin!(fut);
2232 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2233 loop {
2234 if let Some(pid) = std::fs::read_to_string(pid_path)
2235 .ok()
2236 .and_then(|s| s.trim().parse().ok())
2237 {
2238 return pid;
2240 }
2241 assert!(
2242 std::time::Instant::now() < deadline,
2243 "child never wrote its pid file"
2244 );
2245 tokio::select! {
2246 out = &mut fut => panic!("future completed before drop: {out:?}"),
2247 _ = tokio::time::sleep(Duration::from_millis(10)) => {}
2248 }
2249 }
2250 }
2251
2252 fn assert_pid_killed(pid: u32) {
2258 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2259 loop {
2260 let out = std::process::Command::new("ps")
2261 .args(["-o", "stat=", "-p", &pid.to_string()])
2262 .output()
2263 .expect("run ps");
2264 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2265 if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
2266 return;
2267 }
2268 assert!(
2269 std::time::Instant::now() < deadline,
2270 "process {pid} still alive (stat {stat}) after kill"
2271 );
2272 std::thread::sleep(Duration::from_millis(25));
2273 }
2274 }
2275
2276 #[cfg(feature = "async")]
2277 fn assert_pid_not_live_now(pid: u32) {
2278 let out = std::process::Command::new("ps")
2279 .args(["-o", "stat=", "-p", &pid.to_string()])
2280 .output()
2281 .expect("run ps");
2282 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2283 assert!(
2284 !out.status.success() || stat.is_empty() || stat.starts_with('Z'),
2285 "process {pid} still live after terminal settlement (stat {stat})"
2286 );
2287 }
2288
2289 fn group_script(
2297 pid_path: &std::path::Path,
2298 gpid_path: &std::path::Path,
2299 ) -> (tempfile::TempDir, std::path::PathBuf) {
2300 fake_script(&format!(
2305 concat!(
2306 "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2307 "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2308 "echo $$ > \"{p}\"\n",
2309 "exec sleep 300",
2310 ),
2311 g = gpid_path.display(),
2312 p = pid_path.display(),
2313 ))
2314 }
2315
2316 fn try_read_pid(path: &std::path::Path) -> Option<u32> {
2318 std::fs::read_to_string(path).ok()?.trim().parse().ok()
2319 }
2320
2321 #[cfg(feature = "async")]
2325 fn read_pid(path: &std::path::Path) -> u32 {
2326 try_read_pid(path).expect("pid file readable")
2327 }
2328
2329 #[cfg(feature = "async")]
2335 #[tokio::test]
2336 async fn async_dropping_in_flight_future_kills_child() {
2337 let workdir = tempfile::tempdir().expect("workdir");
2338 let pid_path = workdir.path().join("pid");
2339 let (_dir, path) = fake_script(&format!(
2340 r#"echo $$ > "{}"; exec sleep 30"#,
2341 pid_path.display()
2342 ));
2343 let claude = client(&path);
2344 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2345 assert_pid_killed(pid);
2346 }
2347
2348 #[cfg(feature = "async")]
2353 #[tokio::test]
2354 async fn async_dropping_in_flight_future_kills_child_with_timeout() {
2355 let workdir = tempfile::tempdir().expect("workdir");
2356 let pid_path = workdir.path().join("pid");
2357 let (_dir, path) = fake_script(&format!(
2358 r#"echo $$ > "{}"; exec sleep 30"#,
2359 pid_path.display()
2360 ));
2361 let claude = Claude::builder()
2362 .binary(&path)
2363 .timeout(Duration::from_secs(120))
2364 .build()
2365 .expect("build");
2366 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2367 assert_pid_killed(pid);
2368 }
2369
2370 #[cfg(feature = "async")]
2374 #[tokio::test]
2375 async fn async_dropping_in_flight_future_kills_process_group() {
2376 let workdir = tempfile::tempdir().expect("workdir");
2377 let pid_path = workdir.path().join("pid");
2378 let gpid_path = workdir.path().join("gpid");
2379 let (_dir, path) = group_script(&pid_path, &gpid_path);
2380 let claude = client(&path);
2381 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2382 assert_pid_killed(pid);
2383 assert_pid_killed(read_pid(&gpid_path));
2384 }
2385
2386 #[cfg(feature = "async")]
2392 #[tokio::test]
2393 async fn async_timeout_kills_process_group() {
2394 let mut observed = false;
2395 for _ in 0..5 {
2396 let workdir = tempfile::tempdir().expect("workdir");
2397 let pid_path = workdir.path().join("pid");
2398 let gpid_path = workdir.path().join("gpid");
2399 let (_dir, path) = group_script(&pid_path, &gpid_path);
2400 let claude = Claude::builder()
2401 .binary(&path)
2402 .timeout(Duration::from_millis(1000))
2403 .build()
2404 .expect("build");
2405 let err = run_claude(&claude, vec![]).await.unwrap_err();
2406 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2407 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2408 assert_pid_killed(pid);
2409 assert_pid_killed(gpid);
2410 observed = true;
2411 break;
2412 }
2413 }
2414 assert!(observed, "child never recorded pids within 5 timeout runs");
2415 }
2416
2417 #[cfg(feature = "async")]
2418 #[tokio::test]
2419 async fn async_explicit_cancellation_settles_process_group() {
2420 let workdir = tempfile::tempdir().expect("workdir");
2421 let pid_path = workdir.path().join("pid");
2422 let gpid_path = workdir.path().join("gpid");
2423 let (_dir, path) = group_script(&pid_path, &gpid_path);
2424 let claude = Claude::builder()
2425 .binary(&path)
2426 .kill_grace(Duration::from_millis(10))
2427 .build()
2428 .expect("build");
2429 let cancel = async {
2430 while !pid_path.exists() || !gpid_path.exists() {
2431 tokio::time::sleep(Duration::from_millis(10)).await;
2432 }
2433 };
2434
2435 let error = run_claude_cancellable(&claude, vec![], cancel)
2436 .await
2437 .expect_err("run must be cancelled");
2438 assert!(matches!(error, Error::Cancelled));
2439 assert_pid_not_live_now(read_pid(&pid_path));
2440 assert_pid_not_live_now(read_pid(&gpid_path));
2441 }
2442
2443 #[cfg(feature = "async")]
2444 #[tokio::test]
2445 async fn async_stdin_failure_settles_process_group() {
2446 let workdir = tempfile::tempdir().expect("workdir");
2447 let pid_path = workdir.path().join("pid");
2448 let gpid_path = workdir.path().join("gpid");
2449 let (_dir, path) = fake_script(&format!(
2450 concat!(
2451 "sleep 300 </dev/null &\n",
2452 "child=$!\n",
2453 "echo $$ > '{p}'\n",
2454 "echo $child > '{g}'\n",
2455 "exec 0<&-\n",
2456 "wait $child",
2457 ),
2458 p = pid_path.display(),
2459 g = gpid_path.display(),
2460 ));
2461 let claude = Claude::builder()
2462 .binary(&path)
2463 .kill_grace(Duration::from_millis(10))
2464 .build()
2465 .expect("build");
2466
2467 let error = run_claude_with_stdin_prompt(&claude, vec![], "x".repeat(4 * 1024 * 1024))
2468 .await
2469 .expect_err("stdin write must fail");
2470 assert!(matches!(error, Error::Io { .. }));
2471 assert_pid_not_live_now(read_pid(&pid_path));
2472 assert_pid_not_live_now(read_pid(&gpid_path));
2473 }
2474
2475 #[cfg(feature = "async")]
2481 #[tokio::test]
2482 async fn async_process_group_opt_out_kills_only_direct_child() {
2483 let workdir = tempfile::tempdir().expect("workdir");
2484 let pid_path = workdir.path().join("pid");
2485 let gpid_path = workdir.path().join("gpid");
2486 let (_dir, path) = group_script(&pid_path, &gpid_path);
2487 let claude = Claude::builder()
2488 .binary(&path)
2489 .process_group(false)
2490 .build()
2491 .expect("build");
2492 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2493 assert_pid_killed(pid);
2494
2495 let gpid = read_pid(&gpid_path);
2497 let out = std::process::Command::new("ps")
2498 .args(["-o", "stat=", "-p", &gpid.to_string()])
2499 .output()
2500 .expect("run ps");
2501 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2502 assert!(
2503 out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
2504 "grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
2505 );
2506
2507 let _ = std::process::Command::new("kill")
2509 .args(["-9", &gpid.to_string()])
2510 .status();
2511 }
2512
2513 fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
2519 fake_script(&format!(
2520 concat!(
2521 "trap 'echo term > \"{m}\"; exit 0' TERM\n",
2522 "sleep 300 &\n",
2523 "wait $!",
2524 ),
2525 m = marker.display(),
2526 ))
2527 }
2528
2529 #[cfg(feature = "async")]
2535 #[tokio::test]
2536 async fn async_timeout_with_grace_delivers_sigterm_first() {
2537 let mut observed = false;
2538 for _ in 0..5 {
2539 let workdir = tempfile::tempdir().expect("workdir");
2540 let marker = workdir.path().join("term-marker");
2541 let (_dir, path) = term_trap_script(&marker);
2542 let claude = Claude::builder()
2543 .binary(&path)
2544 .timeout(Duration::from_millis(500))
2545 .kill_grace(Duration::from_secs(1))
2546 .build()
2547 .expect("build");
2548 let err = run_claude(&claude, vec![]).await.unwrap_err();
2549 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2550 if marker.exists() {
2551 observed = true;
2552 break;
2553 }
2554 }
2555 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2556 }
2557
2558 #[cfg(feature = "sync")]
2560 #[test]
2561 fn sync_timeout_with_grace_delivers_sigterm_first() {
2562 let mut observed = false;
2563 for _ in 0..5 {
2564 let workdir = tempfile::tempdir().expect("workdir");
2565 let marker = workdir.path().join("term-marker");
2566 let (_dir, path) = term_trap_script(&marker);
2567 let claude = Claude::builder()
2568 .binary(&path)
2569 .timeout(Duration::from_millis(500))
2570 .kill_grace(Duration::from_secs(1))
2571 .build()
2572 .expect("build");
2573 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2574 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2575 if marker.exists() {
2576 observed = true;
2577 break;
2578 }
2579 }
2580 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2581 }
2582
2583 #[cfg(feature = "async")]
2585 #[tokio::test]
2586 async fn async_dropping_in_flight_stdin_future_kills_child() {
2587 let workdir = tempfile::tempdir().expect("workdir");
2588 let pid_path = workdir.path().join("pid");
2589 let (_dir, path) = fake_script(&format!(
2590 r#"echo $$ > "{}"; exec sleep 30"#,
2591 pid_path.display()
2592 ));
2593 let claude = client(&path);
2594 let pid = drop_in_flight_and_capture_pid(
2595 run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
2596 &pid_path,
2597 )
2598 .await;
2599 assert_pid_killed(pid);
2600 }
2601
2602 #[cfg(feature = "async")]
2603 #[tokio::test]
2604 async fn async_spawn_failure_maps_io() {
2605 let claude = Claude::builder()
2606 .binary("/nonexistent/definitely/not/here")
2607 .build()
2608 .expect("build");
2609 let err = run_claude(&claude, vec![]).await.unwrap_err();
2610 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
2611 }
2612
2613 #[cfg(feature = "sync")]
2616 #[test]
2617 fn sync_success_maps_output() {
2618 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
2619 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2620 assert!(out.success);
2621 assert!(out.stdout.contains("hi sync"));
2622 }
2623
2624 #[cfg(feature = "sync")]
2625 #[test]
2626 fn sync_nonzero_exit_maps_command_failed() {
2627 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2628 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
2629 match err {
2630 Error::CommandFailed {
2631 exit_code, stderr, ..
2632 } => {
2633 assert_eq!(exit_code, 3);
2634 assert!(stderr.contains("boom"));
2635 }
2636 other => panic!("expected CommandFailed, got {other:?}"),
2637 }
2638 }
2639
2640 #[cfg(feature = "sync")]
2641 #[test]
2642 fn sync_scrubs_claude_env_vars() {
2643 let (_dir, path) =
2644 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2645 set_scrub_vars();
2646 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2647 clear_scrub_vars();
2648 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2649 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2650 }
2651
2652 #[cfg(feature = "sync")]
2653 #[test]
2654 fn sync_stdin_prompt_round_trips() {
2655 let (_dir, path) = fake_script(r#"cat"#);
2656 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
2657 .expect("success");
2658 assert!(out.stdout.contains("sync stdin"));
2659 }
2660
2661 #[cfg(feature = "sync")]
2664 #[test]
2665 fn sync_spawn_retry_passes_through_non_txtbsy_error() {
2666 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2667 let err =
2668 spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
2669 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2670 }
2671
2672 #[cfg(feature = "sync")]
2673 #[test]
2674 fn sync_output_retry_passes_through_non_txtbsy_error() {
2675 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2676 let err = output_retrying_txtbsy_sync_observed(&mut cmd, false, None)
2677 .expect_err("output of missing binary should fail");
2678 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2679 }
2680
2681 #[cfg(feature = "sync")]
2682 #[test]
2683 fn sync_allow_exit_codes_permits_listed_code() {
2684 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2685 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
2686 .expect("allowed code is Ok");
2687 assert!(!out.success);
2688 assert_eq!(out.exit_code, 2);
2689 }
2690
2691 #[cfg(feature = "sync")]
2692 #[test]
2693 fn sync_timeout_fires_on_slow_child() {
2694 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2695 let claude = Claude::builder()
2696 .binary(&path)
2697 .timeout(Duration::from_millis(300))
2698 .build()
2699 .expect("build");
2700 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2701 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2702 }
2703
2704 #[cfg(feature = "sync")]
2708 #[test]
2709 fn sync_timeout_kills_process_group() {
2710 let mut observed = false;
2711 for _ in 0..5 {
2712 let workdir = tempfile::tempdir().expect("workdir");
2713 let pid_path = workdir.path().join("pid");
2714 let gpid_path = workdir.path().join("gpid");
2715 let (_dir, path) = group_script(&pid_path, &gpid_path);
2716 let claude = Claude::builder()
2717 .binary(&path)
2718 .timeout(Duration::from_millis(1000))
2719 .build()
2720 .expect("build");
2721 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2722 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2723 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2724 assert_pid_killed(pid);
2725 assert_pid_killed(gpid);
2726 observed = true;
2727 break;
2728 }
2729 }
2730 assert!(observed, "child never recorded pids within 5 timeout runs");
2731 }
2732
2733 #[cfg(feature = "sync")]
2734 #[test]
2735 fn sync_timeout_path_returns_output_when_fast() {
2736 let (_dir, path) = fake_script(r#"echo quick"#);
2737 let claude = Claude::builder()
2738 .binary(&path)
2739 .timeout(Duration::from_secs(30))
2740 .build()
2741 .expect("build");
2742 let out = run_claude_sync(&claude, vec![]).expect("success");
2743 assert!(out.stdout.contains("quick"));
2744 }
2745
2746 #[cfg(feature = "sync")]
2747 #[test]
2748 fn sync_stdin_with_timeout_round_trips() {
2749 let (_dir, path) = fake_script(r#"cat"#);
2750 let claude = Claude::builder()
2751 .binary(&path)
2752 .timeout(Duration::from_secs(30))
2753 .build()
2754 .expect("build");
2755 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
2756 .expect("success");
2757 assert!(out.stdout.contains("sync piped"));
2758 }
2759}