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 = "sync")]
421pub(crate) fn kill_group_with_grace_sync(group: &mut GroupKillGuard, grace: Option<Duration>) {
422 if let Some(g) = grace
423 && !g.is_zero()
424 && group.is_armed()
425 {
426 group.term_now();
427 std::thread::sleep(g);
428 }
429 group.kill_now();
430}
431
432#[cfg(feature = "async")]
443pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
444 run_claude_with_retry(claude, args, None).await
445}
446
447#[cfg(feature = "async")]
451pub async fn run_claude_with_retry(
452 claude: &Claude,
453 args: Vec<String>,
454 retry_override: Option<&crate::retry::RetryPolicy>,
455) -> Result<CommandOutput> {
456 let policy = retry_override.or(claude.retry_policy.as_ref());
457
458 match policy {
459 Some(policy) => {
460 crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
461 }
462 None => run_claude_once(claude, args).await,
463 }
464}
465
466#[cfg(feature = "async")]
474pub async fn run_claude_with_stdin_prompt(
475 claude: &Claude,
476 args: Vec<String>,
477 stdin_content: String,
478) -> Result<CommandOutput> {
479 run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
480}
481
482#[cfg(feature = "async")]
483async fn run_claude_with_stdin_prompt_internal(
484 claude: &Claude,
485 args: Vec<String>,
486 stdin_content: String,
487) -> Result<CommandOutput> {
488 let command_args = full_command_args(claude, args);
489
490 let span = exec_span(claude, &command_args, "stdin");
491 let _enter = span.enter();
492 let started = std::time::Instant::now();
493 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
494
495 let binary = &claude.binary;
496 let env = &claude.env;
497 let clear_env = claude.clear_env;
498 let working_dir = claude.working_dir.as_deref();
499
500 let result = if let Some(timeout) = claude.timeout {
501 run_with_timeout_stdin(
502 binary,
503 &command_args,
504 env,
505 clear_env,
506 working_dir,
507 timeout,
508 stdin_content,
509 SpawnPolicy::of(claude),
510 )
511 .await
512 } else {
513 run_internal_stdin(
514 binary,
515 &command_args,
516 env,
517 clear_env,
518 working_dir,
519 stdin_content,
520 SpawnPolicy::of(claude),
521 )
522 .await
523 };
524
525 if let Ok(output) = &result {
526 record_exec_outcome(&span, output.exit_code, started);
527 }
528 result
529}
530
531#[cfg(feature = "async")]
532async fn run_internal_stdin(
533 binary: &std::path::Path,
534 args: &[String],
535 env: &std::collections::HashMap<String, String>,
536 clear_env: bool,
537 working_dir: Option<&std::path::Path>,
538 stdin_content: String,
539 policy: SpawnPolicy<'_>,
540) -> Result<CommandOutput> {
541 let SpawnPolicy {
542 process_group,
543 kill_grace: _, die_with_parent,
545 on_spawn,
546 } = policy;
547 use tokio::io::AsyncWriteExt;
548
549 let mut cmd = Command::new(binary);
550 cmd.args(args);
551 cmd.stdin(std::process::Stdio::piped());
552 cmd.stdout(std::process::Stdio::piped());
553 cmd.stderr(std::process::Stdio::piped());
554 cmd.kill_on_drop(true);
557 apply_process_group(&mut cmd, process_group);
561 apply_die_with_parent(&mut cmd, die_with_parent);
562 apply_child_environment(cmd.as_std_mut(), clear_env, env);
563
564 if let Some(dir) = working_dir {
565 cmd.current_dir(dir);
566 }
567
568 let mut child = spawn_retrying_txtbsy(&mut cmd)
569 .await
570 .map_err(|e| Error::Io {
571 message: format!("failed to spawn claude: {e}"),
572 source: e,
573 working_dir: working_dir.map(|p| p.to_path_buf()),
574 })?;
575 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
576
577 if let Some(mut stdin) = child.stdin.take() {
579 stdin
580 .write_all(stdin_content.as_bytes())
581 .await
582 .map_err(|e| Error::Io {
583 message: format!("failed to write to claude stdin: {e}"),
584 source: e,
585 working_dir: working_dir.map(|p| p.to_path_buf()),
586 })?;
587 }
589
590 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
591 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
592
593 let (status, stdout_str, stderr_str) = tokio::join!(
594 child.wait(),
595 drain(&mut stdout_handle),
596 drain(&mut stderr_handle),
597 );
598
599 let status = status.map_err(|e| Error::Io {
600 message: "failed to wait for claude process".to_string(),
601 source: e,
602 working_dir: working_dir.map(|p| p.to_path_buf()),
603 })?;
604 group.disarm();
605
606 let exit_code = status.code().unwrap_or(-1);
607
608 if !status.success() {
609 return Err(Error::from_command_failure(
610 format!("{} {}", binary.display(), args.join(" ")),
611 exit_code,
612 stdout_str,
613 stderr_str,
614 working_dir.map(|p| p.to_path_buf()),
615 ));
616 }
617
618 Ok(CommandOutput {
619 stdout: stdout_str,
620 stderr: stderr_str,
621 exit_code,
622 success: true,
623 })
624}
625
626#[cfg(feature = "async")]
627#[allow(clippy::too_many_arguments)]
628async fn run_with_timeout_stdin(
629 binary: &std::path::Path,
630 args: &[String],
631 env: &std::collections::HashMap<String, String>,
632 clear_env: bool,
633 working_dir: Option<&std::path::Path>,
634 timeout: Duration,
635 stdin_content: String,
636 policy: SpawnPolicy<'_>,
637) -> Result<CommandOutput> {
638 let SpawnPolicy {
639 process_group,
640 kill_grace,
641 die_with_parent,
642 on_spawn,
643 } = policy;
644 use tokio::io::AsyncWriteExt;
645
646 let mut cmd = Command::new(binary);
647 cmd.args(args);
648 cmd.stdin(std::process::Stdio::piped());
649 cmd.stdout(std::process::Stdio::piped());
650 cmd.stderr(std::process::Stdio::piped());
651 cmd.kill_on_drop(true);
654 apply_process_group(&mut cmd, process_group);
658 apply_die_with_parent(&mut cmd, die_with_parent);
659 apply_child_environment(cmd.as_std_mut(), clear_env, env);
660
661 if let Some(dir) = working_dir {
662 cmd.current_dir(dir);
663 }
664
665 let mut child = spawn_retrying_txtbsy(&mut cmd)
666 .await
667 .map_err(|e| Error::Io {
668 message: format!("failed to spawn claude: {e}"),
669 source: e,
670 working_dir: working_dir.map(|p| p.to_path_buf()),
671 })?;
672 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
673
674 if let Some(mut stdin) = child.stdin.take() {
676 stdin
677 .write_all(stdin_content.as_bytes())
678 .await
679 .map_err(|e| Error::Io {
680 message: format!("failed to write to claude stdin: {e}"),
681 source: e,
682 working_dir: working_dir.map(|p| p.to_path_buf()),
683 })?;
684 }
686
687 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
688 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
689
690 let wait_and_drain = async {
691 let (status, stdout_str, stderr_str) = tokio::join!(
692 child.wait(),
693 drain(&mut stdout_handle),
694 drain(&mut stderr_handle),
695 );
696 (status, stdout_str, stderr_str)
697 };
698
699 match tokio::time::timeout(timeout, wait_and_drain).await {
700 Ok((Ok(status), stdout, stderr)) => {
701 group.disarm();
702 let exit_code = status.code().unwrap_or(-1);
703
704 if !status.success() {
705 return Err(Error::from_command_failure(
706 format!("{} {}", binary.display(), args.join(" ")),
707 exit_code,
708 stdout,
709 stderr,
710 working_dir.map(|p| p.to_path_buf()),
711 ));
712 }
713
714 Ok(CommandOutput {
715 stdout,
716 stderr,
717 exit_code,
718 success: true,
719 })
720 }
721 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
722 message: "failed to wait for claude process".to_string(),
723 source: e,
724 working_dir: working_dir.map(|p| p.to_path_buf()),
725 }),
726 Err(_) => {
727 kill_group_with_grace(&mut group, kill_grace).await;
731 let _ = child.kill().await;
732 let drain_budget = Duration::from_millis(200);
733 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
734 .await
735 .unwrap_or_default();
736 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
737 .await
738 .unwrap_or_default();
739 if !stdout_str.is_empty() || !stderr_str.is_empty() {
740 warn!(
741 stdout = %stdout_str,
742 stderr = %stderr_str,
743 "partial output from timed-out process",
744 );
745 }
746 Err(Error::Timeout {
747 timeout_seconds: timeout.as_secs(),
748 })
749 }
750 }
751}
752
753#[cfg(feature = "async")]
754async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
755 let command_args = full_command_args(claude, args);
756
757 let span = exec_span(claude, &command_args, "oneshot");
758 let _enter = span.enter();
759 let started = std::time::Instant::now();
760 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
761
762 let output = if let Some(timeout) = claude.timeout {
763 run_with_timeout(
764 &claude.binary,
765 &command_args,
766 &claude.env,
767 claude.clear_env,
768 claude.working_dir.as_deref(),
769 timeout,
770 SpawnPolicy::of(claude),
771 )
772 .await?
773 } else {
774 run_internal(
775 &claude.binary,
776 &command_args,
777 &claude.env,
778 claude.clear_env,
779 claude.working_dir.as_deref(),
780 SpawnPolicy::of(claude),
781 )
782 .await?
783 };
784
785 record_exec_outcome(&span, output.exit_code, started);
786 Ok(output)
787}
788
789#[cfg(feature = "async")]
793pub async fn run_claude_allow_exit_codes(
794 claude: &Claude,
795 args: Vec<String>,
796 allowed_codes: &[i32],
797) -> Result<CommandOutput> {
798 let output = run_claude(claude, args).await;
799
800 match output {
801 Err(Error::CommandFailed {
802 exit_code,
803 stdout,
804 stderr,
805 ..
806 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
807 stdout,
808 stderr,
809 exit_code,
810 success: false,
811 }),
812 other => other,
813 }
814}
815
816#[cfg(feature = "async")]
817async fn run_internal(
818 binary: &std::path::Path,
819 args: &[String],
820 env: &std::collections::HashMap<String, String>,
821 clear_env: bool,
822 working_dir: Option<&std::path::Path>,
823 policy: SpawnPolicy<'_>,
824) -> Result<CommandOutput> {
825 let SpawnPolicy {
826 process_group,
827 kill_grace: _, die_with_parent,
829 on_spawn,
830 } = policy;
831 let mut cmd = Command::new(binary);
832 cmd.args(args);
833
834 cmd.stdin(std::process::Stdio::null());
836 cmd.stdout(std::process::Stdio::piped());
837 cmd.stderr(std::process::Stdio::piped());
838
839 cmd.kill_on_drop(true);
842 apply_process_group(&mut cmd, process_group);
846 apply_die_with_parent(&mut cmd, die_with_parent);
847
848 apply_child_environment(cmd.as_std_mut(), clear_env, env);
849
850 if let Some(dir) = working_dir {
851 cmd.current_dir(dir);
852 }
853
854 let mut child = spawn_retrying_txtbsy(&mut cmd)
857 .await
858 .map_err(|e| Error::Io {
859 message: format!("failed to spawn claude: {e}"),
860 source: e,
861 working_dir: working_dir.map(|p| p.to_path_buf()),
862 })?;
863 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
864
865 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
866 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
867
868 let (status, stdout, stderr) = tokio::join!(
869 child.wait(),
870 drain(&mut stdout_handle),
871 drain(&mut stderr_handle),
872 );
873
874 let status = status.map_err(|e| Error::Io {
875 message: "failed to wait for claude process".to_string(),
876 source: e,
877 working_dir: working_dir.map(|p| p.to_path_buf()),
878 })?;
879 group.disarm();
880
881 let exit_code = status.code().unwrap_or(-1);
882
883 if !status.success() {
884 return Err(Error::from_command_failure(
885 format!("{} {}", binary.display(), args.join(" ")),
886 exit_code,
887 stdout,
888 stderr,
889 working_dir.map(|p| p.to_path_buf()),
890 ));
891 }
892
893 Ok(CommandOutput {
894 stdout,
895 stderr,
896 exit_code,
897 success: true,
898 })
899}
900
901#[cfg(feature = "async")]
914async fn run_with_timeout(
915 binary: &std::path::Path,
916 args: &[String],
917 env: &std::collections::HashMap<String, String>,
918 clear_env: bool,
919 working_dir: Option<&std::path::Path>,
920 timeout: Duration,
921 policy: SpawnPolicy<'_>,
922) -> Result<CommandOutput> {
923 let SpawnPolicy {
924 process_group,
925 kill_grace,
926 die_with_parent,
927 on_spawn,
928 } = policy;
929 let mut cmd = Command::new(binary);
930 cmd.args(args);
931 cmd.stdin(std::process::Stdio::null());
932 cmd.stdout(std::process::Stdio::piped());
933 cmd.stderr(std::process::Stdio::piped());
934 cmd.kill_on_drop(true);
937 apply_process_group(&mut cmd, process_group);
941 apply_die_with_parent(&mut cmd, die_with_parent);
942 apply_child_environment(cmd.as_std_mut(), clear_env, env);
943
944 if let Some(dir) = working_dir {
945 cmd.current_dir(dir);
946 }
947
948 let mut child = spawn_retrying_txtbsy(&mut cmd)
949 .await
950 .map_err(|e| Error::Io {
951 message: format!("failed to spawn claude: {e}"),
952 source: e,
953 working_dir: working_dir.map(|p| p.to_path_buf()),
954 })?;
955 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
956
957 let mut stdout = child.stdout.take().expect("stdout was piped");
958 let mut stderr = child.stderr.take().expect("stderr was piped");
959
960 let wait_and_drain = async {
965 let (status, stdout_str, stderr_str) =
966 tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
967 (status, stdout_str, stderr_str)
968 };
969
970 match tokio::time::timeout(timeout, wait_and_drain).await {
971 Ok((Ok(status), stdout, stderr)) => {
972 group.disarm();
973 let exit_code = status.code().unwrap_or(-1);
974
975 if !status.success() {
976 return Err(Error::from_command_failure(
977 format!("{} {}", binary.display(), args.join(" ")),
978 exit_code,
979 stdout,
980 stderr,
981 working_dir.map(|p| p.to_path_buf()),
982 ));
983 }
984
985 Ok(CommandOutput {
986 stdout,
987 stderr,
988 exit_code,
989 success: true,
990 })
991 }
992 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
993 message: "failed to wait for claude process".to_string(),
994 source: e,
995 working_dir: working_dir.map(|p| p.to_path_buf()),
996 }),
997 Err(_) => {
998 kill_group_with_grace(&mut group, kill_grace).await;
1004 let _ = child.kill().await;
1005 let drain_budget = Duration::from_millis(200);
1006 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
1007 .await
1008 .unwrap_or_default();
1009 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
1010 .await
1011 .unwrap_or_default();
1012 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1013 warn!(
1014 stdout = %stdout_str,
1015 stderr = %stderr_str,
1016 "partial output from timed-out process",
1017 );
1018 }
1019 Err(Error::Timeout {
1020 timeout_seconds: timeout.as_secs(),
1021 })
1022 }
1023 }
1024}
1025
1026#[cfg(feature = "async")]
1027async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
1028 let mut buf = Vec::new();
1029 let _ = reader.read_to_end(&mut buf).await;
1030 String::from_utf8_lossy(&buf).into_owned()
1031}
1032
1033#[cfg(any(feature = "async", feature = "sync"))]
1042const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
1043
1044#[cfg(any(feature = "async", feature = "sync"))]
1051const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
1052
1053#[cfg(feature = "async")]
1064async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
1065 let start = std::time::Instant::now();
1066 let mut backoff = Duration::from_millis(1);
1067 loop {
1068 match cmd.spawn() {
1069 Err(e)
1070 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1071 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1072 {
1073 tokio::time::sleep(backoff).await;
1074 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1075 }
1076 other => return other,
1077 }
1078 }
1079}
1080
1081#[cfg(feature = "sync")]
1085pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1086 run_claude_with_retry_sync(claude, args, None)
1087}
1088
1089#[cfg(feature = "sync")]
1091pub fn run_claude_with_retry_sync(
1092 claude: &Claude,
1093 args: Vec<String>,
1094 retry_override: Option<&crate::retry::RetryPolicy>,
1095) -> Result<CommandOutput> {
1096 let policy = retry_override.or(claude.retry_policy.as_ref());
1097
1098 match policy {
1099 Some(policy) => {
1100 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
1101 }
1102 None => run_claude_once_sync(claude, args),
1103 }
1104}
1105
1106#[cfg(feature = "sync")]
1111pub fn run_claude_with_stdin_prompt_sync(
1112 claude: &Claude,
1113 args: Vec<String>,
1114 stdin_content: String,
1115) -> Result<CommandOutput> {
1116 let command_args = full_command_args(claude, args);
1117
1118 let span = exec_span(claude, &command_args, "stdin-sync");
1119 let _enter = span.enter();
1120 let started = std::time::Instant::now();
1121 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
1122
1123 let result = if let Some(timeout) = claude.timeout {
1124 run_with_timeout_stdin_sync(
1125 &claude.binary,
1126 &command_args,
1127 &claude.env,
1128 claude.clear_env,
1129 claude.working_dir.as_deref(),
1130 timeout,
1131 stdin_content,
1132 SpawnPolicy::of(claude),
1133 )
1134 } else {
1135 run_internal_stdin_sync(
1136 &claude.binary,
1137 &command_args,
1138 &claude.env,
1139 claude.clear_env,
1140 claude.working_dir.as_deref(),
1141 stdin_content,
1142 SpawnPolicy::of(claude),
1143 )
1144 };
1145
1146 if let Ok(output) = &result {
1147 record_exec_outcome(&span, output.exit_code, started);
1148 }
1149 result
1150}
1151
1152#[cfg(feature = "sync")]
1153fn run_internal_stdin_sync(
1154 binary: &std::path::Path,
1155 args: &[String],
1156 env: &std::collections::HashMap<String, String>,
1157 clear_env: bool,
1158 working_dir: Option<&std::path::Path>,
1159 stdin_content: String,
1160 policy: SpawnPolicy<'_>,
1161) -> Result<CommandOutput> {
1162 let SpawnPolicy {
1163 process_group,
1164 kill_grace: _, die_with_parent,
1166 on_spawn,
1167 } = policy;
1168 use std::io::Write;
1169 use std::process::{Command as StdCommand, Stdio};
1170
1171 let mut cmd = StdCommand::new(binary);
1172 cmd.args(args);
1173 cmd.stdin(Stdio::piped());
1174 cmd.stdout(Stdio::piped());
1175 cmd.stderr(Stdio::piped());
1176 apply_process_group_sync(&mut cmd, process_group);
1180 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1181 apply_child_environment(&mut cmd, clear_env, env);
1182
1183 if let Some(dir) = working_dir {
1184 cmd.current_dir(dir);
1185 }
1186
1187 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1188 message: format!("failed to spawn claude: {e}"),
1189 source: e,
1190 working_dir: working_dir.map(|p| p.to_path_buf()),
1191 })?;
1192 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1193
1194 if let Some(mut stdin) = child.stdin.take() {
1196 stdin
1197 .write_all(stdin_content.as_bytes())
1198 .map_err(|e| Error::Io {
1199 message: format!("failed to write to claude stdin: {e}"),
1200 source: e,
1201 working_dir: working_dir.map(|p| p.to_path_buf()),
1202 })?;
1203 stdin.flush().map_err(|e| Error::Io {
1204 message: format!("failed to flush claude stdin: {e}"),
1205 source: e,
1206 working_dir: working_dir.map(|p| p.to_path_buf()),
1207 })?;
1208 }
1210
1211 let output = child.wait_with_output().map_err(|e| Error::Io {
1212 message: "failed to wait for claude process".to_string(),
1213 source: e,
1214 working_dir: working_dir.map(|p| p.to_path_buf()),
1215 })?;
1216 group.disarm();
1217
1218 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1219 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1220 let exit_code = output.status.code().unwrap_or(-1);
1221
1222 if !output.status.success() {
1223 return Err(Error::from_command_failure(
1224 format!("{} {}", binary.display(), args.join(" ")),
1225 exit_code,
1226 stdout,
1227 stderr,
1228 working_dir.map(|p| p.to_path_buf()),
1229 ));
1230 }
1231
1232 Ok(CommandOutput {
1233 stdout,
1234 stderr,
1235 exit_code,
1236 success: true,
1237 })
1238}
1239
1240#[cfg(feature = "sync")]
1241#[allow(clippy::too_many_arguments)]
1242fn run_with_timeout_stdin_sync(
1243 binary: &std::path::Path,
1244 args: &[String],
1245 env: &std::collections::HashMap<String, String>,
1246 clear_env: bool,
1247 working_dir: Option<&std::path::Path>,
1248 timeout: Duration,
1249 stdin_content: String,
1250 policy: SpawnPolicy<'_>,
1251) -> Result<CommandOutput> {
1252 let SpawnPolicy {
1253 process_group,
1254 kill_grace,
1255 die_with_parent,
1256 on_spawn,
1257 } = policy;
1258 use std::io::Write;
1259 use std::process::{Command as StdCommand, Stdio};
1260 use std::thread;
1261 use wait_timeout::ChildExt;
1262
1263 let mut cmd = StdCommand::new(binary);
1264 cmd.args(args);
1265 cmd.stdin(Stdio::piped());
1266 cmd.stdout(Stdio::piped());
1267 cmd.stderr(Stdio::piped());
1268 apply_process_group_sync(&mut cmd, process_group);
1272 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1273 apply_child_environment(&mut cmd, clear_env, env);
1274
1275 if let Some(dir) = working_dir {
1276 cmd.current_dir(dir);
1277 }
1278
1279 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1280 message: format!("failed to spawn claude: {e}"),
1281 source: e,
1282 working_dir: working_dir.map(|p| p.to_path_buf()),
1283 })?;
1284 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1285
1286 if let Some(mut stdin) = child.stdin.take() {
1288 stdin
1289 .write_all(stdin_content.as_bytes())
1290 .map_err(|e| Error::Io {
1291 message: format!("failed to write to claude stdin: {e}"),
1292 source: e,
1293 working_dir: working_dir.map(|p| p.to_path_buf()),
1294 })?;
1295 stdin.flush().map_err(|e| Error::Io {
1296 message: format!("failed to flush claude stdin: {e}"),
1297 source: e,
1298 working_dir: working_dir.map(|p| p.to_path_buf()),
1299 })?;
1300 }
1302
1303 let stdout = child.stdout.take().expect("stdout was piped");
1304 let stderr = child.stderr.take().expect("stderr was piped");
1305
1306 let stdout_thread = thread::spawn(move || drain_sync(stdout));
1307 let stderr_thread = thread::spawn(move || drain_sync(stderr));
1308
1309 match child.wait_timeout(timeout).map_err(|e| Error::Io {
1310 message: "failed to wait for claude process".to_string(),
1311 source: e,
1312 working_dir: working_dir.map(|p| p.to_path_buf()),
1313 })? {
1314 Some(status) => {
1315 group.disarm();
1316 let stdout = stdout_thread.join().unwrap_or_default();
1317 let stderr = stderr_thread.join().unwrap_or_default();
1318 let exit_code = status.code().unwrap_or(-1);
1319
1320 if !status.success() {
1321 return Err(Error::from_command_failure(
1322 format!("{} {}", binary.display(), args.join(" ")),
1323 exit_code,
1324 stdout,
1325 stderr,
1326 working_dir.map(|p| p.to_path_buf()),
1327 ));
1328 }
1329
1330 Ok(CommandOutput {
1331 stdout,
1332 stderr,
1333 exit_code,
1334 success: true,
1335 })
1336 }
1337 None => {
1338 kill_group_with_grace_sync(&mut group, kill_grace);
1342 let _ = child.kill();
1343 let _ = child.wait();
1344 let (stdout_str, stderr_str) =
1345 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1346 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1347 warn!(
1348 stdout = %stdout_str,
1349 stderr = %stderr_str,
1350 "partial output from timed-out process",
1351 );
1352 }
1353 Err(Error::Timeout {
1354 timeout_seconds: timeout.as_secs(),
1355 })
1356 }
1357 }
1358}
1359
1360#[cfg(feature = "sync")]
1361fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1362 let command_args = full_command_args(claude, args);
1363
1364 let span = exec_span(claude, &command_args, "oneshot-sync");
1365 let _enter = span.enter();
1366 let started = std::time::Instant::now();
1367 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
1368
1369 let result = if let Some(timeout) = claude.timeout {
1370 run_with_timeout_sync(
1371 &claude.binary,
1372 &command_args,
1373 &claude.env,
1374 claude.clear_env,
1375 claude.working_dir.as_deref(),
1376 timeout,
1377 SpawnPolicy::of(claude),
1378 )
1379 } else {
1380 run_internal_sync(
1381 &claude.binary,
1382 &command_args,
1383 &claude.env,
1384 claude.clear_env,
1385 claude.working_dir.as_deref(),
1386 SpawnPolicy::of(claude),
1387 )
1388 };
1389
1390 if let Ok(output) = &result {
1391 record_exec_outcome(&span, output.exit_code, started);
1392 }
1393 result
1394}
1395
1396#[cfg(feature = "sync")]
1398pub fn run_claude_allow_exit_codes_sync(
1399 claude: &Claude,
1400 args: Vec<String>,
1401 allowed_codes: &[i32],
1402) -> Result<CommandOutput> {
1403 match run_claude_sync(claude, args) {
1404 Err(Error::CommandFailed {
1405 exit_code,
1406 stdout,
1407 stderr,
1408 ..
1409 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
1410 stdout,
1411 stderr,
1412 exit_code,
1413 success: false,
1414 }),
1415 other => other,
1416 }
1417}
1418
1419#[cfg(feature = "sync")]
1420fn run_internal_sync(
1421 binary: &std::path::Path,
1422 args: &[String],
1423 env: &std::collections::HashMap<String, String>,
1424 clear_env: bool,
1425 working_dir: Option<&std::path::Path>,
1426 policy: SpawnPolicy<'_>,
1427) -> Result<CommandOutput> {
1428 let SpawnPolicy {
1429 process_group,
1430 kill_grace: _, die_with_parent,
1432 on_spawn,
1433 } = policy;
1434 use std::process::{Command as StdCommand, Stdio};
1435
1436 let mut cmd = StdCommand::new(binary);
1437 cmd.args(args);
1438 cmd.stdin(Stdio::null());
1439 apply_process_group_sync(&mut cmd, process_group);
1444 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1445 apply_child_environment(&mut cmd, clear_env, env);
1446
1447 if let Some(dir) = working_dir {
1448 cmd.current_dir(dir);
1449 }
1450
1451 let output =
1452 output_retrying_txtbsy_sync_observed(&mut cmd, process_group, on_spawn).map_err(|e| {
1453 Error::Io {
1454 message: format!("failed to spawn claude: {e}"),
1455 source: e,
1456 working_dir: working_dir.map(|p| p.to_path_buf()),
1457 }
1458 })?;
1459
1460 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1461 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1462 let exit_code = output.status.code().unwrap_or(-1);
1463
1464 if !output.status.success() {
1465 return Err(Error::from_command_failure(
1466 format!("{} {}", binary.display(), args.join(" ")),
1467 exit_code,
1468 stdout,
1469 stderr,
1470 working_dir.map(|p| p.to_path_buf()),
1471 ));
1472 }
1473
1474 Ok(CommandOutput {
1475 stdout,
1476 stderr,
1477 exit_code,
1478 success: true,
1479 })
1480}
1481
1482#[cfg(feature = "sync")]
1490fn run_with_timeout_sync(
1491 binary: &std::path::Path,
1492 args: &[String],
1493 env: &std::collections::HashMap<String, String>,
1494 clear_env: bool,
1495 working_dir: Option<&std::path::Path>,
1496 timeout: Duration,
1497 policy: SpawnPolicy<'_>,
1498) -> Result<CommandOutput> {
1499 let SpawnPolicy {
1500 process_group,
1501 kill_grace,
1502 die_with_parent,
1503 on_spawn,
1504 } = policy;
1505 use std::process::{Command as StdCommand, Stdio};
1506 use std::thread;
1507 use wait_timeout::ChildExt;
1508
1509 let mut cmd = StdCommand::new(binary);
1510 cmd.args(args);
1511 cmd.stdin(Stdio::null());
1512 cmd.stdout(Stdio::piped());
1513 cmd.stderr(Stdio::piped());
1514 apply_process_group_sync(&mut cmd, process_group);
1518 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1519 apply_child_environment(&mut cmd, clear_env, env);
1520
1521 if let Some(dir) = working_dir {
1522 cmd.current_dir(dir);
1523 }
1524
1525 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1526 message: format!("failed to spawn claude: {e}"),
1527 source: e,
1528 working_dir: working_dir.map(|p| p.to_path_buf()),
1529 })?;
1530 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1531
1532 let stdout = child.stdout.take().expect("stdout was piped");
1537 let stderr = child.stderr.take().expect("stderr was piped");
1538
1539 let stdout_thread = thread::spawn(move || drain_sync(stdout));
1540 let stderr_thread = thread::spawn(move || drain_sync(stderr));
1541
1542 match child.wait_timeout(timeout).map_err(|e| Error::Io {
1543 message: "failed to wait for claude process".to_string(),
1544 source: e,
1545 working_dir: working_dir.map(|p| p.to_path_buf()),
1546 })? {
1547 Some(status) => {
1548 group.disarm();
1549 let stdout = stdout_thread.join().unwrap_or_default();
1550 let stderr = stderr_thread.join().unwrap_or_default();
1551 let exit_code = status.code().unwrap_or(-1);
1552
1553 if !status.success() {
1554 return Err(Error::from_command_failure(
1555 format!("{} {}", binary.display(), args.join(" ")),
1556 exit_code,
1557 stdout,
1558 stderr,
1559 working_dir.map(|p| p.to_path_buf()),
1560 ));
1561 }
1562
1563 Ok(CommandOutput {
1564 stdout,
1565 stderr,
1566 exit_code,
1567 success: true,
1568 })
1569 }
1570 None => {
1571 kill_group_with_grace_sync(&mut group, kill_grace);
1578 let _ = child.kill();
1579 let _ = child.wait();
1580
1581 let (stdout_str, stderr_str) =
1582 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1583
1584 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1585 warn!(
1586 stdout = %stdout_str,
1587 stderr = %stderr_str,
1588 "partial output from timed-out process",
1589 );
1590 }
1591
1592 Err(Error::Timeout {
1593 timeout_seconds: timeout.as_secs(),
1594 })
1595 }
1596 }
1597}
1598
1599#[cfg(feature = "sync")]
1600fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
1601 let mut buf = Vec::new();
1602 let _ = reader.read_to_end(&mut buf);
1603 String::from_utf8_lossy(&buf).into_owned()
1604}
1605
1606#[cfg(feature = "sync")]
1609fn spawn_retrying_txtbsy_sync(
1610 cmd: &mut std::process::Command,
1611) -> std::io::Result<std::process::Child> {
1612 let start = std::time::Instant::now();
1613 let mut backoff = Duration::from_millis(1);
1614 loop {
1615 match cmd.spawn() {
1616 Err(e)
1617 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1618 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1619 {
1620 std::thread::sleep(backoff);
1621 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1622 }
1623 other => return other,
1624 }
1625 }
1626}
1627
1628#[cfg(feature = "sync")]
1636#[cfg(feature = "sync")]
1642fn output_retrying_txtbsy_sync_observed(
1643 cmd: &mut std::process::Command,
1644 process_group: bool,
1645 on_spawn: Option<&crate::SpawnObserver>,
1646) -> std::io::Result<std::process::Output> {
1647 cmd.stdout(std::process::Stdio::piped());
1651 cmd.stderr(std::process::Stdio::piped());
1652
1653 let start = std::time::Instant::now();
1654 let mut backoff = Duration::from_millis(1);
1655 loop {
1656 let spawned = cmd.spawn().inspect(|child| {
1657 if let Some(observer) = on_spawn {
1658 let pid = child.id();
1659 observer(crate::SpawnInfo {
1660 pid,
1661 pgid: process_group.then_some(pid),
1662 });
1663 }
1664 });
1665 match spawned.and_then(std::process::Child::wait_with_output) {
1666 Err(e)
1667 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1668 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1669 {
1670 std::thread::sleep(backoff);
1671 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1672 }
1673 other => return other,
1674 }
1675 }
1676}
1677
1678#[cfg(feature = "sync")]
1684fn join_with_deadline(
1685 stdout_thread: std::thread::JoinHandle<String>,
1686 stderr_thread: std::thread::JoinHandle<String>,
1687 budget: Duration,
1688) -> (String, String) {
1689 use std::sync::mpsc;
1690 use std::thread;
1691
1692 let (tx, rx) = mpsc::channel::<(&'static str, String)>();
1693
1694 let tx_out = tx.clone();
1695 let tx_err = tx;
1696
1697 thread::spawn(move || {
1698 let s = stdout_thread.join().unwrap_or_default();
1699 let _ = tx_out.send(("stdout", s));
1700 });
1701 thread::spawn(move || {
1702 let s = stderr_thread.join().unwrap_or_default();
1703 let _ = tx_err.send(("stderr", s));
1704 });
1705
1706 let mut stdout = String::new();
1707 let mut stderr = String::new();
1708 let deadline = std::time::Instant::now() + budget;
1709
1710 for _ in 0..2 {
1711 let now = std::time::Instant::now();
1712 if now >= deadline {
1713 break;
1714 }
1715 match rx.recv_timeout(deadline - now) {
1716 Ok(("stdout", s)) => stdout = s,
1717 Ok(("stderr", s)) => stderr = s,
1718 Ok(_) => unreachable!(),
1719 Err(_) => break,
1720 }
1721 }
1722
1723 (stdout, stderr)
1724}
1725
1726#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1733mod tests {
1734 use super::*;
1735 use std::io::Write;
1736 use std::os::unix::fs::PermissionsExt;
1737
1738 use crate::Claude;
1739
1740 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1744 let dir = tempfile::tempdir().expect("tempdir");
1745 let path = dir.path().join("fake-claude.sh");
1746 {
1751 let mut f = std::fs::File::create(&path).expect("create script");
1752 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1753 f.sync_all().expect("sync script");
1754 }
1755 let perms = std::fs::Permissions::from_mode(0o755);
1756 std::fs::set_permissions(&path, perms).expect("chmod");
1757 (dir, path)
1758 }
1759
1760 fn client(path: &std::path::Path) -> Claude {
1761 Claude::builder()
1762 .binary(path)
1763 .build()
1764 .expect("build client")
1765 }
1766
1767 #[test]
1768 fn full_command_args_puts_global_args_first() {
1769 let claude = Claude::builder()
1770 .binary("/usr/local/bin/claude")
1771 .arg("--debug")
1772 .arg("--verbose")
1773 .build()
1774 .expect("build client");
1775 let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
1776 assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
1777 }
1778
1779 #[test]
1780 fn full_command_args_without_global_args_is_passthrough() {
1781 let claude = Claude::builder()
1782 .binary("/usr/local/bin/claude")
1783 .build()
1784 .expect("build client");
1785 let args = full_command_args(&claude, vec!["--print".to_string()]);
1786 assert_eq!(args, ["--print"]);
1787 }
1788
1789 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1791
1792 fn set_scrub_vars() {
1793 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1794 unsafe {
1797 std::env::set_var("CLAUDECODE", "1");
1798 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1799 }
1800 }
1801
1802 fn clear_scrub_vars() {
1803 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1804 unsafe {
1806 std::env::remove_var("CLAUDECODE");
1807 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
1808 }
1809 }
1810
1811 #[cfg(feature = "async")]
1814 #[tokio::test]
1815 async fn async_success_maps_output() {
1816 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
1817 let out = run_claude(&client(&path), vec!["--version".into()])
1818 .await
1819 .expect("success");
1820 assert!(out.success);
1821 assert_eq!(out.exit_code, 0);
1822 assert!(out.stdout.contains("hi there"));
1823 }
1824
1825 #[cfg(feature = "async")]
1826 #[tokio::test]
1827 async fn async_nonzero_exit_maps_command_failed() {
1828 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1829 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1830 match err {
1831 Error::CommandFailed {
1832 exit_code, stderr, ..
1833 } => {
1834 assert_eq!(exit_code, 3);
1835 assert!(stderr.contains("boom"));
1836 }
1837 other => panic!("expected CommandFailed, got {other:?}"),
1838 }
1839 }
1840
1841 #[cfg(feature = "async")]
1842 #[tokio::test]
1843 async fn async_rail_stop_maps_max_turns() {
1844 let (_dir, path) = fake_script(
1845 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
1846 );
1847 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1848 assert!(
1849 matches!(
1850 err,
1851 Error::MaxTurnsExceeded {
1852 max_turns: Some(2),
1853 ..
1854 }
1855 ),
1856 "got: {err:?}"
1857 );
1858 }
1859
1860 #[cfg(feature = "async")]
1861 #[tokio::test]
1862 async fn async_auth_shaped_stderr_maps_auth() {
1863 let (_dir, path) =
1864 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
1865 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1866 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
1867 }
1868
1869 #[cfg(feature = "async")]
1870 #[tokio::test]
1871 async fn async_scrubs_claude_env_vars() {
1872 let (_dir, path) =
1873 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1874 set_scrub_vars();
1880 let out = run_claude(&client(&path), vec![]).await.expect("success");
1881 clear_scrub_vars();
1882 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1883 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1884 }
1885
1886 #[cfg(feature = "async")]
1887 #[tokio::test]
1888 async fn async_applies_working_dir() {
1889 let (_dir, path) = fake_script(r#"pwd"#);
1890 let workdir = tempfile::tempdir().expect("workdir");
1891 let claude = Claude::builder()
1892 .binary(&path)
1893 .working_dir(workdir.path())
1894 .build()
1895 .expect("build");
1896 let out = run_claude(&claude, vec![]).await.expect("success");
1897 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
1898 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
1899 assert_eq!(got, want);
1900 }
1901
1902 #[cfg(feature = "async")]
1903 #[tokio::test]
1904 async fn async_stdin_prompt_round_trips() {
1905 let (_dir, path) = fake_script(r#"cat"#);
1906 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
1907 .await
1908 .expect("success");
1909 assert!(out.stdout.contains("hello via stdin"));
1910 }
1911
1912 #[cfg(feature = "async")]
1917 #[tokio::test]
1918 async fn async_spawn_retry_passes_through_non_txtbsy_error() {
1919 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
1920 let err = spawn_retrying_txtbsy(&mut cmd)
1921 .await
1922 .expect_err("spawn of missing binary should fail");
1923 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1924 }
1925
1926 #[cfg(feature = "async")]
1927 #[tokio::test]
1928 async fn async_allow_exit_codes_permits_listed_code() {
1929 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1930 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
1931 .await
1932 .expect("allowed code is Ok");
1933 assert!(!out.success);
1934 assert_eq!(out.exit_code, 2);
1935 assert!(out.stdout.contains("out"));
1936 }
1937
1938 #[cfg(feature = "async")]
1939 #[tokio::test]
1940 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
1941 let (_dir, path) = fake_script(r#"exit 2"#);
1942 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
1943 .await
1944 .unwrap_err();
1945 assert!(
1946 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
1947 "got: {err:?}"
1948 );
1949 }
1950
1951 #[cfg(feature = "async")]
1952 #[tokio::test]
1953 async fn async_timeout_fires_on_slow_child() {
1954 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1955 let claude = Claude::builder()
1956 .binary(&path)
1957 .timeout(Duration::from_millis(300))
1958 .build()
1959 .expect("build");
1960 let err = run_claude(&claude, vec![]).await.unwrap_err();
1961 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1962 }
1963
1964 #[cfg(feature = "async")]
1965 #[tokio::test]
1966 async fn async_timeout_path_returns_output_when_fast() {
1967 let (_dir, path) = fake_script(r#"echo quick"#);
1968 let claude = Claude::builder()
1969 .binary(&path)
1970 .timeout(Duration::from_secs(30))
1971 .build()
1972 .expect("build");
1973 let out = run_claude(&claude, vec![]).await.expect("success");
1974 assert!(out.stdout.contains("quick"));
1975 }
1976
1977 #[cfg(feature = "async")]
1978 #[tokio::test]
1979 async fn async_timeout_path_maps_command_failed() {
1980 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
1981 let claude = Claude::builder()
1982 .binary(&path)
1983 .timeout(Duration::from_secs(30))
1984 .build()
1985 .expect("build");
1986 let err = run_claude(&claude, vec![]).await.unwrap_err();
1987 assert!(
1988 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
1989 "got: {err:?}"
1990 );
1991 }
1992
1993 #[cfg(feature = "async")]
1994 #[tokio::test]
1995 async fn async_stdin_with_timeout_round_trips() {
1996 let (_dir, path) = fake_script(r#"cat"#);
1997 let claude = Claude::builder()
1998 .binary(&path)
1999 .timeout(Duration::from_secs(30))
2000 .build()
2001 .expect("build");
2002 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
2003 .await
2004 .expect("success");
2005 assert!(out.stdout.contains("piped under timeout"));
2006 }
2007
2008 #[cfg(feature = "async")]
2009 #[tokio::test]
2010 async fn async_stdin_timeout_fires_on_slow_child() {
2011 let (_dir, path) = fake_script(r#"sleep 3"#);
2012 let claude = Claude::builder()
2013 .binary(&path)
2014 .timeout(Duration::from_millis(300))
2015 .build()
2016 .expect("build");
2017 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
2018 .await
2019 .unwrap_err();
2020 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2021 }
2022
2023 #[cfg(feature = "async")]
2026 async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
2027 where
2028 F: std::future::Future,
2029 F::Output: std::fmt::Debug,
2030 {
2031 tokio::pin!(fut);
2032 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2033 loop {
2034 if let Some(pid) = std::fs::read_to_string(pid_path)
2035 .ok()
2036 .and_then(|s| s.trim().parse().ok())
2037 {
2038 return pid;
2040 }
2041 assert!(
2042 std::time::Instant::now() < deadline,
2043 "child never wrote its pid file"
2044 );
2045 tokio::select! {
2046 out = &mut fut => panic!("future completed before drop: {out:?}"),
2047 _ = tokio::time::sleep(Duration::from_millis(10)) => {}
2048 }
2049 }
2050 }
2051
2052 fn assert_pid_killed(pid: u32) {
2058 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2059 loop {
2060 let out = std::process::Command::new("ps")
2061 .args(["-o", "stat=", "-p", &pid.to_string()])
2062 .output()
2063 .expect("run ps");
2064 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2065 if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
2066 return;
2067 }
2068 assert!(
2069 std::time::Instant::now() < deadline,
2070 "process {pid} still alive (stat {stat}) after kill"
2071 );
2072 std::thread::sleep(Duration::from_millis(25));
2073 }
2074 }
2075
2076 fn group_script(
2084 pid_path: &std::path::Path,
2085 gpid_path: &std::path::Path,
2086 ) -> (tempfile::TempDir, std::path::PathBuf) {
2087 fake_script(&format!(
2092 concat!(
2093 "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2094 "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2095 "echo $$ > \"{p}\"\n",
2096 "exec sleep 300",
2097 ),
2098 g = gpid_path.display(),
2099 p = pid_path.display(),
2100 ))
2101 }
2102
2103 fn try_read_pid(path: &std::path::Path) -> Option<u32> {
2105 std::fs::read_to_string(path).ok()?.trim().parse().ok()
2106 }
2107
2108 #[cfg(feature = "async")]
2112 fn read_pid(path: &std::path::Path) -> u32 {
2113 try_read_pid(path).expect("pid file readable")
2114 }
2115
2116 #[cfg(feature = "async")]
2122 #[tokio::test]
2123 async fn async_dropping_in_flight_future_kills_child() {
2124 let workdir = tempfile::tempdir().expect("workdir");
2125 let pid_path = workdir.path().join("pid");
2126 let (_dir, path) = fake_script(&format!(
2127 r#"echo $$ > "{}"; exec sleep 30"#,
2128 pid_path.display()
2129 ));
2130 let claude = client(&path);
2131 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2132 assert_pid_killed(pid);
2133 }
2134
2135 #[cfg(feature = "async")]
2140 #[tokio::test]
2141 async fn async_dropping_in_flight_future_kills_child_with_timeout() {
2142 let workdir = tempfile::tempdir().expect("workdir");
2143 let pid_path = workdir.path().join("pid");
2144 let (_dir, path) = fake_script(&format!(
2145 r#"echo $$ > "{}"; exec sleep 30"#,
2146 pid_path.display()
2147 ));
2148 let claude = Claude::builder()
2149 .binary(&path)
2150 .timeout(Duration::from_secs(120))
2151 .build()
2152 .expect("build");
2153 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2154 assert_pid_killed(pid);
2155 }
2156
2157 #[cfg(feature = "async")]
2161 #[tokio::test]
2162 async fn async_dropping_in_flight_future_kills_process_group() {
2163 let workdir = tempfile::tempdir().expect("workdir");
2164 let pid_path = workdir.path().join("pid");
2165 let gpid_path = workdir.path().join("gpid");
2166 let (_dir, path) = group_script(&pid_path, &gpid_path);
2167 let claude = client(&path);
2168 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2169 assert_pid_killed(pid);
2170 assert_pid_killed(read_pid(&gpid_path));
2171 }
2172
2173 #[cfg(feature = "async")]
2179 #[tokio::test]
2180 async fn async_timeout_kills_process_group() {
2181 let mut observed = false;
2182 for _ in 0..5 {
2183 let workdir = tempfile::tempdir().expect("workdir");
2184 let pid_path = workdir.path().join("pid");
2185 let gpid_path = workdir.path().join("gpid");
2186 let (_dir, path) = group_script(&pid_path, &gpid_path);
2187 let claude = Claude::builder()
2188 .binary(&path)
2189 .timeout(Duration::from_millis(1000))
2190 .build()
2191 .expect("build");
2192 let err = run_claude(&claude, vec![]).await.unwrap_err();
2193 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2194 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2195 assert_pid_killed(pid);
2196 assert_pid_killed(gpid);
2197 observed = true;
2198 break;
2199 }
2200 }
2201 assert!(observed, "child never recorded pids within 5 timeout runs");
2202 }
2203
2204 #[cfg(feature = "async")]
2210 #[tokio::test]
2211 async fn async_process_group_opt_out_kills_only_direct_child() {
2212 let workdir = tempfile::tempdir().expect("workdir");
2213 let pid_path = workdir.path().join("pid");
2214 let gpid_path = workdir.path().join("gpid");
2215 let (_dir, path) = group_script(&pid_path, &gpid_path);
2216 let claude = Claude::builder()
2217 .binary(&path)
2218 .process_group(false)
2219 .build()
2220 .expect("build");
2221 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2222 assert_pid_killed(pid);
2223
2224 let gpid = read_pid(&gpid_path);
2226 let out = std::process::Command::new("ps")
2227 .args(["-o", "stat=", "-p", &gpid.to_string()])
2228 .output()
2229 .expect("run ps");
2230 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2231 assert!(
2232 out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
2233 "grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
2234 );
2235
2236 let _ = std::process::Command::new("kill")
2238 .args(["-9", &gpid.to_string()])
2239 .status();
2240 }
2241
2242 fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
2248 fake_script(&format!(
2249 concat!(
2250 "trap 'echo term > \"{m}\"; exit 0' TERM\n",
2251 "sleep 300 &\n",
2252 "wait $!",
2253 ),
2254 m = marker.display(),
2255 ))
2256 }
2257
2258 #[cfg(feature = "async")]
2264 #[tokio::test]
2265 async fn async_timeout_with_grace_delivers_sigterm_first() {
2266 let mut observed = false;
2267 for _ in 0..5 {
2268 let workdir = tempfile::tempdir().expect("workdir");
2269 let marker = workdir.path().join("term-marker");
2270 let (_dir, path) = term_trap_script(&marker);
2271 let claude = Claude::builder()
2272 .binary(&path)
2273 .timeout(Duration::from_millis(500))
2274 .kill_grace(Duration::from_secs(1))
2275 .build()
2276 .expect("build");
2277 let err = run_claude(&claude, vec![]).await.unwrap_err();
2278 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2279 if marker.exists() {
2280 observed = true;
2281 break;
2282 }
2283 }
2284 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2285 }
2286
2287 #[cfg(feature = "sync")]
2289 #[test]
2290 fn sync_timeout_with_grace_delivers_sigterm_first() {
2291 let mut observed = false;
2292 for _ in 0..5 {
2293 let workdir = tempfile::tempdir().expect("workdir");
2294 let marker = workdir.path().join("term-marker");
2295 let (_dir, path) = term_trap_script(&marker);
2296 let claude = Claude::builder()
2297 .binary(&path)
2298 .timeout(Duration::from_millis(500))
2299 .kill_grace(Duration::from_secs(1))
2300 .build()
2301 .expect("build");
2302 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2303 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2304 if marker.exists() {
2305 observed = true;
2306 break;
2307 }
2308 }
2309 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2310 }
2311
2312 #[cfg(feature = "async")]
2314 #[tokio::test]
2315 async fn async_dropping_in_flight_stdin_future_kills_child() {
2316 let workdir = tempfile::tempdir().expect("workdir");
2317 let pid_path = workdir.path().join("pid");
2318 let (_dir, path) = fake_script(&format!(
2319 r#"echo $$ > "{}"; exec sleep 30"#,
2320 pid_path.display()
2321 ));
2322 let claude = client(&path);
2323 let pid = drop_in_flight_and_capture_pid(
2324 run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
2325 &pid_path,
2326 )
2327 .await;
2328 assert_pid_killed(pid);
2329 }
2330
2331 #[cfg(feature = "async")]
2332 #[tokio::test]
2333 async fn async_spawn_failure_maps_io() {
2334 let claude = Claude::builder()
2335 .binary("/nonexistent/definitely/not/here")
2336 .build()
2337 .expect("build");
2338 let err = run_claude(&claude, vec![]).await.unwrap_err();
2339 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
2340 }
2341
2342 #[cfg(feature = "sync")]
2345 #[test]
2346 fn sync_success_maps_output() {
2347 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
2348 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2349 assert!(out.success);
2350 assert!(out.stdout.contains("hi sync"));
2351 }
2352
2353 #[cfg(feature = "sync")]
2354 #[test]
2355 fn sync_nonzero_exit_maps_command_failed() {
2356 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2357 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
2358 match err {
2359 Error::CommandFailed {
2360 exit_code, stderr, ..
2361 } => {
2362 assert_eq!(exit_code, 3);
2363 assert!(stderr.contains("boom"));
2364 }
2365 other => panic!("expected CommandFailed, got {other:?}"),
2366 }
2367 }
2368
2369 #[cfg(feature = "sync")]
2370 #[test]
2371 fn sync_scrubs_claude_env_vars() {
2372 let (_dir, path) =
2373 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2374 set_scrub_vars();
2375 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2376 clear_scrub_vars();
2377 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2378 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2379 }
2380
2381 #[cfg(feature = "sync")]
2382 #[test]
2383 fn sync_stdin_prompt_round_trips() {
2384 let (_dir, path) = fake_script(r#"cat"#);
2385 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
2386 .expect("success");
2387 assert!(out.stdout.contains("sync stdin"));
2388 }
2389
2390 #[cfg(feature = "sync")]
2393 #[test]
2394 fn sync_spawn_retry_passes_through_non_txtbsy_error() {
2395 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2396 let err =
2397 spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
2398 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2399 }
2400
2401 #[cfg(feature = "sync")]
2402 #[test]
2403 fn sync_output_retry_passes_through_non_txtbsy_error() {
2404 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2405 let err = output_retrying_txtbsy_sync_observed(&mut cmd, false, None)
2406 .expect_err("output of missing binary should fail");
2407 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2408 }
2409
2410 #[cfg(feature = "sync")]
2411 #[test]
2412 fn sync_allow_exit_codes_permits_listed_code() {
2413 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2414 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
2415 .expect("allowed code is Ok");
2416 assert!(!out.success);
2417 assert_eq!(out.exit_code, 2);
2418 }
2419
2420 #[cfg(feature = "sync")]
2421 #[test]
2422 fn sync_timeout_fires_on_slow_child() {
2423 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2424 let claude = Claude::builder()
2425 .binary(&path)
2426 .timeout(Duration::from_millis(300))
2427 .build()
2428 .expect("build");
2429 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2430 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2431 }
2432
2433 #[cfg(feature = "sync")]
2437 #[test]
2438 fn sync_timeout_kills_process_group() {
2439 let mut observed = false;
2440 for _ in 0..5 {
2441 let workdir = tempfile::tempdir().expect("workdir");
2442 let pid_path = workdir.path().join("pid");
2443 let gpid_path = workdir.path().join("gpid");
2444 let (_dir, path) = group_script(&pid_path, &gpid_path);
2445 let claude = Claude::builder()
2446 .binary(&path)
2447 .timeout(Duration::from_millis(1000))
2448 .build()
2449 .expect("build");
2450 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2451 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2452 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2453 assert_pid_killed(pid);
2454 assert_pid_killed(gpid);
2455 observed = true;
2456 break;
2457 }
2458 }
2459 assert!(observed, "child never recorded pids within 5 timeout runs");
2460 }
2461
2462 #[cfg(feature = "sync")]
2463 #[test]
2464 fn sync_timeout_path_returns_output_when_fast() {
2465 let (_dir, path) = fake_script(r#"echo quick"#);
2466 let claude = Claude::builder()
2467 .binary(&path)
2468 .timeout(Duration::from_secs(30))
2469 .build()
2470 .expect("build");
2471 let out = run_claude_sync(&claude, vec![]).expect("success");
2472 assert!(out.stdout.contains("quick"));
2473 }
2474
2475 #[cfg(feature = "sync")]
2476 #[test]
2477 fn sync_stdin_with_timeout_round_trips() {
2478 let (_dir, path) = fake_script(r#"cat"#);
2479 let claude = Claude::builder()
2480 .binary(&path)
2481 .timeout(Duration::from_secs(30))
2482 .build()
2483 .expect("build");
2484 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
2485 .expect("success");
2486 assert!(out.stdout.contains("sync piped"));
2487 }
2488}