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"))]
67pub(crate) fn span_command(args: &[String]) -> &str {
68 args.first().map(String::as_str).unwrap_or("<none>")
69}
70
71#[cfg(any(feature = "async", feature = "sync"))]
77pub(crate) fn exec_span(claude: &Claude, args: &[String], mode: &'static str) -> tracing::Span {
78 tracing::debug_span!(
79 "claude.exec",
80 command = span_command(args),
81 mode,
82 binary = %claude.binary.display(),
83 cwd = claude.working_dir.as_deref().map(|d| d.display().to_string()),
84 exit_code = tracing::field::Empty,
85 duration_ms = tracing::field::Empty,
86 )
87}
88
89#[cfg(any(feature = "async", feature = "sync"))]
91pub(crate) fn record_exec_outcome(
92 span: &tracing::Span,
93 exit_code: i32,
94 started: std::time::Instant,
95) {
96 span.record("exit_code", exit_code);
97 span.record("duration_ms", started.elapsed().as_millis() as u64);
98}
99
100#[derive(Debug, Clone)]
102pub struct CommandOutput {
103 pub stdout: String,
105 pub stderr: String,
107 pub exit_code: i32,
109 pub success: bool,
111}
112
113#[cfg(any(feature = "async", feature = "sync"))]
142#[derive(Clone, Copy)]
143pub(crate) struct SpawnPolicy<'a> {
144 pub(crate) process_group: bool,
145 pub(crate) kill_grace: Option<Duration>,
146 pub(crate) die_with_parent: bool,
147 pub(crate) on_spawn: Option<&'a crate::SpawnObserver>,
148}
149
150#[cfg(any(feature = "async", feature = "sync"))]
151impl SpawnPolicy<'_> {
152 pub(crate) fn of(claude: &Claude) -> SpawnPolicy<'_> {
154 SpawnPolicy {
155 process_group: claude.process_group,
156 kill_grace: claude.kill_grace,
157 die_with_parent: claude.die_with_parent,
158 on_spawn: claude.on_spawn.as_ref(),
159 }
160 }
161}
162
163#[cfg(any(feature = "async", feature = "sync"))]
164pub(crate) fn arm_and_notify(
165 process_group: bool,
166 pid: Option<u32>,
167 on_spawn: Option<&crate::SpawnObserver>,
168) -> GroupKillGuard {
169 if let (Some(pid), Some(observer)) = (pid, on_spawn) {
170 observer(crate::SpawnInfo {
171 pid,
172 pgid: process_group.then_some(pid),
173 });
174 }
175 GroupKillGuard::new_if(process_group, pid)
176}
177
178#[cfg(any(feature = "async", feature = "sync"))]
179pub(crate) struct GroupKillGuard {
180 #[cfg(unix)]
181 pgid: Option<i32>,
182}
183
184#[cfg(any(feature = "async", feature = "sync"))]
185impl GroupKillGuard {
186 pub(crate) fn new_if(enabled: bool, pid: Option<u32>) -> Self {
191 Self::new(if enabled { pid } else { None })
192 }
193
194 pub(crate) fn new(pid: Option<u32>) -> Self {
198 #[cfg(unix)]
199 {
200 Self {
201 pgid: pid.and_then(|p| i32::try_from(p).ok()),
202 }
203 }
204 #[cfg(not(unix))]
205 {
206 let _ = pid;
207 Self {}
208 }
209 }
210
211 pub(crate) fn disarm(&mut self) {
214 #[cfg(unix)]
215 {
216 self.pgid = None;
217 }
218 }
219
220 pub(crate) fn is_armed(&self) -> bool {
222 #[cfg(unix)]
223 {
224 self.pgid.is_some()
225 }
226 #[cfg(not(unix))]
227 {
228 false
229 }
230 }
231
232 pub(crate) fn term_now(&self) {
236 #[cfg(unix)]
237 if let Some(pgid) = self.pgid {
238 let _ = unsafe { libc::killpg(pgid, libc::SIGTERM) };
241 }
242 }
243
244 pub(crate) fn kill_now(&mut self) {
246 #[cfg(unix)]
247 if let Some(pgid) = self.pgid.take() {
248 let _ = unsafe { libc::killpg(pgid, libc::SIGKILL) };
251 }
252 }
253}
254
255#[cfg(any(feature = "async", feature = "sync"))]
256impl Drop for GroupKillGuard {
257 fn drop(&mut self) {
258 self.kill_now();
259 }
260}
261
262#[must_use]
271pub const fn die_with_parent_supported() -> bool {
272 cfg!(target_os = "linux")
273}
274
275#[cfg(all(unix, any(feature = "async", feature = "sync")))]
291fn pdeathsig_hook() -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static {
292 let parent = std::process::id();
295 move || {
296 #[cfg(target_os = "linux")]
297 {
298 unsafe {
300 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
301 return Err(std::io::Error::last_os_error());
302 }
303 if libc::getppid() as u32 != parent {
305 libc::_exit(1);
306 }
307 }
308 }
309 #[cfg(not(target_os = "linux"))]
310 {
311 let _ = parent;
312 }
313 Ok(())
314 }
315}
316
317#[cfg(feature = "async")]
320pub(crate) fn apply_die_with_parent(cmd: &mut Command, enabled: bool) {
321 #[cfg(unix)]
322 if enabled {
323 unsafe {
325 cmd.pre_exec(pdeathsig_hook());
326 }
327 }
328 #[cfg(not(unix))]
329 {
330 let _ = (cmd, enabled);
331 }
332}
333
334#[cfg(feature = "sync")]
336pub(crate) fn apply_die_with_parent_sync(cmd: &mut std::process::Command, enabled: bool) {
337 #[cfg(unix)]
338 if enabled {
339 use std::os::unix::process::CommandExt;
340 unsafe {
342 cmd.pre_exec(pdeathsig_hook());
343 }
344 }
345 #[cfg(not(unix))]
346 {
347 let _ = (cmd, enabled);
348 }
349}
350
351#[cfg(feature = "async")]
355pub(crate) fn apply_process_group(cmd: &mut Command, enabled: bool) {
356 #[cfg(unix)]
357 if enabled {
358 cmd.process_group(0);
359 }
360 #[cfg(not(unix))]
361 {
362 let _ = (cmd, enabled);
363 }
364}
365
366#[cfg(feature = "sync")]
368pub(crate) fn apply_process_group_sync(cmd: &mut std::process::Command, enabled: bool) {
369 #[cfg(unix)]
370 if enabled {
371 use std::os::unix::process::CommandExt;
372 cmd.process_group(0);
373 }
374 #[cfg(not(unix))]
375 {
376 let _ = (cmd, enabled);
377 }
378}
379
380#[cfg(feature = "async")]
388pub(crate) async fn kill_group_with_grace(group: &mut GroupKillGuard, grace: Option<Duration>) {
389 if let Some(g) = grace
390 && !g.is_zero()
391 && group.is_armed()
392 {
393 group.term_now();
394 tokio::time::sleep(g).await;
395 }
396 group.kill_now();
397}
398
399#[cfg(feature = "sync")]
401pub(crate) fn kill_group_with_grace_sync(group: &mut GroupKillGuard, grace: Option<Duration>) {
402 if let Some(g) = grace
403 && !g.is_zero()
404 && group.is_armed()
405 {
406 group.term_now();
407 std::thread::sleep(g);
408 }
409 group.kill_now();
410}
411
412#[cfg(feature = "async")]
423pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
424 run_claude_with_retry(claude, args, None).await
425}
426
427#[cfg(feature = "async")]
431pub async fn run_claude_with_retry(
432 claude: &Claude,
433 args: Vec<String>,
434 retry_override: Option<&crate::retry::RetryPolicy>,
435) -> Result<CommandOutput> {
436 let policy = retry_override.or(claude.retry_policy.as_ref());
437
438 match policy {
439 Some(policy) => {
440 crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
441 }
442 None => run_claude_once(claude, args).await,
443 }
444}
445
446#[cfg(feature = "async")]
454pub async fn run_claude_with_stdin_prompt(
455 claude: &Claude,
456 args: Vec<String>,
457 stdin_content: String,
458) -> Result<CommandOutput> {
459 run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
460}
461
462#[cfg(feature = "async")]
463async fn run_claude_with_stdin_prompt_internal(
464 claude: &Claude,
465 args: Vec<String>,
466 stdin_content: String,
467) -> Result<CommandOutput> {
468 let command_args = full_command_args(claude, args);
469
470 let span = exec_span(claude, &command_args, "stdin");
471 let _enter = span.enter();
472 let started = std::time::Instant::now();
473 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
474
475 let binary = &claude.binary;
476 let env = &claude.env;
477 let working_dir = claude.working_dir.as_deref();
478
479 let result = if let Some(timeout) = claude.timeout {
480 run_with_timeout_stdin(
481 binary,
482 &command_args,
483 env,
484 working_dir,
485 timeout,
486 stdin_content,
487 SpawnPolicy::of(claude),
488 )
489 .await
490 } else {
491 run_internal_stdin(
492 binary,
493 &command_args,
494 env,
495 working_dir,
496 stdin_content,
497 SpawnPolicy::of(claude),
498 )
499 .await
500 };
501
502 if let Ok(output) = &result {
503 record_exec_outcome(&span, output.exit_code, started);
504 }
505 result
506}
507
508#[cfg(feature = "async")]
509async fn run_internal_stdin(
510 binary: &std::path::Path,
511 args: &[String],
512 env: &std::collections::HashMap<String, String>,
513 working_dir: Option<&std::path::Path>,
514 stdin_content: String,
515 policy: SpawnPolicy<'_>,
516) -> Result<CommandOutput> {
517 let SpawnPolicy {
518 process_group,
519 kill_grace: _, die_with_parent,
521 on_spawn,
522 } = policy;
523 use tokio::io::AsyncWriteExt;
524
525 let mut cmd = Command::new(binary);
526 cmd.args(args);
527 cmd.stdin(std::process::Stdio::piped());
528 cmd.stdout(std::process::Stdio::piped());
529 cmd.stderr(std::process::Stdio::piped());
530 cmd.kill_on_drop(true);
533 apply_process_group(&mut cmd, process_group);
537 apply_die_with_parent(&mut cmd, die_with_parent);
538 cmd.env_remove("CLAUDECODE");
539 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
540
541 if let Some(dir) = working_dir {
542 cmd.current_dir(dir);
543 }
544
545 for (key, value) in env {
546 cmd.env(key, value);
547 }
548
549 let mut child = spawn_retrying_txtbsy(&mut cmd)
550 .await
551 .map_err(|e| Error::Io {
552 message: format!("failed to spawn claude: {e}"),
553 source: e,
554 working_dir: working_dir.map(|p| p.to_path_buf()),
555 })?;
556 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
557
558 if let Some(mut stdin) = child.stdin.take() {
560 stdin
561 .write_all(stdin_content.as_bytes())
562 .await
563 .map_err(|e| Error::Io {
564 message: format!("failed to write to claude stdin: {e}"),
565 source: e,
566 working_dir: working_dir.map(|p| p.to_path_buf()),
567 })?;
568 }
570
571 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
572 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
573
574 let (status, stdout_str, stderr_str) = tokio::join!(
575 child.wait(),
576 drain(&mut stdout_handle),
577 drain(&mut stderr_handle),
578 );
579
580 let status = status.map_err(|e| Error::Io {
581 message: "failed to wait for claude process".to_string(),
582 source: e,
583 working_dir: working_dir.map(|p| p.to_path_buf()),
584 })?;
585 group.disarm();
586
587 let exit_code = status.code().unwrap_or(-1);
588
589 if !status.success() {
590 return Err(Error::from_command_failure(
591 format!("{} {}", binary.display(), args.join(" ")),
592 exit_code,
593 stdout_str,
594 stderr_str,
595 working_dir.map(|p| p.to_path_buf()),
596 ));
597 }
598
599 Ok(CommandOutput {
600 stdout: stdout_str,
601 stderr: stderr_str,
602 exit_code,
603 success: true,
604 })
605}
606
607#[cfg(feature = "async")]
608#[allow(clippy::too_many_arguments)]
609async fn run_with_timeout_stdin(
610 binary: &std::path::Path,
611 args: &[String],
612 env: &std::collections::HashMap<String, String>,
613 working_dir: Option<&std::path::Path>,
614 timeout: Duration,
615 stdin_content: String,
616 policy: SpawnPolicy<'_>,
617) -> Result<CommandOutput> {
618 let SpawnPolicy {
619 process_group,
620 kill_grace,
621 die_with_parent,
622 on_spawn,
623 } = policy;
624 use tokio::io::AsyncWriteExt;
625
626 let mut cmd = Command::new(binary);
627 cmd.args(args);
628 cmd.stdin(std::process::Stdio::piped());
629 cmd.stdout(std::process::Stdio::piped());
630 cmd.stderr(std::process::Stdio::piped());
631 cmd.kill_on_drop(true);
634 apply_process_group(&mut cmd, process_group);
638 apply_die_with_parent(&mut cmd, die_with_parent);
639 cmd.env_remove("CLAUDECODE");
640 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
641
642 if let Some(dir) = working_dir {
643 cmd.current_dir(dir);
644 }
645
646 for (key, value) in env {
647 cmd.env(key, value);
648 }
649
650 let mut child = spawn_retrying_txtbsy(&mut cmd)
651 .await
652 .map_err(|e| Error::Io {
653 message: format!("failed to spawn claude: {e}"),
654 source: e,
655 working_dir: working_dir.map(|p| p.to_path_buf()),
656 })?;
657 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
658
659 if let Some(mut stdin) = child.stdin.take() {
661 stdin
662 .write_all(stdin_content.as_bytes())
663 .await
664 .map_err(|e| Error::Io {
665 message: format!("failed to write to claude stdin: {e}"),
666 source: e,
667 working_dir: working_dir.map(|p| p.to_path_buf()),
668 })?;
669 }
671
672 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
673 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
674
675 let wait_and_drain = async {
676 let (status, stdout_str, stderr_str) = tokio::join!(
677 child.wait(),
678 drain(&mut stdout_handle),
679 drain(&mut stderr_handle),
680 );
681 (status, stdout_str, stderr_str)
682 };
683
684 match tokio::time::timeout(timeout, wait_and_drain).await {
685 Ok((Ok(status), stdout, stderr)) => {
686 group.disarm();
687 let exit_code = status.code().unwrap_or(-1);
688
689 if !status.success() {
690 return Err(Error::from_command_failure(
691 format!("{} {}", binary.display(), args.join(" ")),
692 exit_code,
693 stdout,
694 stderr,
695 working_dir.map(|p| p.to_path_buf()),
696 ));
697 }
698
699 Ok(CommandOutput {
700 stdout,
701 stderr,
702 exit_code,
703 success: true,
704 })
705 }
706 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
707 message: "failed to wait for claude process".to_string(),
708 source: e,
709 working_dir: working_dir.map(|p| p.to_path_buf()),
710 }),
711 Err(_) => {
712 kill_group_with_grace(&mut group, kill_grace).await;
716 let _ = child.kill().await;
717 let drain_budget = Duration::from_millis(200);
718 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
719 .await
720 .unwrap_or_default();
721 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
722 .await
723 .unwrap_or_default();
724 if !stdout_str.is_empty() || !stderr_str.is_empty() {
725 warn!(
726 stdout = %stdout_str,
727 stderr = %stderr_str,
728 "partial output from timed-out process",
729 );
730 }
731 Err(Error::Timeout {
732 timeout_seconds: timeout.as_secs(),
733 })
734 }
735 }
736}
737
738#[cfg(feature = "async")]
739async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
740 let command_args = full_command_args(claude, args);
741
742 let span = exec_span(claude, &command_args, "oneshot");
743 let _enter = span.enter();
744 let started = std::time::Instant::now();
745 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
746
747 let output = if let Some(timeout) = claude.timeout {
748 run_with_timeout(
749 &claude.binary,
750 &command_args,
751 &claude.env,
752 claude.working_dir.as_deref(),
753 timeout,
754 SpawnPolicy::of(claude),
755 )
756 .await?
757 } else {
758 run_internal(
759 &claude.binary,
760 &command_args,
761 &claude.env,
762 claude.working_dir.as_deref(),
763 SpawnPolicy::of(claude),
764 )
765 .await?
766 };
767
768 record_exec_outcome(&span, output.exit_code, started);
769 Ok(output)
770}
771
772#[cfg(feature = "async")]
776pub async fn run_claude_allow_exit_codes(
777 claude: &Claude,
778 args: Vec<String>,
779 allowed_codes: &[i32],
780) -> Result<CommandOutput> {
781 let output = run_claude(claude, args).await;
782
783 match output {
784 Err(Error::CommandFailed {
785 exit_code,
786 stdout,
787 stderr,
788 ..
789 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
790 stdout,
791 stderr,
792 exit_code,
793 success: false,
794 }),
795 other => other,
796 }
797}
798
799#[cfg(feature = "async")]
800async fn run_internal(
801 binary: &std::path::Path,
802 args: &[String],
803 env: &std::collections::HashMap<String, String>,
804 working_dir: Option<&std::path::Path>,
805 policy: SpawnPolicy<'_>,
806) -> Result<CommandOutput> {
807 let SpawnPolicy {
808 process_group,
809 kill_grace: _, die_with_parent,
811 on_spawn,
812 } = policy;
813 let mut cmd = Command::new(binary);
814 cmd.args(args);
815
816 cmd.stdin(std::process::Stdio::null());
818 cmd.stdout(std::process::Stdio::piped());
819 cmd.stderr(std::process::Stdio::piped());
820
821 cmd.kill_on_drop(true);
824 apply_process_group(&mut cmd, process_group);
828 apply_die_with_parent(&mut cmd, die_with_parent);
829
830 cmd.env_remove("CLAUDECODE");
832 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
833
834 if let Some(dir) = working_dir {
835 cmd.current_dir(dir);
836 }
837
838 for (key, value) in env {
839 cmd.env(key, value);
840 }
841
842 let mut child = spawn_retrying_txtbsy(&mut cmd)
845 .await
846 .map_err(|e| Error::Io {
847 message: format!("failed to spawn claude: {e}"),
848 source: e,
849 working_dir: working_dir.map(|p| p.to_path_buf()),
850 })?;
851 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
852
853 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
854 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
855
856 let (status, stdout, stderr) = tokio::join!(
857 child.wait(),
858 drain(&mut stdout_handle),
859 drain(&mut stderr_handle),
860 );
861
862 let status = status.map_err(|e| Error::Io {
863 message: "failed to wait for claude process".to_string(),
864 source: e,
865 working_dir: working_dir.map(|p| p.to_path_buf()),
866 })?;
867 group.disarm();
868
869 let exit_code = status.code().unwrap_or(-1);
870
871 if !status.success() {
872 return Err(Error::from_command_failure(
873 format!("{} {}", binary.display(), args.join(" ")),
874 exit_code,
875 stdout,
876 stderr,
877 working_dir.map(|p| p.to_path_buf()),
878 ));
879 }
880
881 Ok(CommandOutput {
882 stdout,
883 stderr,
884 exit_code,
885 success: true,
886 })
887}
888
889#[cfg(feature = "async")]
902async fn run_with_timeout(
903 binary: &std::path::Path,
904 args: &[String],
905 env: &std::collections::HashMap<String, String>,
906 working_dir: Option<&std::path::Path>,
907 timeout: Duration,
908 policy: SpawnPolicy<'_>,
909) -> Result<CommandOutput> {
910 let SpawnPolicy {
911 process_group,
912 kill_grace,
913 die_with_parent,
914 on_spawn,
915 } = policy;
916 let mut cmd = Command::new(binary);
917 cmd.args(args);
918 cmd.stdin(std::process::Stdio::null());
919 cmd.stdout(std::process::Stdio::piped());
920 cmd.stderr(std::process::Stdio::piped());
921 cmd.kill_on_drop(true);
924 apply_process_group(&mut cmd, process_group);
928 apply_die_with_parent(&mut cmd, die_with_parent);
929 cmd.env_remove("CLAUDECODE");
930 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
931
932 if let Some(dir) = working_dir {
933 cmd.current_dir(dir);
934 }
935
936 for (key, value) in env {
937 cmd.env(key, value);
938 }
939
940 let mut child = spawn_retrying_txtbsy(&mut cmd)
941 .await
942 .map_err(|e| Error::Io {
943 message: format!("failed to spawn claude: {e}"),
944 source: e,
945 working_dir: working_dir.map(|p| p.to_path_buf()),
946 })?;
947 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
948
949 let mut stdout = child.stdout.take().expect("stdout was piped");
950 let mut stderr = child.stderr.take().expect("stderr was piped");
951
952 let wait_and_drain = async {
957 let (status, stdout_str, stderr_str) =
958 tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
959 (status, stdout_str, stderr_str)
960 };
961
962 match tokio::time::timeout(timeout, wait_and_drain).await {
963 Ok((Ok(status), stdout, stderr)) => {
964 group.disarm();
965 let exit_code = status.code().unwrap_or(-1);
966
967 if !status.success() {
968 return Err(Error::from_command_failure(
969 format!("{} {}", binary.display(), args.join(" ")),
970 exit_code,
971 stdout,
972 stderr,
973 working_dir.map(|p| p.to_path_buf()),
974 ));
975 }
976
977 Ok(CommandOutput {
978 stdout,
979 stderr,
980 exit_code,
981 success: true,
982 })
983 }
984 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
985 message: "failed to wait for claude process".to_string(),
986 source: e,
987 working_dir: working_dir.map(|p| p.to_path_buf()),
988 }),
989 Err(_) => {
990 kill_group_with_grace(&mut group, kill_grace).await;
996 let _ = child.kill().await;
997 let drain_budget = Duration::from_millis(200);
998 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
999 .await
1000 .unwrap_or_default();
1001 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
1002 .await
1003 .unwrap_or_default();
1004 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1005 warn!(
1006 stdout = %stdout_str,
1007 stderr = %stderr_str,
1008 "partial output from timed-out process",
1009 );
1010 }
1011 Err(Error::Timeout {
1012 timeout_seconds: timeout.as_secs(),
1013 })
1014 }
1015 }
1016}
1017
1018#[cfg(feature = "async")]
1019async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
1020 let mut buf = Vec::new();
1021 let _ = reader.read_to_end(&mut buf).await;
1022 String::from_utf8_lossy(&buf).into_owned()
1023}
1024
1025#[cfg(any(feature = "async", feature = "sync"))]
1034const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
1035
1036#[cfg(any(feature = "async", feature = "sync"))]
1043const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
1044
1045#[cfg(feature = "async")]
1056async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
1057 let start = std::time::Instant::now();
1058 let mut backoff = Duration::from_millis(1);
1059 loop {
1060 match cmd.spawn() {
1061 Err(e)
1062 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1063 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1064 {
1065 tokio::time::sleep(backoff).await;
1066 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1067 }
1068 other => return other,
1069 }
1070 }
1071}
1072
1073#[cfg(feature = "sync")]
1077pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1078 run_claude_with_retry_sync(claude, args, None)
1079}
1080
1081#[cfg(feature = "sync")]
1083pub fn run_claude_with_retry_sync(
1084 claude: &Claude,
1085 args: Vec<String>,
1086 retry_override: Option<&crate::retry::RetryPolicy>,
1087) -> Result<CommandOutput> {
1088 let policy = retry_override.or(claude.retry_policy.as_ref());
1089
1090 match policy {
1091 Some(policy) => {
1092 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
1093 }
1094 None => run_claude_once_sync(claude, args),
1095 }
1096}
1097
1098#[cfg(feature = "sync")]
1103pub fn run_claude_with_stdin_prompt_sync(
1104 claude: &Claude,
1105 args: Vec<String>,
1106 stdin_content: String,
1107) -> Result<CommandOutput> {
1108 let command_args = full_command_args(claude, args);
1109
1110 let span = exec_span(claude, &command_args, "stdin-sync");
1111 let _enter = span.enter();
1112 let started = std::time::Instant::now();
1113 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
1114
1115 let result = if let Some(timeout) = claude.timeout {
1116 run_with_timeout_stdin_sync(
1117 &claude.binary,
1118 &command_args,
1119 &claude.env,
1120 claude.working_dir.as_deref(),
1121 timeout,
1122 stdin_content,
1123 SpawnPolicy::of(claude),
1124 )
1125 } else {
1126 run_internal_stdin_sync(
1127 &claude.binary,
1128 &command_args,
1129 &claude.env,
1130 claude.working_dir.as_deref(),
1131 stdin_content,
1132 SpawnPolicy::of(claude),
1133 )
1134 };
1135
1136 if let Ok(output) = &result {
1137 record_exec_outcome(&span, output.exit_code, started);
1138 }
1139 result
1140}
1141
1142#[cfg(feature = "sync")]
1143fn run_internal_stdin_sync(
1144 binary: &std::path::Path,
1145 args: &[String],
1146 env: &std::collections::HashMap<String, String>,
1147 working_dir: Option<&std::path::Path>,
1148 stdin_content: String,
1149 policy: SpawnPolicy<'_>,
1150) -> Result<CommandOutput> {
1151 let SpawnPolicy {
1152 process_group,
1153 kill_grace: _, die_with_parent,
1155 on_spawn,
1156 } = policy;
1157 use std::io::Write;
1158 use std::process::{Command as StdCommand, Stdio};
1159
1160 let mut cmd = StdCommand::new(binary);
1161 cmd.args(args);
1162 cmd.stdin(Stdio::piped());
1163 cmd.stdout(Stdio::piped());
1164 cmd.stderr(Stdio::piped());
1165 apply_process_group_sync(&mut cmd, process_group);
1169 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1170 cmd.env_remove("CLAUDECODE");
1171 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
1172
1173 if let Some(dir) = working_dir {
1174 cmd.current_dir(dir);
1175 }
1176
1177 for (key, value) in env {
1178 cmd.env(key, value);
1179 }
1180
1181 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1182 message: format!("failed to spawn claude: {e}"),
1183 source: e,
1184 working_dir: working_dir.map(|p| p.to_path_buf()),
1185 })?;
1186 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1187
1188 if let Some(mut stdin) = child.stdin.take() {
1190 stdin
1191 .write_all(stdin_content.as_bytes())
1192 .map_err(|e| Error::Io {
1193 message: format!("failed to write to claude stdin: {e}"),
1194 source: e,
1195 working_dir: working_dir.map(|p| p.to_path_buf()),
1196 })?;
1197 stdin.flush().map_err(|e| Error::Io {
1198 message: format!("failed to flush claude stdin: {e}"),
1199 source: e,
1200 working_dir: working_dir.map(|p| p.to_path_buf()),
1201 })?;
1202 }
1204
1205 let output = child.wait_with_output().map_err(|e| Error::Io {
1206 message: "failed to wait for claude process".to_string(),
1207 source: e,
1208 working_dir: working_dir.map(|p| p.to_path_buf()),
1209 })?;
1210 group.disarm();
1211
1212 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1213 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1214 let exit_code = output.status.code().unwrap_or(-1);
1215
1216 if !output.status.success() {
1217 return Err(Error::from_command_failure(
1218 format!("{} {}", binary.display(), args.join(" ")),
1219 exit_code,
1220 stdout,
1221 stderr,
1222 working_dir.map(|p| p.to_path_buf()),
1223 ));
1224 }
1225
1226 Ok(CommandOutput {
1227 stdout,
1228 stderr,
1229 exit_code,
1230 success: true,
1231 })
1232}
1233
1234#[cfg(feature = "sync")]
1235#[allow(clippy::too_many_arguments)]
1236fn run_with_timeout_stdin_sync(
1237 binary: &std::path::Path,
1238 args: &[String],
1239 env: &std::collections::HashMap<String, String>,
1240 working_dir: Option<&std::path::Path>,
1241 timeout: Duration,
1242 stdin_content: String,
1243 policy: SpawnPolicy<'_>,
1244) -> Result<CommandOutput> {
1245 let SpawnPolicy {
1246 process_group,
1247 kill_grace,
1248 die_with_parent,
1249 on_spawn,
1250 } = policy;
1251 use std::io::Write;
1252 use std::process::{Command as StdCommand, Stdio};
1253 use std::thread;
1254 use wait_timeout::ChildExt;
1255
1256 let mut cmd = StdCommand::new(binary);
1257 cmd.args(args);
1258 cmd.stdin(Stdio::piped());
1259 cmd.stdout(Stdio::piped());
1260 cmd.stderr(Stdio::piped());
1261 apply_process_group_sync(&mut cmd, process_group);
1265 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1266 cmd.env_remove("CLAUDECODE");
1267 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
1268
1269 if let Some(dir) = working_dir {
1270 cmd.current_dir(dir);
1271 }
1272
1273 for (key, value) in env {
1274 cmd.env(key, value);
1275 }
1276
1277 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1278 message: format!("failed to spawn claude: {e}"),
1279 source: e,
1280 working_dir: working_dir.map(|p| p.to_path_buf()),
1281 })?;
1282 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1283
1284 if let Some(mut stdin) = child.stdin.take() {
1286 stdin
1287 .write_all(stdin_content.as_bytes())
1288 .map_err(|e| Error::Io {
1289 message: format!("failed to write to claude stdin: {e}"),
1290 source: e,
1291 working_dir: working_dir.map(|p| p.to_path_buf()),
1292 })?;
1293 stdin.flush().map_err(|e| Error::Io {
1294 message: format!("failed to flush claude stdin: {e}"),
1295 source: e,
1296 working_dir: working_dir.map(|p| p.to_path_buf()),
1297 })?;
1298 }
1300
1301 let stdout = child.stdout.take().expect("stdout was piped");
1302 let stderr = child.stderr.take().expect("stderr was piped");
1303
1304 let stdout_thread = thread::spawn(move || drain_sync(stdout));
1305 let stderr_thread = thread::spawn(move || drain_sync(stderr));
1306
1307 match child.wait_timeout(timeout).map_err(|e| Error::Io {
1308 message: "failed to wait for claude process".to_string(),
1309 source: e,
1310 working_dir: working_dir.map(|p| p.to_path_buf()),
1311 })? {
1312 Some(status) => {
1313 group.disarm();
1314 let stdout = stdout_thread.join().unwrap_or_default();
1315 let stderr = stderr_thread.join().unwrap_or_default();
1316 let exit_code = status.code().unwrap_or(-1);
1317
1318 if !status.success() {
1319 return Err(Error::from_command_failure(
1320 format!("{} {}", binary.display(), args.join(" ")),
1321 exit_code,
1322 stdout,
1323 stderr,
1324 working_dir.map(|p| p.to_path_buf()),
1325 ));
1326 }
1327
1328 Ok(CommandOutput {
1329 stdout,
1330 stderr,
1331 exit_code,
1332 success: true,
1333 })
1334 }
1335 None => {
1336 kill_group_with_grace_sync(&mut group, kill_grace);
1340 let _ = child.kill();
1341 let _ = child.wait();
1342 let (stdout_str, stderr_str) =
1343 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1344 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1345 warn!(
1346 stdout = %stdout_str,
1347 stderr = %stderr_str,
1348 "partial output from timed-out process",
1349 );
1350 }
1351 Err(Error::Timeout {
1352 timeout_seconds: timeout.as_secs(),
1353 })
1354 }
1355 }
1356}
1357
1358#[cfg(feature = "sync")]
1359fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1360 let command_args = full_command_args(claude, args);
1361
1362 let span = exec_span(claude, &command_args, "oneshot-sync");
1363 let _enter = span.enter();
1364 let started = std::time::Instant::now();
1365 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
1366
1367 let result = if let Some(timeout) = claude.timeout {
1368 run_with_timeout_sync(
1369 &claude.binary,
1370 &command_args,
1371 &claude.env,
1372 claude.working_dir.as_deref(),
1373 timeout,
1374 SpawnPolicy::of(claude),
1375 )
1376 } else {
1377 run_internal_sync(
1378 &claude.binary,
1379 &command_args,
1380 &claude.env,
1381 claude.working_dir.as_deref(),
1382 SpawnPolicy::of(claude),
1383 )
1384 };
1385
1386 if let Ok(output) = &result {
1387 record_exec_outcome(&span, output.exit_code, started);
1388 }
1389 result
1390}
1391
1392#[cfg(feature = "sync")]
1394pub fn run_claude_allow_exit_codes_sync(
1395 claude: &Claude,
1396 args: Vec<String>,
1397 allowed_codes: &[i32],
1398) -> Result<CommandOutput> {
1399 match run_claude_sync(claude, args) {
1400 Err(Error::CommandFailed {
1401 exit_code,
1402 stdout,
1403 stderr,
1404 ..
1405 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
1406 stdout,
1407 stderr,
1408 exit_code,
1409 success: false,
1410 }),
1411 other => other,
1412 }
1413}
1414
1415#[cfg(feature = "sync")]
1416fn run_internal_sync(
1417 binary: &std::path::Path,
1418 args: &[String],
1419 env: &std::collections::HashMap<String, String>,
1420 working_dir: Option<&std::path::Path>,
1421 policy: SpawnPolicy<'_>,
1422) -> Result<CommandOutput> {
1423 let SpawnPolicy {
1424 process_group,
1425 kill_grace: _, die_with_parent,
1427 on_spawn,
1428 } = policy;
1429 use std::process::{Command as StdCommand, Stdio};
1430
1431 let mut cmd = StdCommand::new(binary);
1432 cmd.args(args);
1433 cmd.stdin(Stdio::null());
1434 apply_process_group_sync(&mut cmd, process_group);
1439 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1440 cmd.env_remove("CLAUDECODE");
1441 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
1442
1443 if let Some(dir) = working_dir {
1444 cmd.current_dir(dir);
1445 }
1446
1447 for (key, value) in env {
1448 cmd.env(key, value);
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 working_dir: Option<&std::path::Path>,
1495 timeout: Duration,
1496 policy: SpawnPolicy<'_>,
1497) -> Result<CommandOutput> {
1498 let SpawnPolicy {
1499 process_group,
1500 kill_grace,
1501 die_with_parent,
1502 on_spawn,
1503 } = policy;
1504 use std::process::{Command as StdCommand, Stdio};
1505 use std::thread;
1506 use wait_timeout::ChildExt;
1507
1508 let mut cmd = StdCommand::new(binary);
1509 cmd.args(args);
1510 cmd.stdin(Stdio::null());
1511 cmd.stdout(Stdio::piped());
1512 cmd.stderr(Stdio::piped());
1513 apply_process_group_sync(&mut cmd, process_group);
1517 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1518 cmd.env_remove("CLAUDECODE");
1519 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
1520
1521 if let Some(dir) = working_dir {
1522 cmd.current_dir(dir);
1523 }
1524
1525 for (key, value) in env {
1526 cmd.env(key, value);
1527 }
1528
1529 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1530 message: format!("failed to spawn claude: {e}"),
1531 source: e,
1532 working_dir: working_dir.map(|p| p.to_path_buf()),
1533 })?;
1534 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1535
1536 let stdout = child.stdout.take().expect("stdout was piped");
1541 let stderr = child.stderr.take().expect("stderr was piped");
1542
1543 let stdout_thread = thread::spawn(move || drain_sync(stdout));
1544 let stderr_thread = thread::spawn(move || drain_sync(stderr));
1545
1546 match child.wait_timeout(timeout).map_err(|e| Error::Io {
1547 message: "failed to wait for claude process".to_string(),
1548 source: e,
1549 working_dir: working_dir.map(|p| p.to_path_buf()),
1550 })? {
1551 Some(status) => {
1552 group.disarm();
1553 let stdout = stdout_thread.join().unwrap_or_default();
1554 let stderr = stderr_thread.join().unwrap_or_default();
1555 let exit_code = status.code().unwrap_or(-1);
1556
1557 if !status.success() {
1558 return Err(Error::from_command_failure(
1559 format!("{} {}", binary.display(), args.join(" ")),
1560 exit_code,
1561 stdout,
1562 stderr,
1563 working_dir.map(|p| p.to_path_buf()),
1564 ));
1565 }
1566
1567 Ok(CommandOutput {
1568 stdout,
1569 stderr,
1570 exit_code,
1571 success: true,
1572 })
1573 }
1574 None => {
1575 kill_group_with_grace_sync(&mut group, kill_grace);
1582 let _ = child.kill();
1583 let _ = child.wait();
1584
1585 let (stdout_str, stderr_str) =
1586 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1587
1588 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1589 warn!(
1590 stdout = %stdout_str,
1591 stderr = %stderr_str,
1592 "partial output from timed-out process",
1593 );
1594 }
1595
1596 Err(Error::Timeout {
1597 timeout_seconds: timeout.as_secs(),
1598 })
1599 }
1600 }
1601}
1602
1603#[cfg(feature = "sync")]
1604fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
1605 let mut buf = Vec::new();
1606 let _ = reader.read_to_end(&mut buf);
1607 String::from_utf8_lossy(&buf).into_owned()
1608}
1609
1610#[cfg(feature = "sync")]
1613fn spawn_retrying_txtbsy_sync(
1614 cmd: &mut std::process::Command,
1615) -> std::io::Result<std::process::Child> {
1616 let start = std::time::Instant::now();
1617 let mut backoff = Duration::from_millis(1);
1618 loop {
1619 match cmd.spawn() {
1620 Err(e)
1621 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1622 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1623 {
1624 std::thread::sleep(backoff);
1625 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1626 }
1627 other => return other,
1628 }
1629 }
1630}
1631
1632#[cfg(feature = "sync")]
1640#[cfg(feature = "sync")]
1646fn output_retrying_txtbsy_sync_observed(
1647 cmd: &mut std::process::Command,
1648 process_group: bool,
1649 on_spawn: Option<&crate::SpawnObserver>,
1650) -> std::io::Result<std::process::Output> {
1651 cmd.stdout(std::process::Stdio::piped());
1655 cmd.stderr(std::process::Stdio::piped());
1656
1657 let start = std::time::Instant::now();
1658 let mut backoff = Duration::from_millis(1);
1659 loop {
1660 let spawned = cmd.spawn().inspect(|child| {
1661 if let Some(observer) = on_spawn {
1662 let pid = child.id();
1663 observer(crate::SpawnInfo {
1664 pid,
1665 pgid: process_group.then_some(pid),
1666 });
1667 }
1668 });
1669 match spawned.and_then(std::process::Child::wait_with_output) {
1670 Err(e)
1671 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1672 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1673 {
1674 std::thread::sleep(backoff);
1675 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1676 }
1677 other => return other,
1678 }
1679 }
1680}
1681
1682#[cfg(feature = "sync")]
1688fn join_with_deadline(
1689 stdout_thread: std::thread::JoinHandle<String>,
1690 stderr_thread: std::thread::JoinHandle<String>,
1691 budget: Duration,
1692) -> (String, String) {
1693 use std::sync::mpsc;
1694 use std::thread;
1695
1696 let (tx, rx) = mpsc::channel::<(&'static str, String)>();
1697
1698 let tx_out = tx.clone();
1699 let tx_err = tx;
1700
1701 thread::spawn(move || {
1702 let s = stdout_thread.join().unwrap_or_default();
1703 let _ = tx_out.send(("stdout", s));
1704 });
1705 thread::spawn(move || {
1706 let s = stderr_thread.join().unwrap_or_default();
1707 let _ = tx_err.send(("stderr", s));
1708 });
1709
1710 let mut stdout = String::new();
1711 let mut stderr = String::new();
1712 let deadline = std::time::Instant::now() + budget;
1713
1714 for _ in 0..2 {
1715 let now = std::time::Instant::now();
1716 if now >= deadline {
1717 break;
1718 }
1719 match rx.recv_timeout(deadline - now) {
1720 Ok(("stdout", s)) => stdout = s,
1721 Ok(("stderr", s)) => stderr = s,
1722 Ok(_) => unreachable!(),
1723 Err(_) => break,
1724 }
1725 }
1726
1727 (stdout, stderr)
1728}
1729
1730#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1737mod tests {
1738 use super::*;
1739 use std::io::Write;
1740 use std::os::unix::fs::PermissionsExt;
1741
1742 use crate::Claude;
1743
1744 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1748 let dir = tempfile::tempdir().expect("tempdir");
1749 let path = dir.path().join("fake-claude.sh");
1750 {
1755 let mut f = std::fs::File::create(&path).expect("create script");
1756 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1757 f.sync_all().expect("sync script");
1758 }
1759 let perms = std::fs::Permissions::from_mode(0o755);
1760 std::fs::set_permissions(&path, perms).expect("chmod");
1761 (dir, path)
1762 }
1763
1764 fn client(path: &std::path::Path) -> Claude {
1765 Claude::builder()
1766 .binary(path)
1767 .build()
1768 .expect("build client")
1769 }
1770
1771 #[test]
1772 fn full_command_args_puts_global_args_first() {
1773 let claude = Claude::builder()
1774 .binary("/usr/local/bin/claude")
1775 .arg("--debug")
1776 .arg("--verbose")
1777 .build()
1778 .expect("build client");
1779 let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
1780 assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
1781 }
1782
1783 #[test]
1784 fn full_command_args_without_global_args_is_passthrough() {
1785 let claude = Claude::builder()
1786 .binary("/usr/local/bin/claude")
1787 .build()
1788 .expect("build client");
1789 let args = full_command_args(&claude, vec!["--print".to_string()]);
1790 assert_eq!(args, ["--print"]);
1791 }
1792
1793 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1795
1796 fn set_scrub_vars() {
1797 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1798 unsafe {
1801 std::env::set_var("CLAUDECODE", "1");
1802 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1803 }
1804 }
1805
1806 fn clear_scrub_vars() {
1807 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1808 unsafe {
1810 std::env::remove_var("CLAUDECODE");
1811 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
1812 }
1813 }
1814
1815 #[cfg(feature = "async")]
1818 #[tokio::test]
1819 async fn async_success_maps_output() {
1820 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
1821 let out = run_claude(&client(&path), vec!["--version".into()])
1822 .await
1823 .expect("success");
1824 assert!(out.success);
1825 assert_eq!(out.exit_code, 0);
1826 assert!(out.stdout.contains("hi there"));
1827 }
1828
1829 #[cfg(feature = "async")]
1830 #[tokio::test]
1831 async fn async_nonzero_exit_maps_command_failed() {
1832 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1833 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1834 match err {
1835 Error::CommandFailed {
1836 exit_code, stderr, ..
1837 } => {
1838 assert_eq!(exit_code, 3);
1839 assert!(stderr.contains("boom"));
1840 }
1841 other => panic!("expected CommandFailed, got {other:?}"),
1842 }
1843 }
1844
1845 #[cfg(feature = "async")]
1846 #[tokio::test]
1847 async fn async_rail_stop_maps_max_turns() {
1848 let (_dir, path) = fake_script(
1849 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
1850 );
1851 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1852 assert!(
1853 matches!(
1854 err,
1855 Error::MaxTurnsExceeded {
1856 max_turns: Some(2),
1857 ..
1858 }
1859 ),
1860 "got: {err:?}"
1861 );
1862 }
1863
1864 #[cfg(feature = "async")]
1865 #[tokio::test]
1866 async fn async_auth_shaped_stderr_maps_auth() {
1867 let (_dir, path) =
1868 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
1869 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1870 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
1871 }
1872
1873 #[cfg(feature = "async")]
1874 #[tokio::test]
1875 async fn async_scrubs_claude_env_vars() {
1876 let (_dir, path) =
1877 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1878 set_scrub_vars();
1884 let out = run_claude(&client(&path), vec![]).await.expect("success");
1885 clear_scrub_vars();
1886 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1887 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1888 }
1889
1890 #[cfg(feature = "async")]
1891 #[tokio::test]
1892 async fn async_applies_working_dir() {
1893 let (_dir, path) = fake_script(r#"pwd"#);
1894 let workdir = tempfile::tempdir().expect("workdir");
1895 let claude = Claude::builder()
1896 .binary(&path)
1897 .working_dir(workdir.path())
1898 .build()
1899 .expect("build");
1900 let out = run_claude(&claude, vec![]).await.expect("success");
1901 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
1902 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
1903 assert_eq!(got, want);
1904 }
1905
1906 #[cfg(feature = "async")]
1907 #[tokio::test]
1908 async fn async_stdin_prompt_round_trips() {
1909 let (_dir, path) = fake_script(r#"cat"#);
1910 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
1911 .await
1912 .expect("success");
1913 assert!(out.stdout.contains("hello via stdin"));
1914 }
1915
1916 #[cfg(feature = "async")]
1921 #[tokio::test]
1922 async fn async_spawn_retry_passes_through_non_txtbsy_error() {
1923 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
1924 let err = spawn_retrying_txtbsy(&mut cmd)
1925 .await
1926 .expect_err("spawn of missing binary should fail");
1927 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1928 }
1929
1930 #[cfg(feature = "async")]
1931 #[tokio::test]
1932 async fn async_allow_exit_codes_permits_listed_code() {
1933 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1934 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
1935 .await
1936 .expect("allowed code is Ok");
1937 assert!(!out.success);
1938 assert_eq!(out.exit_code, 2);
1939 assert!(out.stdout.contains("out"));
1940 }
1941
1942 #[cfg(feature = "async")]
1943 #[tokio::test]
1944 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
1945 let (_dir, path) = fake_script(r#"exit 2"#);
1946 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
1947 .await
1948 .unwrap_err();
1949 assert!(
1950 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
1951 "got: {err:?}"
1952 );
1953 }
1954
1955 #[cfg(feature = "async")]
1956 #[tokio::test]
1957 async fn async_timeout_fires_on_slow_child() {
1958 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1959 let claude = Claude::builder()
1960 .binary(&path)
1961 .timeout(Duration::from_millis(300))
1962 .build()
1963 .expect("build");
1964 let err = run_claude(&claude, vec![]).await.unwrap_err();
1965 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1966 }
1967
1968 #[cfg(feature = "async")]
1969 #[tokio::test]
1970 async fn async_timeout_path_returns_output_when_fast() {
1971 let (_dir, path) = fake_script(r#"echo quick"#);
1972 let claude = Claude::builder()
1973 .binary(&path)
1974 .timeout(Duration::from_secs(30))
1975 .build()
1976 .expect("build");
1977 let out = run_claude(&claude, vec![]).await.expect("success");
1978 assert!(out.stdout.contains("quick"));
1979 }
1980
1981 #[cfg(feature = "async")]
1982 #[tokio::test]
1983 async fn async_timeout_path_maps_command_failed() {
1984 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
1985 let claude = Claude::builder()
1986 .binary(&path)
1987 .timeout(Duration::from_secs(30))
1988 .build()
1989 .expect("build");
1990 let err = run_claude(&claude, vec![]).await.unwrap_err();
1991 assert!(
1992 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
1993 "got: {err:?}"
1994 );
1995 }
1996
1997 #[cfg(feature = "async")]
1998 #[tokio::test]
1999 async fn async_stdin_with_timeout_round_trips() {
2000 let (_dir, path) = fake_script(r#"cat"#);
2001 let claude = Claude::builder()
2002 .binary(&path)
2003 .timeout(Duration::from_secs(30))
2004 .build()
2005 .expect("build");
2006 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
2007 .await
2008 .expect("success");
2009 assert!(out.stdout.contains("piped under timeout"));
2010 }
2011
2012 #[cfg(feature = "async")]
2013 #[tokio::test]
2014 async fn async_stdin_timeout_fires_on_slow_child() {
2015 let (_dir, path) = fake_script(r#"sleep 3"#);
2016 let claude = Claude::builder()
2017 .binary(&path)
2018 .timeout(Duration::from_millis(300))
2019 .build()
2020 .expect("build");
2021 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
2022 .await
2023 .unwrap_err();
2024 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2025 }
2026
2027 #[cfg(feature = "async")]
2030 async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
2031 where
2032 F: std::future::Future,
2033 F::Output: std::fmt::Debug,
2034 {
2035 tokio::pin!(fut);
2036 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2037 loop {
2038 if let Some(pid) = std::fs::read_to_string(pid_path)
2039 .ok()
2040 .and_then(|s| s.trim().parse().ok())
2041 {
2042 return pid;
2044 }
2045 assert!(
2046 std::time::Instant::now() < deadline,
2047 "child never wrote its pid file"
2048 );
2049 tokio::select! {
2050 out = &mut fut => panic!("future completed before drop: {out:?}"),
2051 _ = tokio::time::sleep(Duration::from_millis(10)) => {}
2052 }
2053 }
2054 }
2055
2056 fn assert_pid_killed(pid: u32) {
2062 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2063 loop {
2064 let out = std::process::Command::new("ps")
2065 .args(["-o", "stat=", "-p", &pid.to_string()])
2066 .output()
2067 .expect("run ps");
2068 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2069 if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
2070 return;
2071 }
2072 assert!(
2073 std::time::Instant::now() < deadline,
2074 "process {pid} still alive (stat {stat}) after kill"
2075 );
2076 std::thread::sleep(Duration::from_millis(25));
2077 }
2078 }
2079
2080 fn group_script(
2088 pid_path: &std::path::Path,
2089 gpid_path: &std::path::Path,
2090 ) -> (tempfile::TempDir, std::path::PathBuf) {
2091 fake_script(&format!(
2096 concat!(
2097 "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2098 "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2099 "echo $$ > \"{p}\"\n",
2100 "exec sleep 300",
2101 ),
2102 g = gpid_path.display(),
2103 p = pid_path.display(),
2104 ))
2105 }
2106
2107 fn try_read_pid(path: &std::path::Path) -> Option<u32> {
2109 std::fs::read_to_string(path).ok()?.trim().parse().ok()
2110 }
2111
2112 #[cfg(feature = "async")]
2116 fn read_pid(path: &std::path::Path) -> u32 {
2117 try_read_pid(path).expect("pid file readable")
2118 }
2119
2120 #[cfg(feature = "async")]
2126 #[tokio::test]
2127 async fn async_dropping_in_flight_future_kills_child() {
2128 let workdir = tempfile::tempdir().expect("workdir");
2129 let pid_path = workdir.path().join("pid");
2130 let (_dir, path) = fake_script(&format!(
2131 r#"echo $$ > "{}"; exec sleep 30"#,
2132 pid_path.display()
2133 ));
2134 let claude = client(&path);
2135 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2136 assert_pid_killed(pid);
2137 }
2138
2139 #[cfg(feature = "async")]
2144 #[tokio::test]
2145 async fn async_dropping_in_flight_future_kills_child_with_timeout() {
2146 let workdir = tempfile::tempdir().expect("workdir");
2147 let pid_path = workdir.path().join("pid");
2148 let (_dir, path) = fake_script(&format!(
2149 r#"echo $$ > "{}"; exec sleep 30"#,
2150 pid_path.display()
2151 ));
2152 let claude = Claude::builder()
2153 .binary(&path)
2154 .timeout(Duration::from_secs(120))
2155 .build()
2156 .expect("build");
2157 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2158 assert_pid_killed(pid);
2159 }
2160
2161 #[cfg(feature = "async")]
2165 #[tokio::test]
2166 async fn async_dropping_in_flight_future_kills_process_group() {
2167 let workdir = tempfile::tempdir().expect("workdir");
2168 let pid_path = workdir.path().join("pid");
2169 let gpid_path = workdir.path().join("gpid");
2170 let (_dir, path) = group_script(&pid_path, &gpid_path);
2171 let claude = client(&path);
2172 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2173 assert_pid_killed(pid);
2174 assert_pid_killed(read_pid(&gpid_path));
2175 }
2176
2177 #[cfg(feature = "async")]
2183 #[tokio::test]
2184 async fn async_timeout_kills_process_group() {
2185 let mut observed = false;
2186 for _ in 0..5 {
2187 let workdir = tempfile::tempdir().expect("workdir");
2188 let pid_path = workdir.path().join("pid");
2189 let gpid_path = workdir.path().join("gpid");
2190 let (_dir, path) = group_script(&pid_path, &gpid_path);
2191 let claude = Claude::builder()
2192 .binary(&path)
2193 .timeout(Duration::from_millis(1000))
2194 .build()
2195 .expect("build");
2196 let err = run_claude(&claude, vec![]).await.unwrap_err();
2197 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2198 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2199 assert_pid_killed(pid);
2200 assert_pid_killed(gpid);
2201 observed = true;
2202 break;
2203 }
2204 }
2205 assert!(observed, "child never recorded pids within 5 timeout runs");
2206 }
2207
2208 #[cfg(feature = "async")]
2214 #[tokio::test]
2215 async fn async_process_group_opt_out_kills_only_direct_child() {
2216 let workdir = tempfile::tempdir().expect("workdir");
2217 let pid_path = workdir.path().join("pid");
2218 let gpid_path = workdir.path().join("gpid");
2219 let (_dir, path) = group_script(&pid_path, &gpid_path);
2220 let claude = Claude::builder()
2221 .binary(&path)
2222 .process_group(false)
2223 .build()
2224 .expect("build");
2225 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2226 assert_pid_killed(pid);
2227
2228 let gpid = read_pid(&gpid_path);
2230 let out = std::process::Command::new("ps")
2231 .args(["-o", "stat=", "-p", &gpid.to_string()])
2232 .output()
2233 .expect("run ps");
2234 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2235 assert!(
2236 out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
2237 "grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
2238 );
2239
2240 let _ = std::process::Command::new("kill")
2242 .args(["-9", &gpid.to_string()])
2243 .status();
2244 }
2245
2246 fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
2252 fake_script(&format!(
2253 concat!(
2254 "trap 'echo term > \"{m}\"; exit 0' TERM\n",
2255 "sleep 300 &\n",
2256 "wait $!",
2257 ),
2258 m = marker.display(),
2259 ))
2260 }
2261
2262 #[cfg(feature = "async")]
2268 #[tokio::test]
2269 async fn async_timeout_with_grace_delivers_sigterm_first() {
2270 let mut observed = false;
2271 for _ in 0..5 {
2272 let workdir = tempfile::tempdir().expect("workdir");
2273 let marker = workdir.path().join("term-marker");
2274 let (_dir, path) = term_trap_script(&marker);
2275 let claude = Claude::builder()
2276 .binary(&path)
2277 .timeout(Duration::from_millis(500))
2278 .kill_grace(Duration::from_secs(1))
2279 .build()
2280 .expect("build");
2281 let err = run_claude(&claude, vec![]).await.unwrap_err();
2282 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2283 if marker.exists() {
2284 observed = true;
2285 break;
2286 }
2287 }
2288 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2289 }
2290
2291 #[cfg(feature = "sync")]
2293 #[test]
2294 fn sync_timeout_with_grace_delivers_sigterm_first() {
2295 let mut observed = false;
2296 for _ in 0..5 {
2297 let workdir = tempfile::tempdir().expect("workdir");
2298 let marker = workdir.path().join("term-marker");
2299 let (_dir, path) = term_trap_script(&marker);
2300 let claude = Claude::builder()
2301 .binary(&path)
2302 .timeout(Duration::from_millis(500))
2303 .kill_grace(Duration::from_secs(1))
2304 .build()
2305 .expect("build");
2306 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2307 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2308 if marker.exists() {
2309 observed = true;
2310 break;
2311 }
2312 }
2313 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2314 }
2315
2316 #[cfg(feature = "async")]
2318 #[tokio::test]
2319 async fn async_dropping_in_flight_stdin_future_kills_child() {
2320 let workdir = tempfile::tempdir().expect("workdir");
2321 let pid_path = workdir.path().join("pid");
2322 let (_dir, path) = fake_script(&format!(
2323 r#"echo $$ > "{}"; exec sleep 30"#,
2324 pid_path.display()
2325 ));
2326 let claude = client(&path);
2327 let pid = drop_in_flight_and_capture_pid(
2328 run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
2329 &pid_path,
2330 )
2331 .await;
2332 assert_pid_killed(pid);
2333 }
2334
2335 #[cfg(feature = "async")]
2336 #[tokio::test]
2337 async fn async_spawn_failure_maps_io() {
2338 let claude = Claude::builder()
2339 .binary("/nonexistent/definitely/not/here")
2340 .build()
2341 .expect("build");
2342 let err = run_claude(&claude, vec![]).await.unwrap_err();
2343 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
2344 }
2345
2346 #[cfg(feature = "sync")]
2349 #[test]
2350 fn sync_success_maps_output() {
2351 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
2352 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2353 assert!(out.success);
2354 assert!(out.stdout.contains("hi sync"));
2355 }
2356
2357 #[cfg(feature = "sync")]
2358 #[test]
2359 fn sync_nonzero_exit_maps_command_failed() {
2360 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2361 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
2362 match err {
2363 Error::CommandFailed {
2364 exit_code, stderr, ..
2365 } => {
2366 assert_eq!(exit_code, 3);
2367 assert!(stderr.contains("boom"));
2368 }
2369 other => panic!("expected CommandFailed, got {other:?}"),
2370 }
2371 }
2372
2373 #[cfg(feature = "sync")]
2374 #[test]
2375 fn sync_scrubs_claude_env_vars() {
2376 let (_dir, path) =
2377 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2378 set_scrub_vars();
2379 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2380 clear_scrub_vars();
2381 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2382 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2383 }
2384
2385 #[cfg(feature = "sync")]
2386 #[test]
2387 fn sync_stdin_prompt_round_trips() {
2388 let (_dir, path) = fake_script(r#"cat"#);
2389 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
2390 .expect("success");
2391 assert!(out.stdout.contains("sync stdin"));
2392 }
2393
2394 #[cfg(feature = "sync")]
2397 #[test]
2398 fn sync_spawn_retry_passes_through_non_txtbsy_error() {
2399 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2400 let err =
2401 spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
2402 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2403 }
2404
2405 #[cfg(feature = "sync")]
2406 #[test]
2407 fn sync_output_retry_passes_through_non_txtbsy_error() {
2408 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2409 let err = output_retrying_txtbsy_sync_observed(&mut cmd, false, None)
2410 .expect_err("output of missing binary should fail");
2411 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2412 }
2413
2414 #[cfg(feature = "sync")]
2415 #[test]
2416 fn sync_allow_exit_codes_permits_listed_code() {
2417 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2418 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
2419 .expect("allowed code is Ok");
2420 assert!(!out.success);
2421 assert_eq!(out.exit_code, 2);
2422 }
2423
2424 #[cfg(feature = "sync")]
2425 #[test]
2426 fn sync_timeout_fires_on_slow_child() {
2427 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2428 let claude = Claude::builder()
2429 .binary(&path)
2430 .timeout(Duration::from_millis(300))
2431 .build()
2432 .expect("build");
2433 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2434 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2435 }
2436
2437 #[cfg(feature = "sync")]
2441 #[test]
2442 fn sync_timeout_kills_process_group() {
2443 let mut observed = false;
2444 for _ in 0..5 {
2445 let workdir = tempfile::tempdir().expect("workdir");
2446 let pid_path = workdir.path().join("pid");
2447 let gpid_path = workdir.path().join("gpid");
2448 let (_dir, path) = group_script(&pid_path, &gpid_path);
2449 let claude = Claude::builder()
2450 .binary(&path)
2451 .timeout(Duration::from_millis(1000))
2452 .build()
2453 .expect("build");
2454 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2455 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2456 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2457 assert_pid_killed(pid);
2458 assert_pid_killed(gpid);
2459 observed = true;
2460 break;
2461 }
2462 }
2463 assert!(observed, "child never recorded pids within 5 timeout runs");
2464 }
2465
2466 #[cfg(feature = "sync")]
2467 #[test]
2468 fn sync_timeout_path_returns_output_when_fast() {
2469 let (_dir, path) = fake_script(r#"echo quick"#);
2470 let claude = Claude::builder()
2471 .binary(&path)
2472 .timeout(Duration::from_secs(30))
2473 .build()
2474 .expect("build");
2475 let out = run_claude_sync(&claude, vec![]).expect("success");
2476 assert!(out.stdout.contains("quick"));
2477 }
2478
2479 #[cfg(feature = "sync")]
2480 #[test]
2481 fn sync_stdin_with_timeout_round_trips() {
2482 let (_dir, path) = fake_script(r#"cat"#);
2483 let claude = Claude::builder()
2484 .binary(&path)
2485 .timeout(Duration::from_secs(30))
2486 .build()
2487 .expect("build");
2488 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
2489 .expect("success");
2490 assert!(out.stdout.contains("sync piped"));
2491 }
2492}