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) output_limit: Option<usize>,
167 pub(crate) die_with_parent: bool,
168 pub(crate) on_spawn: Option<&'a crate::SpawnObserver>,
169}
170
171#[cfg(any(feature = "async", feature = "sync"))]
172impl SpawnPolicy<'_> {
173 pub(crate) fn of(claude: &Claude) -> SpawnPolicy<'_> {
175 SpawnPolicy {
176 process_group: claude.process_group,
177 kill_grace: claude.kill_grace,
178 output_limit: claude.output_limit,
179 die_with_parent: claude.die_with_parent,
180 on_spawn: claude.on_spawn.as_ref(),
181 }
182 }
183}
184
185#[cfg(any(feature = "async", feature = "sync"))]
186pub(crate) fn arm_and_notify(
187 process_group: bool,
188 pid: Option<u32>,
189 on_spawn: Option<&crate::SpawnObserver>,
190) -> GroupKillGuard {
191 if let (Some(pid), Some(observer)) = (pid, on_spawn) {
192 observer(crate::SpawnInfo {
193 pid,
194 pgid: process_group.then_some(pid),
195 });
196 }
197 GroupKillGuard::new_if(process_group, pid)
198}
199
200#[cfg(any(feature = "async", feature = "sync"))]
201pub(crate) struct GroupKillGuard {
202 #[cfg(unix)]
203 pgid: Option<i32>,
204}
205
206#[cfg(any(feature = "async", feature = "sync"))]
207impl GroupKillGuard {
208 pub(crate) fn new_if(enabled: bool, pid: Option<u32>) -> Self {
213 Self::new(if enabled { pid } else { None })
214 }
215
216 pub(crate) fn new(pid: Option<u32>) -> Self {
220 #[cfg(unix)]
221 {
222 Self {
223 pgid: pid.and_then(|p| i32::try_from(p).ok()),
224 }
225 }
226 #[cfg(not(unix))]
227 {
228 let _ = pid;
229 Self {}
230 }
231 }
232
233 pub(crate) fn disarm(&mut self) {
236 #[cfg(unix)]
237 {
238 self.pgid = None;
239 }
240 }
241
242 pub(crate) fn is_armed(&self) -> bool {
244 #[cfg(unix)]
245 {
246 self.pgid.is_some()
247 }
248 #[cfg(not(unix))]
249 {
250 false
251 }
252 }
253
254 pub(crate) fn term_now(&self) {
258 #[cfg(unix)]
259 if let Some(pgid) = self.pgid {
260 let _ = unsafe { libc::killpg(pgid, libc::SIGTERM) };
263 }
264 }
265
266 pub(crate) fn kill_now(&mut self) {
268 #[cfg(unix)]
269 if let Some(pgid) = self.pgid.take() {
270 let _ = unsafe { libc::killpg(pgid, libc::SIGKILL) };
273 }
274 }
275}
276
277#[cfg(any(feature = "async", feature = "sync"))]
278impl Drop for GroupKillGuard {
279 fn drop(&mut self) {
280 self.kill_now();
281 }
282}
283
284#[must_use]
293pub const fn die_with_parent_supported() -> bool {
294 cfg!(target_os = "linux")
295}
296
297#[cfg(all(unix, any(feature = "async", feature = "sync")))]
313fn pdeathsig_hook() -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static {
314 let parent = std::process::id();
317 move || {
318 #[cfg(target_os = "linux")]
319 {
320 unsafe {
322 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
323 return Err(std::io::Error::last_os_error());
324 }
325 if libc::getppid() as u32 != parent {
327 libc::_exit(1);
328 }
329 }
330 }
331 #[cfg(not(target_os = "linux"))]
332 {
333 let _ = parent;
334 }
335 Ok(())
336 }
337}
338
339#[cfg(feature = "async")]
342pub(crate) fn apply_die_with_parent(cmd: &mut Command, enabled: bool) {
343 #[cfg(unix)]
344 if enabled {
345 unsafe {
347 cmd.pre_exec(pdeathsig_hook());
348 }
349 }
350 #[cfg(not(unix))]
351 {
352 let _ = (cmd, enabled);
353 }
354}
355
356#[cfg(feature = "sync")]
358pub(crate) fn apply_die_with_parent_sync(cmd: &mut std::process::Command, enabled: bool) {
359 #[cfg(unix)]
360 if enabled {
361 use std::os::unix::process::CommandExt;
362 unsafe {
364 cmd.pre_exec(pdeathsig_hook());
365 }
366 }
367 #[cfg(not(unix))]
368 {
369 let _ = (cmd, enabled);
370 }
371}
372
373#[cfg(feature = "async")]
377pub(crate) fn apply_process_group(cmd: &mut Command, enabled: bool) {
378 #[cfg(unix)]
379 if enabled {
380 cmd.process_group(0);
381 }
382 #[cfg(not(unix))]
383 {
384 let _ = (cmd, enabled);
385 }
386}
387
388#[cfg(feature = "sync")]
390pub(crate) fn apply_process_group_sync(cmd: &mut std::process::Command, enabled: bool) {
391 #[cfg(unix)]
392 if enabled {
393 use std::os::unix::process::CommandExt;
394 cmd.process_group(0);
395 }
396 #[cfg(not(unix))]
397 {
398 let _ = (cmd, enabled);
399 }
400}
401
402#[cfg(feature = "async")]
410pub(crate) async fn kill_group_with_grace(group: &mut GroupKillGuard, grace: Option<Duration>) {
411 if let Some(g) = grace
412 && !g.is_zero()
413 && group.is_armed()
414 {
415 group.term_now();
416 tokio::time::sleep(g).await;
417 }
418 group.kill_now();
419}
420
421#[cfg(feature = "async")]
422enum StopReason {
423 Timeout { timeout_seconds: u64 },
424 Cancelled,
425}
426
427#[cfg(feature = "async")]
428impl StopReason {
429 fn into_error(self) -> Error {
430 match self {
431 Self::Timeout { timeout_seconds } => Error::Timeout { timeout_seconds },
432 Self::Cancelled => Error::Cancelled,
433 }
434 }
435}
436
437#[cfg(feature = "async")]
440fn never_stop() -> StopFuture<'static> {
441 Box::pin(std::future::pending())
442}
443
444#[cfg(feature = "async")]
445type StopFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = StopReason> + Send + 'a>>;
446
447#[cfg(feature = "async")]
448fn timeout_stop(timeout: Duration) -> StopFuture<'static> {
449 Box::pin(async move {
450 tokio::time::sleep(timeout).await;
451 StopReason::Timeout {
452 timeout_seconds: timeout.as_secs(),
453 }
454 })
455}
456
457#[cfg(feature = "async")]
458fn cancellation_or_timeout<'a, C>(cancel: C, timeout: Option<Duration>) -> StopFuture<'a>
459where
460 C: std::future::Future<Output = ()> + Send + 'a,
461{
462 Box::pin(async move {
463 match timeout {
464 Some(timeout) => tokio::select! {
465 () = cancel => StopReason::Cancelled,
466 () = tokio::time::sleep(timeout) => StopReason::Timeout {
467 timeout_seconds: timeout.as_secs(),
468 },
469 },
470 None => {
471 cancel.await;
472 StopReason::Cancelled
473 }
474 }
475 })
476}
477
478#[cfg(feature = "async")]
479async fn stop_and_reap(
480 child: &mut tokio::process::Child,
481 group: &mut GroupKillGuard,
482 grace: Option<Duration>,
483 working_dir: Option<&std::path::Path>,
484) -> Result<()> {
485 kill_group_with_grace(group, grace).await;
486 if child
487 .try_wait()
488 .map_err(|e| wait_error(e, working_dir))?
489 .is_none()
490 && let Err(error) = child.start_kill()
491 && error.kind() != std::io::ErrorKind::InvalidInput
492 {
493 return Err(wait_error(error, working_dir));
494 }
495 child.wait().await.map_err(|e| wait_error(e, working_dir))?;
496 Ok(())
497}
498
499#[cfg(feature = "async")]
500fn wait_error(error: std::io::Error, working_dir: Option<&std::path::Path>) -> Error {
501 Error::Io {
502 message: "failed to wait for claude process".to_string(),
503 source: error,
504 working_dir: working_dir.map(|p| p.to_path_buf()),
505 }
506}
507
508#[cfg(feature = "sync")]
510pub(crate) fn kill_group_with_grace_sync(group: &mut GroupKillGuard, grace: Option<Duration>) {
511 if let Some(g) = grace
512 && !g.is_zero()
513 && group.is_armed()
514 {
515 group.term_now();
516 std::thread::sleep(g);
517 }
518 group.kill_now();
519}
520
521#[cfg(feature = "async")]
532pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
533 run_claude_with_retry(claude, args, None).await
534}
535
536#[cfg(feature = "async")]
542pub async fn run_claude_cancellable<C>(
543 claude: &Claude,
544 args: Vec<String>,
545 cancel: C,
546) -> Result<CommandOutput>
547where
548 C: std::future::Future<Output = ()> + Send,
549{
550 let command_args = full_command_args(claude, args);
551 let span = exec_span(claude, &command_args, "cancellable");
552 let _enter = span.enter();
553 let started = std::time::Instant::now();
554 let stop = cancellation_or_timeout(cancel, claude.timeout);
555 let output = run_with_stop(
556 &claude.binary,
557 &command_args,
558 &claude.env,
559 claude.clear_env,
560 claude.working_dir.as_deref(),
561 stop,
562 SpawnPolicy::of(claude),
563 )
564 .await?;
565 record_exec_outcome(&span, output.exit_code, started);
566 Ok(output)
567}
568
569#[cfg(feature = "async")]
573pub async fn run_claude_with_retry(
574 claude: &Claude,
575 args: Vec<String>,
576 retry_override: Option<&crate::retry::RetryPolicy>,
577) -> Result<CommandOutput> {
578 let policy = retry_override.or(claude.retry_policy.as_ref());
579
580 match policy {
581 Some(policy) => {
582 crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
583 }
584 None => run_claude_once(claude, args).await,
585 }
586}
587
588#[cfg(feature = "async")]
596pub async fn run_claude_with_stdin_prompt(
597 claude: &Claude,
598 args: Vec<String>,
599 stdin_content: String,
600) -> Result<CommandOutput> {
601 run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
602}
603
604#[cfg(feature = "async")]
609pub async fn run_claude_with_stdin_prompt_cancellable<C>(
610 claude: &Claude,
611 args: Vec<String>,
612 stdin_content: String,
613 cancel: C,
614) -> Result<CommandOutput>
615where
616 C: std::future::Future<Output = ()> + Send,
617{
618 let command_args = full_command_args(claude, args);
619 let span = exec_span(claude, &command_args, "stdin-cancellable");
620 let _enter = span.enter();
621 let started = std::time::Instant::now();
622 let stop = cancellation_or_timeout(cancel, claude.timeout);
623 let output = run_stdin_with_stop(
624 &claude.binary,
625 &command_args,
626 &claude.env,
627 claude.clear_env,
628 claude.working_dir.as_deref(),
629 stdin_content,
630 stop,
631 SpawnPolicy::of(claude),
632 )
633 .await?;
634 record_exec_outcome(&span, output.exit_code, started);
635 Ok(output)
636}
637
638#[cfg(feature = "async")]
639async fn run_claude_with_stdin_prompt_internal(
640 claude: &Claude,
641 args: Vec<String>,
642 stdin_content: String,
643) -> Result<CommandOutput> {
644 let command_args = full_command_args(claude, args);
645
646 let span = exec_span(claude, &command_args, "stdin");
647 let _enter = span.enter();
648 let started = std::time::Instant::now();
649 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
650
651 let binary = &claude.binary;
652 let env = &claude.env;
653 let clear_env = claude.clear_env;
654 let working_dir = claude.working_dir.as_deref();
655
656 let result = if let Some(timeout) = claude.timeout {
657 run_with_timeout_stdin(
658 binary,
659 &command_args,
660 env,
661 clear_env,
662 working_dir,
663 timeout,
664 stdin_content,
665 SpawnPolicy::of(claude),
666 )
667 .await
668 } else {
669 run_internal_stdin(
670 binary,
671 &command_args,
672 env,
673 clear_env,
674 working_dir,
675 stdin_content,
676 SpawnPolicy::of(claude),
677 )
678 .await
679 };
680
681 if let Ok(output) = &result {
682 record_exec_outcome(&span, output.exit_code, started);
683 }
684 result
685}
686
687#[cfg(feature = "async")]
695async fn run_internal_stdin(
696 binary: &std::path::Path,
697 args: &[String],
698 env: &std::collections::HashMap<String, String>,
699 clear_env: bool,
700 working_dir: Option<&std::path::Path>,
701 stdin_content: String,
702 policy: SpawnPolicy<'_>,
703) -> Result<CommandOutput> {
704 run_stdin_with_stop(
705 binary,
706 args,
707 env,
708 clear_env,
709 working_dir,
710 stdin_content,
711 never_stop(),
712 policy,
713 )
714 .await
715}
716
717#[cfg(feature = "async")]
718#[allow(clippy::too_many_arguments)]
719async fn run_with_timeout_stdin(
720 binary: &std::path::Path,
721 args: &[String],
722 env: &std::collections::HashMap<String, String>,
723 clear_env: bool,
724 working_dir: Option<&std::path::Path>,
725 timeout: Duration,
726 stdin_content: String,
727 policy: SpawnPolicy<'_>,
728) -> Result<CommandOutput> {
729 run_stdin_with_stop(
730 binary,
731 args,
732 env,
733 clear_env,
734 working_dir,
735 stdin_content,
736 timeout_stop(timeout),
737 policy,
738 )
739 .await
740}
741
742#[cfg(feature = "async")]
743#[allow(clippy::too_many_arguments)]
744async fn run_stdin_with_stop(
745 binary: &std::path::Path,
746 args: &[String],
747 env: &std::collections::HashMap<String, String>,
748 clear_env: bool,
749 working_dir: Option<&std::path::Path>,
750 stdin_content: String,
751 stop: StopFuture<'_>,
752 policy: SpawnPolicy<'_>,
753) -> Result<CommandOutput> {
754 let SpawnPolicy {
755 process_group,
756 kill_grace,
757 output_limit,
758 die_with_parent,
759 on_spawn,
760 } = policy;
761 use tokio::io::AsyncWriteExt;
762
763 let mut cmd = Command::new(binary);
764 cmd.args(args);
765 cmd.stdin(std::process::Stdio::piped());
766 cmd.stdout(std::process::Stdio::piped());
767 cmd.stderr(std::process::Stdio::piped());
768 cmd.kill_on_drop(true);
771 apply_process_group(&mut cmd, process_group);
775 apply_die_with_parent(&mut cmd, die_with_parent);
776 apply_child_environment(cmd.as_std_mut(), clear_env, env);
777
778 if let Some(dir) = working_dir {
779 cmd.current_dir(dir);
780 }
781
782 let mut child = spawn_retrying_txtbsy(&mut cmd)
783 .await
784 .map_err(|e| Error::Io {
785 message: format!("failed to spawn claude: {e}"),
786 source: e,
787 working_dir: working_dir.map(|p| p.to_path_buf()),
788 })?;
789 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
790
791 let child_stdin = child.stdin.take();
792 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
793 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
794 let write = async move {
795 let Some(mut stdin) = child_stdin else {
796 return Ok(());
797 };
798 stdin
799 .write_all(stdin_content.as_bytes())
800 .await
801 .map_err(|e| Error::Io {
802 message: format!("failed to write to claude stdin: {e}"),
803 source: e,
804 working_dir: working_dir.map(|p| p.to_path_buf()),
805 })
806 };
807 let read_stdout = capture_stream(
808 &mut stdout_handle,
809 output_limit,
810 crate::OutputStream::Stdout,
811 working_dir,
812 );
813 let read_stderr = capture_stream(
814 &mut stderr_handle,
815 output_limit,
816 crate::OutputStream::Stderr,
817 working_dir,
818 );
819 let wait = async { child.wait().await.map_err(|e| wait_error(e, working_dir)) };
820 let run = async {
821 let ((), status, stdout, stderr) = tokio::try_join!(write, wait, read_stdout, read_stderr)?;
822 Ok::<_, Error>((status, stdout, stderr))
823 };
824
825 match tokio::select! {
826 outcome = run => Ok(outcome),
827 reason = stop => Err(reason),
828 } {
829 Ok(Ok((status, stdout, stderr))) => {
830 group.disarm();
831 let exit_code = status.code().unwrap_or(-1);
832
833 if !status.success() {
834 return Err(Error::from_command_failure(
835 format!("{} {}", binary.display(), args.join(" ")),
836 exit_code,
837 stdout,
838 stderr,
839 working_dir.map(|p| p.to_path_buf()),
840 ));
841 }
842
843 Ok(CommandOutput {
844 stdout,
845 stderr,
846 exit_code,
847 success: true,
848 })
849 }
850 Ok(Err(error)) => {
851 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
852 Err(error)
853 }
854 Err(reason) => {
855 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
859 Err(reason.into_error())
860 }
861 }
862}
863
864#[cfg(feature = "async")]
865async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
866 let command_args = full_command_args(claude, args);
867
868 let span = exec_span(claude, &command_args, "oneshot");
869 let _enter = span.enter();
870 let started = std::time::Instant::now();
871 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
872
873 let output = if let Some(timeout) = claude.timeout {
874 run_with_timeout(
875 &claude.binary,
876 &command_args,
877 &claude.env,
878 claude.clear_env,
879 claude.working_dir.as_deref(),
880 timeout,
881 SpawnPolicy::of(claude),
882 )
883 .await?
884 } else {
885 run_internal(
886 &claude.binary,
887 &command_args,
888 &claude.env,
889 claude.clear_env,
890 claude.working_dir.as_deref(),
891 SpawnPolicy::of(claude),
892 )
893 .await?
894 };
895
896 record_exec_outcome(&span, output.exit_code, started);
897 Ok(output)
898}
899
900#[cfg(feature = "async")]
904pub async fn run_claude_allow_exit_codes(
905 claude: &Claude,
906 args: Vec<String>,
907 allowed_codes: &[i32],
908) -> Result<CommandOutput> {
909 let output = run_claude(claude, args).await;
910
911 match output {
912 Err(Error::CommandFailed {
913 exit_code,
914 stdout,
915 stderr,
916 ..
917 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
918 stdout,
919 stderr,
920 exit_code,
921 success: false,
922 }),
923 other => other,
924 }
925}
926
927#[cfg(feature = "async")]
935async fn run_internal(
936 binary: &std::path::Path,
937 args: &[String],
938 env: &std::collections::HashMap<String, String>,
939 clear_env: bool,
940 working_dir: Option<&std::path::Path>,
941 policy: SpawnPolicy<'_>,
942) -> Result<CommandOutput> {
943 run_with_stop(
944 binary,
945 args,
946 env,
947 clear_env,
948 working_dir,
949 never_stop(),
950 policy,
951 )
952 .await
953}
954
955#[cfg(feature = "async")]
968async fn run_with_timeout(
969 binary: &std::path::Path,
970 args: &[String],
971 env: &std::collections::HashMap<String, String>,
972 clear_env: bool,
973 working_dir: Option<&std::path::Path>,
974 timeout: Duration,
975 policy: SpawnPolicy<'_>,
976) -> Result<CommandOutput> {
977 run_with_stop(
978 binary,
979 args,
980 env,
981 clear_env,
982 working_dir,
983 timeout_stop(timeout),
984 policy,
985 )
986 .await
987}
988
989#[cfg(feature = "async")]
990async fn run_with_stop(
991 binary: &std::path::Path,
992 args: &[String],
993 env: &std::collections::HashMap<String, String>,
994 clear_env: bool,
995 working_dir: Option<&std::path::Path>,
996 stop: StopFuture<'_>,
997 policy: SpawnPolicy<'_>,
998) -> Result<CommandOutput> {
999 let SpawnPolicy {
1000 process_group,
1001 kill_grace,
1002 output_limit,
1003 die_with_parent,
1004 on_spawn,
1005 } = policy;
1006 let mut cmd = Command::new(binary);
1007 cmd.args(args);
1008 cmd.stdin(std::process::Stdio::null());
1009 cmd.stdout(std::process::Stdio::piped());
1010 cmd.stderr(std::process::Stdio::piped());
1011 cmd.kill_on_drop(true);
1014 apply_process_group(&mut cmd, process_group);
1018 apply_die_with_parent(&mut cmd, die_with_parent);
1019 apply_child_environment(cmd.as_std_mut(), clear_env, env);
1020
1021 if let Some(dir) = working_dir {
1022 cmd.current_dir(dir);
1023 }
1024
1025 let mut child = spawn_retrying_txtbsy(&mut cmd)
1026 .await
1027 .map_err(|e| Error::Io {
1028 message: format!("failed to spawn claude: {e}"),
1029 source: e,
1030 working_dir: working_dir.map(|p| p.to_path_buf()),
1031 })?;
1032 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
1033
1034 let mut stdout = child.stdout.take().expect("stdout was piped");
1035 let mut stderr = child.stderr.take().expect("stderr was piped");
1036
1037 let wait_and_drain = async {
1044 tokio::try_join!(
1045 async { child.wait().await.map_err(|e| wait_error(e, working_dir)) },
1046 capture_stream(
1047 &mut stdout,
1048 output_limit,
1049 crate::OutputStream::Stdout,
1050 working_dir
1051 ),
1052 capture_stream(
1053 &mut stderr,
1054 output_limit,
1055 crate::OutputStream::Stderr,
1056 working_dir
1057 ),
1058 )
1059 };
1060
1061 match tokio::select! {
1062 outcome = wait_and_drain => Ok(outcome),
1063 reason = stop => Err(reason),
1064 } {
1065 Ok(Ok((status, stdout, stderr))) => {
1066 group.disarm();
1067 let exit_code = status.code().unwrap_or(-1);
1068
1069 if !status.success() {
1070 return Err(Error::from_command_failure(
1071 format!("{} {}", binary.display(), args.join(" ")),
1072 exit_code,
1073 stdout,
1074 stderr,
1075 working_dir.map(|p| p.to_path_buf()),
1076 ));
1077 }
1078
1079 Ok(CommandOutput {
1080 stdout,
1081 stderr,
1082 exit_code,
1083 success: true,
1084 })
1085 }
1086 Ok(Err(error)) => {
1090 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
1091 Err(error)
1092 }
1093 Err(reason) => {
1094 stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
1099 let stdout_str = tokio::time::timeout(
1100 PARTIAL_CAPTURE_BUDGET,
1101 capture_partial(&mut stdout, output_limit),
1102 )
1103 .await
1104 .unwrap_or_default();
1105 let stderr_str = tokio::time::timeout(
1106 PARTIAL_CAPTURE_BUDGET,
1107 capture_partial(&mut stderr, output_limit),
1108 )
1109 .await
1110 .unwrap_or_default();
1111 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1112 warn!(
1113 stdout = %stdout_str,
1114 stderr = %stderr_str,
1115 "partial output from stopped process",
1116 );
1117 }
1118 Err(reason.into_error())
1119 }
1120 }
1121}
1122
1123#[cfg(any(feature = "async", feature = "sync"))]
1126const CAPTURE_CHUNK: usize = 8 * 1024;
1127
1128#[cfg(any(feature = "async", feature = "sync"))]
1132const BREACH_LOG_BYTES: usize = 2 * 1024;
1133
1134#[cfg(any(feature = "async", feature = "sync"))]
1138const PARTIAL_CAPTURE_BUDGET: Duration = Duration::from_millis(200);
1139
1140#[cfg(any(feature = "async", feature = "sync"))]
1142enum Captured {
1143 Complete(String),
1145 Exceeded { head: String },
1150}
1151
1152#[cfg(feature = "async")]
1158async fn capture<R: AsyncReadExt + Unpin>(
1159 reader: &mut R,
1160 limit: Option<usize>,
1161) -> std::io::Result<Captured> {
1162 let Some(limit) = limit else {
1163 let mut buf = Vec::new();
1164 reader.read_to_end(&mut buf).await?;
1165 return Ok(Captured::Complete(
1166 String::from_utf8_lossy(&buf).into_owned(),
1167 ));
1168 };
1169
1170 let mut buf = Vec::new();
1171 let mut chunk = [0u8; CAPTURE_CHUNK];
1172 loop {
1173 let read = reader.read(&mut chunk).await?;
1174 if read == 0 {
1175 return Ok(Captured::Complete(
1176 String::from_utf8_lossy(&buf).into_owned(),
1177 ));
1178 }
1179 if buf.len() + read > limit {
1180 buf.truncate(BREACH_LOG_BYTES.min(buf.len()));
1181 return Ok(Captured::Exceeded {
1182 head: String::from_utf8_lossy(&buf).into_owned(),
1183 });
1184 }
1185 buf.extend_from_slice(&chunk[..read]);
1186 }
1187}
1188
1189#[cfg(feature = "async")]
1196async fn capture_stream<R: AsyncReadExt + Unpin>(
1197 reader: &mut R,
1198 limit: Option<usize>,
1199 stream: crate::OutputStream,
1200 working_dir: Option<&std::path::Path>,
1201) -> Result<String> {
1202 match capture(reader, limit).await {
1203 Ok(Captured::Complete(text)) => Ok(text),
1204 Ok(Captured::Exceeded { head }) => {
1205 let limit_bytes = limit.unwrap_or_default();
1206 warn!(
1207 %stream,
1208 limit_bytes,
1209 head = %head,
1210 "captured output exceeded its ceiling; terminating the child",
1211 );
1212 Err(Error::OutputLimitExceeded {
1213 stream,
1214 limit_bytes,
1215 })
1216 }
1217 Err(source) => Err(Error::Io {
1218 message: format!("failed to read claude {stream}: {source}"),
1219 source,
1220 working_dir: working_dir.map(|p| p.to_path_buf()),
1221 }),
1222 }
1223}
1224
1225#[cfg(feature = "async")]
1231async fn capture_partial<R: AsyncReadExt + Unpin>(reader: &mut R, limit: Option<usize>) -> String {
1232 match capture(reader, limit).await {
1233 Ok(Captured::Complete(text) | Captured::Exceeded { head: text }) => text,
1234 Err(_) => String::new(),
1235 }
1236}
1237
1238#[cfg(any(feature = "async", feature = "sync"))]
1247const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
1248
1249#[cfg(any(feature = "async", feature = "sync"))]
1256const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
1257
1258#[cfg(feature = "async")]
1269async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
1270 let start = std::time::Instant::now();
1271 let mut backoff = Duration::from_millis(1);
1272 loop {
1273 match cmd.spawn() {
1274 Err(e)
1275 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1276 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1277 {
1278 tokio::time::sleep(backoff).await;
1279 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1280 }
1281 other => return other,
1282 }
1283 }
1284}
1285
1286#[cfg(feature = "sync")]
1290pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1291 run_claude_with_retry_sync(claude, args, None)
1292}
1293
1294#[cfg(feature = "sync")]
1296pub fn run_claude_with_retry_sync(
1297 claude: &Claude,
1298 args: Vec<String>,
1299 retry_override: Option<&crate::retry::RetryPolicy>,
1300) -> Result<CommandOutput> {
1301 let policy = retry_override.or(claude.retry_policy.as_ref());
1302
1303 match policy {
1304 Some(policy) => {
1305 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
1306 }
1307 None => run_claude_once_sync(claude, args),
1308 }
1309}
1310
1311#[cfg(feature = "sync")]
1316pub fn run_claude_with_stdin_prompt_sync(
1317 claude: &Claude,
1318 args: Vec<String>,
1319 stdin_content: String,
1320) -> Result<CommandOutput> {
1321 let command_args = full_command_args(claude, args);
1322
1323 let span = exec_span(claude, &command_args, "stdin-sync");
1324 let _enter = span.enter();
1325 let started = std::time::Instant::now();
1326 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
1327
1328 let result = if let Some(timeout) = claude.timeout {
1329 run_with_deadline_stdin_sync(
1330 &claude.binary,
1331 &command_args,
1332 &claude.env,
1333 claude.clear_env,
1334 claude.working_dir.as_deref(),
1335 Some(timeout),
1336 stdin_content,
1337 SpawnPolicy::of(claude),
1338 )
1339 } else {
1340 run_internal_stdin_sync(
1341 &claude.binary,
1342 &command_args,
1343 &claude.env,
1344 claude.clear_env,
1345 claude.working_dir.as_deref(),
1346 stdin_content,
1347 SpawnPolicy::of(claude),
1348 )
1349 };
1350
1351 if let Ok(output) = &result {
1352 record_exec_outcome(&span, output.exit_code, started);
1353 }
1354 result
1355}
1356
1357#[cfg(feature = "sync")]
1361fn run_internal_stdin_sync(
1362 binary: &std::path::Path,
1363 args: &[String],
1364 env: &std::collections::HashMap<String, String>,
1365 clear_env: bool,
1366 working_dir: Option<&std::path::Path>,
1367 stdin_content: String,
1368 policy: SpawnPolicy<'_>,
1369) -> Result<CommandOutput> {
1370 run_with_deadline_stdin_sync(
1371 binary,
1372 args,
1373 env,
1374 clear_env,
1375 working_dir,
1376 None,
1377 stdin_content,
1378 policy,
1379 )
1380}
1381
1382#[cfg(feature = "sync")]
1386#[allow(clippy::too_many_arguments)]
1387fn run_with_deadline_stdin_sync(
1388 binary: &std::path::Path,
1389 args: &[String],
1390 env: &std::collections::HashMap<String, String>,
1391 clear_env: bool,
1392 working_dir: Option<&std::path::Path>,
1393 deadline: Option<Duration>,
1394 stdin_content: String,
1395 policy: SpawnPolicy<'_>,
1396) -> Result<CommandOutput> {
1397 let SpawnPolicy {
1398 process_group,
1399 kill_grace,
1400 output_limit,
1401 die_with_parent,
1402 on_spawn,
1403 } = policy;
1404 use std::io::Write;
1405 use std::process::{Command as StdCommand, Stdio};
1406
1407 let mut cmd = StdCommand::new(binary);
1408 cmd.args(args);
1409 cmd.stdin(Stdio::piped());
1410 cmd.stdout(Stdio::piped());
1411 cmd.stderr(Stdio::piped());
1412 apply_process_group_sync(&mut cmd, process_group);
1416 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1417 apply_child_environment(&mut cmd, clear_env, env);
1418
1419 if let Some(dir) = working_dir {
1420 cmd.current_dir(dir);
1421 }
1422
1423 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1424 message: format!("failed to spawn claude: {e}"),
1425 source: e,
1426 working_dir: working_dir.map(|p| p.to_path_buf()),
1427 })?;
1428 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1429
1430 let capture = SyncCapture::start(
1434 child.stdout.take().expect("stdout was piped"),
1435 child.stderr.take().expect("stderr was piped"),
1436 output_limit,
1437 );
1438
1439 if let Some(mut stdin) = child.stdin.take() {
1441 let written = stdin
1442 .write_all(stdin_content.as_bytes())
1443 .and_then(|()| stdin.flush());
1444 if let Err(source) = written {
1445 stop_and_reap_sync(&mut child, &mut group, kill_grace);
1446 log_partial(capture, "partial output from process with failed stdin");
1447 return Err(Error::Io {
1448 message: format!("failed to write to claude stdin: {source}"),
1449 source,
1450 working_dir: working_dir.map(|p| p.to_path_buf()),
1451 });
1452 }
1453 }
1455
1456 finish_sync_run(
1457 &mut child,
1458 &mut group,
1459 capture,
1460 SyncRun {
1461 binary,
1462 args,
1463 working_dir,
1464 deadline,
1465 kill_grace,
1466 output_limit,
1467 },
1468 )
1469}
1470
1471#[cfg(feature = "sync")]
1472fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1473 let command_args = full_command_args(claude, args);
1474
1475 let span = exec_span(claude, &command_args, "oneshot-sync");
1476 let _enter = span.enter();
1477 let started = std::time::Instant::now();
1478 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
1479
1480 let result = if let Some(timeout) = claude.timeout {
1481 run_with_deadline_sync(
1482 &claude.binary,
1483 &command_args,
1484 &claude.env,
1485 claude.clear_env,
1486 claude.working_dir.as_deref(),
1487 Some(timeout),
1488 SpawnPolicy::of(claude),
1489 )
1490 } else {
1491 run_internal_sync(
1492 &claude.binary,
1493 &command_args,
1494 &claude.env,
1495 claude.clear_env,
1496 claude.working_dir.as_deref(),
1497 SpawnPolicy::of(claude),
1498 )
1499 };
1500
1501 if let Ok(output) = &result {
1502 record_exec_outcome(&span, output.exit_code, started);
1503 }
1504 result
1505}
1506
1507#[cfg(feature = "sync")]
1509pub fn run_claude_allow_exit_codes_sync(
1510 claude: &Claude,
1511 args: Vec<String>,
1512 allowed_codes: &[i32],
1513) -> Result<CommandOutput> {
1514 match run_claude_sync(claude, args) {
1515 Err(Error::CommandFailed {
1516 exit_code,
1517 stdout,
1518 stderr,
1519 ..
1520 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
1521 stdout,
1522 stderr,
1523 exit_code,
1524 success: false,
1525 }),
1526 other => other,
1527 }
1528}
1529
1530#[cfg(feature = "sync")]
1537fn run_internal_sync(
1538 binary: &std::path::Path,
1539 args: &[String],
1540 env: &std::collections::HashMap<String, String>,
1541 clear_env: bool,
1542 working_dir: Option<&std::path::Path>,
1543 policy: SpawnPolicy<'_>,
1544) -> Result<CommandOutput> {
1545 run_with_deadline_sync(binary, args, env, clear_env, working_dir, None, policy)
1546}
1547
1548#[cfg(feature = "sync")]
1557fn run_with_deadline_sync(
1558 binary: &std::path::Path,
1559 args: &[String],
1560 env: &std::collections::HashMap<String, String>,
1561 clear_env: bool,
1562 working_dir: Option<&std::path::Path>,
1563 deadline: Option<Duration>,
1564 policy: SpawnPolicy<'_>,
1565) -> Result<CommandOutput> {
1566 let SpawnPolicy {
1567 process_group,
1568 kill_grace,
1569 output_limit,
1570 die_with_parent,
1571 on_spawn,
1572 } = policy;
1573 use std::process::{Command as StdCommand, Stdio};
1574
1575 let mut cmd = StdCommand::new(binary);
1576 cmd.args(args);
1577 cmd.stdin(Stdio::null());
1578 cmd.stdout(Stdio::piped());
1579 cmd.stderr(Stdio::piped());
1580 apply_process_group_sync(&mut cmd, process_group);
1584 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1585 apply_child_environment(&mut cmd, clear_env, env);
1586
1587 if let Some(dir) = working_dir {
1588 cmd.current_dir(dir);
1589 }
1590
1591 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1592 message: format!("failed to spawn claude: {e}"),
1593 source: e,
1594 working_dir: working_dir.map(|p| p.to_path_buf()),
1595 })?;
1596 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1597
1598 let capture = SyncCapture::start(
1599 child.stdout.take().expect("stdout was piped"),
1600 child.stderr.take().expect("stderr was piped"),
1601 output_limit,
1602 );
1603
1604 finish_sync_run(
1605 &mut child,
1606 &mut group,
1607 capture,
1608 SyncRun {
1609 binary,
1610 args,
1611 working_dir,
1612 deadline,
1613 kill_grace,
1614 output_limit,
1615 },
1616 )
1617}
1618
1619#[cfg(feature = "sync")]
1620fn capture_sync<R: std::io::Read>(
1621 mut reader: R,
1622 limit: Option<usize>,
1623) -> std::io::Result<Captured> {
1624 let Some(limit) = limit else {
1625 let mut buf = Vec::new();
1626 reader.read_to_end(&mut buf)?;
1627 return Ok(Captured::Complete(
1628 String::from_utf8_lossy(&buf).into_owned(),
1629 ));
1630 };
1631
1632 let mut buf = Vec::new();
1633 let mut chunk = [0u8; CAPTURE_CHUNK];
1634 loop {
1635 let read = reader.read(&mut chunk)?;
1636 if read == 0 {
1637 return Ok(Captured::Complete(
1638 String::from_utf8_lossy(&buf).into_owned(),
1639 ));
1640 }
1641 if buf.len() + read > limit {
1642 buf.truncate(BREACH_LOG_BYTES.min(buf.len()));
1643 return Ok(Captured::Exceeded {
1644 head: String::from_utf8_lossy(&buf).into_owned(),
1645 });
1646 }
1647 buf.extend_from_slice(&chunk[..read]);
1648 }
1649}
1650
1651#[cfg(feature = "sync")]
1659mod breach {
1660 pub(super) const NONE: u8 = 0;
1661 pub(super) const STDOUT: u8 = 1;
1662 pub(super) const STDERR: u8 = 2;
1663
1664 pub(super) fn stream(code: u8) -> Option<crate::OutputStream> {
1665 match code {
1666 STDOUT => Some(crate::OutputStream::Stdout),
1667 STDERR => Some(crate::OutputStream::Stderr),
1668 _ => None,
1669 }
1670 }
1671}
1672
1673#[cfg(feature = "sync")]
1678const BREACH_POLL_INTERVAL: Duration = Duration::from_millis(250);
1679
1680#[cfg(feature = "sync")]
1682struct SyncCapture {
1683 stdout: std::thread::JoinHandle<std::io::Result<Captured>>,
1684 stderr: std::thread::JoinHandle<std::io::Result<Captured>>,
1685 limit: Option<usize>,
1686 breached: std::sync::Arc<std::sync::atomic::AtomicU8>,
1687}
1688
1689#[cfg(feature = "sync")]
1690impl SyncCapture {
1691 fn start(
1696 stdout: std::process::ChildStdout,
1697 stderr: std::process::ChildStderr,
1698 limit: Option<usize>,
1699 ) -> Self {
1700 let breached = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(breach::NONE));
1701 Self {
1702 stdout: spawn_capture_thread(stdout, limit, breach::STDOUT, &breached),
1703 stderr: spawn_capture_thread(stderr, limit, breach::STDERR, &breached),
1704 limit,
1705 breached,
1706 }
1707 }
1708
1709 fn watch(&self) -> Option<&std::sync::atomic::AtomicU8> {
1712 self.limit.is_some().then(|| self.breached.as_ref())
1713 }
1714
1715 fn settle(self, working_dir: Option<&std::path::Path>) -> Result<(String, String)> {
1719 let limit = self.limit;
1720 let stdout = join_capture(self.stdout, crate::OutputStream::Stdout, limit, working_dir);
1721 let stderr = join_capture(self.stderr, crate::OutputStream::Stderr, limit, working_dir);
1722 Ok((stdout?, stderr?))
1723 }
1724
1725 fn partial(self, budget: Duration) -> (String, String) {
1729 let (stdout, stderr) = join_with_deadline(self.stdout, self.stderr, budget);
1730 (partial_text(stdout), partial_text(stderr))
1731 }
1732}
1733
1734#[cfg(feature = "sync")]
1735fn spawn_capture_thread<R: std::io::Read + Send + 'static>(
1736 reader: R,
1737 limit: Option<usize>,
1738 code: u8,
1739 breached: &std::sync::Arc<std::sync::atomic::AtomicU8>,
1740) -> std::thread::JoinHandle<std::io::Result<Captured>> {
1741 let breached = std::sync::Arc::clone(breached);
1742 std::thread::spawn(move || {
1743 let outcome = capture_sync(reader, limit);
1744 if matches!(outcome, Ok(Captured::Exceeded { .. })) {
1745 let _ = breached.compare_exchange(
1748 breach::NONE,
1749 code,
1750 std::sync::atomic::Ordering::AcqRel,
1751 std::sync::atomic::Ordering::Acquire,
1752 );
1753 }
1754 outcome
1755 })
1756}
1757
1758#[cfg(feature = "sync")]
1759fn join_capture(
1760 handle: std::thread::JoinHandle<std::io::Result<Captured>>,
1761 stream: crate::OutputStream,
1762 limit: Option<usize>,
1763 working_dir: Option<&std::path::Path>,
1764) -> Result<String> {
1765 match handle.join() {
1766 Ok(Ok(Captured::Complete(text))) => Ok(text),
1767 Ok(Ok(Captured::Exceeded { head })) => {
1768 let limit_bytes = limit.unwrap_or_default();
1769 warn!(
1770 %stream,
1771 limit_bytes,
1772 head = %head,
1773 "captured output exceeded its ceiling; terminating the child",
1774 );
1775 Err(Error::OutputLimitExceeded {
1776 stream,
1777 limit_bytes,
1778 })
1779 }
1780 Ok(Err(source)) => Err(Error::Io {
1781 message: format!("failed to read claude {stream}: {source}"),
1782 source,
1783 working_dir: working_dir.map(|p| p.to_path_buf()),
1784 }),
1785 Err(_) => Err(Error::Io {
1786 message: format!("claude {stream} capture thread panicked"),
1787 source: std::io::Error::other(format!("{stream} capture thread panicked")),
1788 working_dir: working_dir.map(|p| p.to_path_buf()),
1789 }),
1790 }
1791}
1792
1793#[cfg(feature = "sync")]
1794fn partial_text(outcome: Option<std::io::Result<Captured>>) -> String {
1795 match outcome {
1796 Some(Ok(Captured::Complete(text) | Captured::Exceeded { head: text })) => text,
1797 Some(Err(_)) | None => String::new(),
1798 }
1799}
1800
1801#[cfg(feature = "sync")]
1803enum SyncWait {
1804 Exited(std::process::ExitStatus),
1805 TimedOut,
1806 Breached(crate::OutputStream),
1809}
1810
1811#[cfg(feature = "sync")]
1814fn wait_for_child_sync(
1815 child: &mut std::process::Child,
1816 deadline: Option<Duration>,
1817 watch: Option<&std::sync::atomic::AtomicU8>,
1818) -> std::io::Result<SyncWait> {
1819 use std::sync::atomic::Ordering;
1820 use wait_timeout::ChildExt;
1821
1822 let Some(watch) = watch else {
1825 return Ok(match deadline {
1826 Some(deadline) => child
1827 .wait_timeout(deadline)?
1828 .map_or(SyncWait::TimedOut, SyncWait::Exited),
1829 None => SyncWait::Exited(child.wait()?),
1830 });
1831 };
1832
1833 let expiry = deadline.map(|d| std::time::Instant::now() + d);
1834 loop {
1835 if let Some(stream) = breach::stream(watch.load(Ordering::Acquire)) {
1836 return Ok(SyncWait::Breached(stream));
1837 }
1838 let mut tick = BREACH_POLL_INTERVAL;
1839 if let Some(expiry) = expiry {
1840 let remaining = expiry.saturating_duration_since(std::time::Instant::now());
1841 if remaining.is_zero() {
1842 return Ok(SyncWait::TimedOut);
1843 }
1844 tick = tick.min(remaining);
1845 }
1846 if let Some(status) = child.wait_timeout(tick)? {
1847 return Ok(SyncWait::Exited(status));
1848 }
1849 }
1850}
1851
1852#[cfg(feature = "sync")]
1856struct SyncRun<'a> {
1857 binary: &'a std::path::Path,
1858 args: &'a [String],
1859 working_dir: Option<&'a std::path::Path>,
1860 deadline: Option<Duration>,
1861 kill_grace: Option<Duration>,
1862 output_limit: Option<usize>,
1863}
1864
1865#[cfg(feature = "sync")]
1871fn finish_sync_run(
1872 child: &mut std::process::Child,
1873 group: &mut GroupKillGuard,
1874 capture: SyncCapture,
1875 run: SyncRun<'_>,
1876) -> Result<CommandOutput> {
1877 let waited = match wait_for_child_sync(child, run.deadline, capture.watch()) {
1878 Ok(waited) => waited,
1879 Err(source) => {
1880 stop_and_reap_sync(child, group, run.kill_grace);
1881 return Err(Error::Io {
1882 message: "failed to wait for claude process".to_string(),
1883 source,
1884 working_dir: run.working_dir.map(|p| p.to_path_buf()),
1885 });
1886 }
1887 };
1888
1889 match waited {
1890 SyncWait::Exited(status) => {
1891 group.disarm();
1892 let (stdout, stderr) = capture.settle(run.working_dir)?;
1896 let exit_code = status.code().unwrap_or(-1);
1897
1898 if !status.success() {
1899 return Err(Error::from_command_failure(
1900 format!("{} {}", run.binary.display(), run.args.join(" ")),
1901 exit_code,
1902 stdout,
1903 stderr,
1904 run.working_dir.map(|p| p.to_path_buf()),
1905 ));
1906 }
1907
1908 Ok(CommandOutput {
1909 stdout,
1910 stderr,
1911 exit_code,
1912 success: true,
1913 })
1914 }
1915 SyncWait::TimedOut => {
1919 stop_and_reap_sync(child, group, run.kill_grace);
1920 log_partial(capture, "partial output from timed-out process");
1921 Err(Error::Timeout {
1922 timeout_seconds: run.deadline.unwrap_or_default().as_secs(),
1923 })
1924 }
1925 SyncWait::Breached(stream) => {
1926 stop_and_reap_sync(child, group, run.kill_grace);
1927 log_partial(capture, "partial output from over-limit process");
1928 Err(Error::OutputLimitExceeded {
1929 stream,
1930 limit_bytes: run.output_limit.unwrap_or_default(),
1931 })
1932 }
1933 }
1934}
1935
1936#[cfg(feature = "sync")]
1937fn log_partial(capture: SyncCapture, message: &'static str) {
1938 let (stdout, stderr) = capture.partial(PARTIAL_CAPTURE_BUDGET);
1939 if !stdout.is_empty() || !stderr.is_empty() {
1940 warn!(stdout = %stdout, stderr = %stderr, "{message}");
1941 }
1942}
1943
1944#[cfg(feature = "sync")]
1947fn stop_and_reap_sync(
1948 child: &mut std::process::Child,
1949 group: &mut GroupKillGuard,
1950 grace: Option<Duration>,
1951) {
1952 kill_group_with_grace_sync(group, grace);
1953 let _ = child.kill();
1954 let _ = child.wait();
1955}
1956
1957#[cfg(feature = "sync")]
1960fn spawn_retrying_txtbsy_sync(
1961 cmd: &mut std::process::Command,
1962) -> std::io::Result<std::process::Child> {
1963 let start = std::time::Instant::now();
1964 let mut backoff = Duration::from_millis(1);
1965 loop {
1966 match cmd.spawn() {
1967 Err(e)
1968 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1969 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1970 {
1971 std::thread::sleep(backoff);
1972 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1973 }
1974 other => return other,
1975 }
1976 }
1977}
1978
1979#[cfg(feature = "sync")]
1987#[cfg(feature = "sync")]
1995fn join_with_deadline<T: Send + 'static>(
1996 stdout_thread: std::thread::JoinHandle<T>,
1997 stderr_thread: std::thread::JoinHandle<T>,
1998 budget: Duration,
1999) -> (Option<T>, Option<T>) {
2000 use std::sync::mpsc;
2001 use std::thread;
2002
2003 let (tx, rx) = mpsc::channel::<(&'static str, T)>();
2004
2005 let tx_out = tx.clone();
2006 let tx_err = tx;
2007
2008 thread::spawn(move || {
2009 if let Ok(value) = stdout_thread.join() {
2010 let _ = tx_out.send(("stdout", value));
2011 }
2012 });
2013 thread::spawn(move || {
2014 if let Ok(value) = stderr_thread.join() {
2015 let _ = tx_err.send(("stderr", value));
2016 }
2017 });
2018
2019 let mut stdout = None;
2020 let mut stderr = None;
2021 let deadline = std::time::Instant::now() + budget;
2022
2023 for _ in 0..2 {
2024 let now = std::time::Instant::now();
2025 if now >= deadline {
2026 break;
2027 }
2028 match rx.recv_timeout(deadline - now) {
2029 Ok(("stdout", value)) => stdout = Some(value),
2030 Ok(("stderr", value)) => stderr = Some(value),
2031 Ok(_) => unreachable!(),
2032 Err(_) => break,
2033 }
2034 }
2035
2036 (stdout, stderr)
2037}
2038
2039#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
2046mod tests {
2047 use super::*;
2048 use std::io::Write;
2049 use std::os::unix::fs::PermissionsExt;
2050
2051 use crate::Claude;
2052
2053 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
2057 let dir = tempfile::tempdir().expect("tempdir");
2058 let path = dir.path().join("fake-claude.sh");
2059 {
2064 let mut f = std::fs::File::create(&path).expect("create script");
2065 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
2066 f.sync_all().expect("sync script");
2067 }
2068 let perms = std::fs::Permissions::from_mode(0o755);
2069 std::fs::set_permissions(&path, perms).expect("chmod");
2070 (dir, path)
2071 }
2072
2073 fn client(path: &std::path::Path) -> Claude {
2074 Claude::builder()
2075 .binary(path)
2076 .build()
2077 .expect("build client")
2078 }
2079
2080 #[test]
2081 fn full_command_args_puts_global_args_first() {
2082 let claude = Claude::builder()
2083 .binary("/usr/local/bin/claude")
2084 .arg("--debug")
2085 .arg("--verbose")
2086 .build()
2087 .expect("build client");
2088 let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
2089 assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
2090 }
2091
2092 #[test]
2093 fn full_command_args_without_global_args_is_passthrough() {
2094 let claude = Claude::builder()
2095 .binary("/usr/local/bin/claude")
2096 .build()
2097 .expect("build client");
2098 let args = full_command_args(&claude, vec!["--print".to_string()]);
2099 assert_eq!(args, ["--print"]);
2100 }
2101
2102 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2104
2105 fn set_scrub_vars() {
2106 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2107 unsafe {
2110 std::env::set_var("CLAUDECODE", "1");
2111 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
2112 }
2113 }
2114
2115 fn clear_scrub_vars() {
2116 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2117 unsafe {
2119 std::env::remove_var("CLAUDECODE");
2120 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
2121 }
2122 }
2123
2124 #[cfg(feature = "async")]
2127 #[tokio::test]
2128 async fn async_success_maps_output() {
2129 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
2130 let out = run_claude(&client(&path), vec!["--version".into()])
2131 .await
2132 .expect("success");
2133 assert!(out.success);
2134 assert_eq!(out.exit_code, 0);
2135 assert!(out.stdout.contains("hi there"));
2136 }
2137
2138 #[cfg(feature = "async")]
2139 #[tokio::test]
2140 async fn async_nonzero_exit_maps_command_failed() {
2141 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2142 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2143 match err {
2144 Error::CommandFailed {
2145 exit_code, stderr, ..
2146 } => {
2147 assert_eq!(exit_code, 3);
2148 assert!(stderr.contains("boom"));
2149 }
2150 other => panic!("expected CommandFailed, got {other:?}"),
2151 }
2152 }
2153
2154 #[cfg(feature = "async")]
2155 #[tokio::test]
2156 async fn async_rail_stop_maps_max_turns() {
2157 let (_dir, path) = fake_script(
2158 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
2159 );
2160 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2161 assert!(
2162 matches!(
2163 err,
2164 Error::MaxTurnsExceeded {
2165 max_turns: Some(2),
2166 ..
2167 }
2168 ),
2169 "got: {err:?}"
2170 );
2171 }
2172
2173 #[cfg(feature = "async")]
2174 #[tokio::test]
2175 async fn async_auth_shaped_stderr_maps_auth() {
2176 let (_dir, path) =
2177 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
2178 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2179 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
2180 }
2181
2182 #[cfg(feature = "async")]
2183 #[tokio::test]
2184 async fn async_scrubs_claude_env_vars() {
2185 let (_dir, path) =
2186 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2187 set_scrub_vars();
2193 let out = run_claude(&client(&path), vec![]).await.expect("success");
2194 clear_scrub_vars();
2195 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2196 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2197 }
2198
2199 #[cfg(feature = "async")]
2200 #[tokio::test]
2201 async fn async_applies_working_dir() {
2202 let (_dir, path) = fake_script(r#"pwd"#);
2203 let workdir = tempfile::tempdir().expect("workdir");
2204 let claude = Claude::builder()
2205 .binary(&path)
2206 .working_dir(workdir.path())
2207 .build()
2208 .expect("build");
2209 let out = run_claude(&claude, vec![]).await.expect("success");
2210 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
2211 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
2212 assert_eq!(got, want);
2213 }
2214
2215 #[cfg(feature = "async")]
2216 #[tokio::test]
2217 async fn async_stdin_prompt_round_trips() {
2218 let (_dir, path) = fake_script(r#"cat"#);
2219 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
2220 .await
2221 .expect("success");
2222 assert!(out.stdout.contains("hello via stdin"));
2223 }
2224
2225 #[cfg(feature = "async")]
2230 #[tokio::test]
2231 async fn async_spawn_retry_passes_through_non_txtbsy_error() {
2232 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
2233 let err = spawn_retrying_txtbsy(&mut cmd)
2234 .await
2235 .expect_err("spawn of missing binary should fail");
2236 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2237 }
2238
2239 #[cfg(feature = "async")]
2240 #[tokio::test]
2241 async fn async_allow_exit_codes_permits_listed_code() {
2242 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2243 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
2244 .await
2245 .expect("allowed code is Ok");
2246 assert!(!out.success);
2247 assert_eq!(out.exit_code, 2);
2248 assert!(out.stdout.contains("out"));
2249 }
2250
2251 #[cfg(feature = "async")]
2252 #[tokio::test]
2253 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
2254 let (_dir, path) = fake_script(r#"exit 2"#);
2255 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
2256 .await
2257 .unwrap_err();
2258 assert!(
2259 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
2260 "got: {err:?}"
2261 );
2262 }
2263
2264 #[cfg(feature = "async")]
2265 #[tokio::test]
2266 async fn async_timeout_fires_on_slow_child() {
2267 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2268 let claude = Claude::builder()
2269 .binary(&path)
2270 .timeout(Duration::from_millis(300))
2271 .build()
2272 .expect("build");
2273 let err = run_claude(&claude, vec![]).await.unwrap_err();
2274 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2275 }
2276
2277 #[cfg(feature = "async")]
2278 #[tokio::test]
2279 async fn async_timeout_path_returns_output_when_fast() {
2280 let (_dir, path) = fake_script(r#"echo quick"#);
2281 let claude = Claude::builder()
2282 .binary(&path)
2283 .timeout(Duration::from_secs(30))
2284 .build()
2285 .expect("build");
2286 let out = run_claude(&claude, vec![]).await.expect("success");
2287 assert!(out.stdout.contains("quick"));
2288 }
2289
2290 #[cfg(feature = "async")]
2291 #[tokio::test]
2292 async fn async_timeout_path_maps_command_failed() {
2293 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
2294 let claude = Claude::builder()
2295 .binary(&path)
2296 .timeout(Duration::from_secs(30))
2297 .build()
2298 .expect("build");
2299 let err = run_claude(&claude, vec![]).await.unwrap_err();
2300 assert!(
2301 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
2302 "got: {err:?}"
2303 );
2304 }
2305
2306 #[cfg(feature = "async")]
2307 #[tokio::test]
2308 async fn async_stdin_with_timeout_round_trips() {
2309 let (_dir, path) = fake_script(r#"cat"#);
2310 let claude = Claude::builder()
2311 .binary(&path)
2312 .timeout(Duration::from_secs(30))
2313 .build()
2314 .expect("build");
2315 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
2316 .await
2317 .expect("success");
2318 assert!(out.stdout.contains("piped under timeout"));
2319 }
2320
2321 #[cfg(feature = "async")]
2322 #[tokio::test]
2323 async fn async_stdin_timeout_fires_on_slow_child() {
2324 let (_dir, path) = fake_script(r#"sleep 3"#);
2325 let claude = Claude::builder()
2326 .binary(&path)
2327 .timeout(Duration::from_millis(300))
2328 .build()
2329 .expect("build");
2330 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
2331 .await
2332 .unwrap_err();
2333 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2334 }
2335
2336 #[cfg(feature = "async")]
2339 async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
2340 where
2341 F: std::future::Future,
2342 F::Output: std::fmt::Debug,
2343 {
2344 tokio::pin!(fut);
2345 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2346 loop {
2347 if let Some(pid) = std::fs::read_to_string(pid_path)
2348 .ok()
2349 .and_then(|s| s.trim().parse().ok())
2350 {
2351 return pid;
2353 }
2354 assert!(
2355 std::time::Instant::now() < deadline,
2356 "child never wrote its pid file"
2357 );
2358 tokio::select! {
2359 out = &mut fut => panic!("future completed before drop: {out:?}"),
2360 _ = tokio::time::sleep(Duration::from_millis(10)) => {}
2361 }
2362 }
2363 }
2364
2365 fn assert_pid_killed(pid: u32) {
2371 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2372 loop {
2373 let out = std::process::Command::new("ps")
2374 .args(["-o", "stat=", "-p", &pid.to_string()])
2375 .output()
2376 .expect("run ps");
2377 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2378 if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
2379 return;
2380 }
2381 assert!(
2382 std::time::Instant::now() < deadline,
2383 "process {pid} still alive (stat {stat}) after kill"
2384 );
2385 std::thread::sleep(Duration::from_millis(25));
2386 }
2387 }
2388
2389 #[cfg(feature = "async")]
2390 fn assert_pid_not_live_now(pid: u32) {
2391 let out = std::process::Command::new("ps")
2392 .args(["-o", "stat=", "-p", &pid.to_string()])
2393 .output()
2394 .expect("run ps");
2395 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2396 assert!(
2397 !out.status.success() || stat.is_empty() || stat.starts_with('Z'),
2398 "process {pid} still live after terminal settlement (stat {stat})"
2399 );
2400 }
2401
2402 fn group_script(
2410 pid_path: &std::path::Path,
2411 gpid_path: &std::path::Path,
2412 ) -> (tempfile::TempDir, std::path::PathBuf) {
2413 fake_script(&format!(
2418 concat!(
2419 "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2420 "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2421 "echo $$ > \"{p}\"\n",
2422 "exec sleep 300",
2423 ),
2424 g = gpid_path.display(),
2425 p = pid_path.display(),
2426 ))
2427 }
2428
2429 fn spew_script(stream: &str, bytes: usize) -> (tempfile::TempDir, std::path::PathBuf) {
2432 let redirect = if stream == "stderr" { ">&2" } else { "" };
2433 fake_script(&format!(
2434 "head -c {bytes} /dev/zero | tr '\\0' 'x' {redirect}\nexit 0"
2435 ))
2436 }
2437
2438 fn limited_client(path: &std::path::Path, limit: usize) -> Claude {
2439 Claude::builder()
2440 .binary(path)
2441 .output_limit(limit)
2442 .build()
2443 .expect("build client")
2444 }
2445
2446 fn assert_limit_error(error: &Error, want_stream: crate::OutputStream, want_limit: usize) {
2447 match error {
2448 Error::OutputLimitExceeded {
2449 stream,
2450 limit_bytes,
2451 } => {
2452 assert_eq!(*stream, want_stream);
2453 assert_eq!(*limit_bytes, want_limit);
2454 }
2455 other => panic!("expected OutputLimitExceeded, got: {other:?}"),
2456 }
2457 }
2458
2459 #[cfg(feature = "async")]
2460 #[tokio::test]
2461 async fn async_stdout_over_limit_is_typed_not_truncated() {
2462 let (_dir, path) = spew_script("stdout", 64 * 1024);
2463 let error = run_claude(&limited_client(&path, 4096), vec![])
2464 .await
2465 .expect_err("over-limit stdout must fail");
2466 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2467 }
2468
2469 #[cfg(feature = "async")]
2470 #[tokio::test]
2471 async fn async_stderr_over_limit_names_stderr() {
2472 let (_dir, path) = spew_script("stderr", 64 * 1024);
2473 let error = run_claude(&limited_client(&path, 4096), vec![])
2474 .await
2475 .expect_err("over-limit stderr must fail");
2476 assert_limit_error(&error, crate::OutputStream::Stderr, 4096);
2477 }
2478
2479 #[cfg(feature = "async")]
2482 #[tokio::test]
2483 async fn async_output_at_limit_succeeds() {
2484 let (_dir, path) = fake_script(r#"printf 'abcde'"#);
2485 let out = run_claude(&limited_client(&path, 5), vec![])
2486 .await
2487 .expect("output at the ceiling is complete");
2488 assert_eq!(out.stdout, "abcde");
2489 }
2490
2491 #[cfg(feature = "async")]
2492 #[tokio::test]
2493 async fn async_unset_limit_leaves_large_output_intact() {
2494 let (_dir, path) = spew_script("stdout", 256 * 1024);
2495 let out = run_claude(&client(&path), vec![])
2496 .await
2497 .expect("no ceiling means no failure");
2498 assert_eq!(out.stdout.len(), 256 * 1024);
2499 }
2500
2501 #[cfg(feature = "async")]
2502 #[tokio::test]
2503 async fn async_stdin_prompt_path_honors_limit() {
2504 let (_dir, path) = spew_script("stdout", 64 * 1024);
2505 let error = run_claude_with_stdin_prompt_sync_free(&limited_client(&path, 4096))
2506 .await
2507 .expect_err("over-limit stdout must fail on the stdin path");
2508 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2509 }
2510
2511 #[cfg(feature = "async")]
2512 async fn run_claude_with_stdin_prompt_sync_free(claude: &Claude) -> Result<CommandOutput> {
2513 run_claude_with_stdin_prompt(claude, vec![], "prompt".to_string()).await
2514 }
2515
2516 #[cfg(feature = "async")]
2520 #[tokio::test]
2521 async fn async_limit_breach_kills_process_group() {
2522 let workdir = tempfile::tempdir().expect("workdir");
2523 let pid_path = workdir.path().join("pid");
2524 let gpid_path = workdir.path().join("gpid");
2525 let (_dir, path) = fake_script(&format!(
2526 concat!(
2527 "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2528 "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2529 "echo $$ > \"{p}\"\n",
2530 "head -c 65536 /dev/zero | tr '\\0' 'x'\n",
2531 "exec sleep 300",
2532 ),
2533 g = gpid_path.display(),
2534 p = pid_path.display(),
2535 ));
2536 let error = run_claude(&limited_client(&path, 4096), vec![])
2537 .await
2538 .expect_err("over-limit stdout must fail");
2539 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2540
2541 let pid = try_read_pid(&pid_path).expect("child recorded its pid");
2542 let gpid = try_read_pid(&gpid_path).expect("grandchild recorded its pid");
2543 assert_pid_killed(pid);
2544 assert_pid_killed(gpid);
2545 }
2546
2547 #[cfg(feature = "sync")]
2548 #[test]
2549 fn sync_stdout_over_limit_is_typed_not_truncated() {
2550 let (_dir, path) = spew_script("stdout", 64 * 1024);
2551 let error = run_claude_sync(&limited_client(&path, 4096), vec![])
2552 .expect_err("over-limit stdout must fail");
2553 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2554 }
2555
2556 #[cfg(feature = "sync")]
2557 #[test]
2558 fn sync_stderr_over_limit_names_stderr() {
2559 let (_dir, path) = spew_script("stderr", 64 * 1024);
2560 let error = run_claude_sync(&limited_client(&path, 4096), vec![])
2561 .expect_err("over-limit stderr must fail");
2562 assert_limit_error(&error, crate::OutputStream::Stderr, 4096);
2563 }
2564
2565 #[cfg(feature = "sync")]
2566 #[test]
2567 fn sync_output_at_limit_succeeds() {
2568 let (_dir, path) = fake_script(r#"printf 'abcde'"#);
2569 let out = run_claude_sync(&limited_client(&path, 5), vec![])
2570 .expect("output at the ceiling is complete");
2571 assert_eq!(out.stdout, "abcde");
2572 }
2573
2574 #[cfg(feature = "sync")]
2575 #[test]
2576 fn sync_unset_limit_leaves_large_output_intact() {
2577 let (_dir, path) = spew_script("stdout", 256 * 1024);
2578 let out = run_claude_sync(&client(&path), vec![]).expect("no ceiling means no failure");
2579 assert_eq!(out.stdout.len(), 256 * 1024);
2580 }
2581
2582 #[cfg(feature = "sync")]
2583 #[test]
2584 fn sync_stdin_prompt_path_honors_limit() {
2585 let (_dir, path) = spew_script("stdout", 64 * 1024);
2586 let error =
2587 run_claude_with_stdin_prompt_sync(&limited_client(&path, 4096), vec![], "p".into())
2588 .expect_err("over-limit stdout must fail on the stdin path");
2589 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2590 }
2591
2592 #[cfg(feature = "sync")]
2596 #[test]
2597 fn sync_limit_breach_terminates_a_child_with_no_deadline() {
2598 let (_dir, path) = fake_script(concat!(
2599 "head -c 65536 /dev/zero | tr '\\0' 'x'\n",
2600 "exec sleep 300",
2601 ));
2602 let started = std::time::Instant::now();
2603 let error = run_claude_sync(&limited_client(&path, 4096), vec![])
2604 .expect_err("over-limit stdout must fail");
2605 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2606 assert!(
2607 started.elapsed() < Duration::from_secs(30),
2608 "breach must not wait out the child, took {:?}",
2609 started.elapsed(),
2610 );
2611 }
2612
2613 fn try_read_pid(path: &std::path::Path) -> Option<u32> {
2615 std::fs::read_to_string(path).ok()?.trim().parse().ok()
2616 }
2617
2618 #[cfg(feature = "async")]
2622 fn read_pid(path: &std::path::Path) -> u32 {
2623 try_read_pid(path).expect("pid file readable")
2624 }
2625
2626 #[cfg(feature = "async")]
2632 #[tokio::test]
2633 async fn async_dropping_in_flight_future_kills_child() {
2634 let workdir = tempfile::tempdir().expect("workdir");
2635 let pid_path = workdir.path().join("pid");
2636 let (_dir, path) = fake_script(&format!(
2637 r#"echo $$ > "{}"; exec sleep 30"#,
2638 pid_path.display()
2639 ));
2640 let claude = client(&path);
2641 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2642 assert_pid_killed(pid);
2643 }
2644
2645 #[cfg(feature = "async")]
2650 #[tokio::test]
2651 async fn async_dropping_in_flight_future_kills_child_with_timeout() {
2652 let workdir = tempfile::tempdir().expect("workdir");
2653 let pid_path = workdir.path().join("pid");
2654 let (_dir, path) = fake_script(&format!(
2655 r#"echo $$ > "{}"; exec sleep 30"#,
2656 pid_path.display()
2657 ));
2658 let claude = Claude::builder()
2659 .binary(&path)
2660 .timeout(Duration::from_secs(120))
2661 .build()
2662 .expect("build");
2663 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2664 assert_pid_killed(pid);
2665 }
2666
2667 #[cfg(feature = "async")]
2671 #[tokio::test]
2672 async fn async_dropping_in_flight_future_kills_process_group() {
2673 let workdir = tempfile::tempdir().expect("workdir");
2674 let pid_path = workdir.path().join("pid");
2675 let gpid_path = workdir.path().join("gpid");
2676 let (_dir, path) = group_script(&pid_path, &gpid_path);
2677 let claude = client(&path);
2678 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2679 assert_pid_killed(pid);
2680 assert_pid_killed(read_pid(&gpid_path));
2681 }
2682
2683 #[cfg(feature = "async")]
2689 #[tokio::test]
2690 async fn async_timeout_kills_process_group() {
2691 let mut observed = false;
2692 for _ in 0..5 {
2693 let workdir = tempfile::tempdir().expect("workdir");
2694 let pid_path = workdir.path().join("pid");
2695 let gpid_path = workdir.path().join("gpid");
2696 let (_dir, path) = group_script(&pid_path, &gpid_path);
2697 let claude = Claude::builder()
2698 .binary(&path)
2699 .timeout(Duration::from_millis(1000))
2700 .build()
2701 .expect("build");
2702 let err = run_claude(&claude, vec![]).await.unwrap_err();
2703 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2704 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2705 assert_pid_killed(pid);
2706 assert_pid_killed(gpid);
2707 observed = true;
2708 break;
2709 }
2710 }
2711 assert!(observed, "child never recorded pids within 5 timeout runs");
2712 }
2713
2714 #[cfg(feature = "async")]
2715 #[tokio::test]
2716 async fn async_explicit_cancellation_settles_process_group() {
2717 let workdir = tempfile::tempdir().expect("workdir");
2718 let pid_path = workdir.path().join("pid");
2719 let gpid_path = workdir.path().join("gpid");
2720 let (_dir, path) = group_script(&pid_path, &gpid_path);
2721 let claude = Claude::builder()
2722 .binary(&path)
2723 .kill_grace(Duration::from_millis(10))
2724 .build()
2725 .expect("build");
2726 let cancel = async {
2727 while !pid_path.exists() || !gpid_path.exists() {
2728 tokio::time::sleep(Duration::from_millis(10)).await;
2729 }
2730 };
2731
2732 let error = run_claude_cancellable(&claude, vec![], cancel)
2733 .await
2734 .expect_err("run must be cancelled");
2735 assert!(matches!(error, Error::Cancelled));
2736 assert_pid_not_live_now(read_pid(&pid_path));
2737 assert_pid_not_live_now(read_pid(&gpid_path));
2738 }
2739
2740 #[cfg(feature = "async")]
2741 #[tokio::test]
2742 async fn async_stdin_failure_settles_process_group() {
2743 let workdir = tempfile::tempdir().expect("workdir");
2744 let pid_path = workdir.path().join("pid");
2745 let gpid_path = workdir.path().join("gpid");
2746 let (_dir, path) = fake_script(&format!(
2747 concat!(
2748 "sleep 300 </dev/null &\n",
2749 "child=$!\n",
2750 "echo $$ > '{p}'\n",
2751 "echo $child > '{g}'\n",
2752 "exec 0<&-\n",
2753 "wait $child",
2754 ),
2755 p = pid_path.display(),
2756 g = gpid_path.display(),
2757 ));
2758 let claude = Claude::builder()
2759 .binary(&path)
2760 .kill_grace(Duration::from_millis(10))
2761 .build()
2762 .expect("build");
2763
2764 let error = run_claude_with_stdin_prompt(&claude, vec![], "x".repeat(4 * 1024 * 1024))
2765 .await
2766 .expect_err("stdin write must fail");
2767 assert!(matches!(error, Error::Io { .. }));
2768 assert_pid_not_live_now(read_pid(&pid_path));
2769 assert_pid_not_live_now(read_pid(&gpid_path));
2770 }
2771
2772 #[cfg(feature = "async")]
2778 #[tokio::test]
2779 async fn async_process_group_opt_out_kills_only_direct_child() {
2780 let workdir = tempfile::tempdir().expect("workdir");
2781 let pid_path = workdir.path().join("pid");
2782 let gpid_path = workdir.path().join("gpid");
2783 let (_dir, path) = group_script(&pid_path, &gpid_path);
2784 let claude = Claude::builder()
2785 .binary(&path)
2786 .process_group(false)
2787 .build()
2788 .expect("build");
2789 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2790 assert_pid_killed(pid);
2791
2792 let gpid = read_pid(&gpid_path);
2794 let out = std::process::Command::new("ps")
2795 .args(["-o", "stat=", "-p", &gpid.to_string()])
2796 .output()
2797 .expect("run ps");
2798 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2799 assert!(
2800 out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
2801 "grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
2802 );
2803
2804 let _ = std::process::Command::new("kill")
2806 .args(["-9", &gpid.to_string()])
2807 .status();
2808 }
2809
2810 fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
2816 fake_script(&format!(
2817 concat!(
2818 "trap 'echo term > \"{m}\"; exit 0' TERM\n",
2819 "sleep 300 &\n",
2820 "wait $!",
2821 ),
2822 m = marker.display(),
2823 ))
2824 }
2825
2826 #[cfg(feature = "async")]
2832 #[tokio::test]
2833 async fn async_timeout_with_grace_delivers_sigterm_first() {
2834 let mut observed = false;
2835 for _ in 0..5 {
2836 let workdir = tempfile::tempdir().expect("workdir");
2837 let marker = workdir.path().join("term-marker");
2838 let (_dir, path) = term_trap_script(&marker);
2839 let claude = Claude::builder()
2840 .binary(&path)
2841 .timeout(Duration::from_millis(500))
2842 .kill_grace(Duration::from_secs(1))
2843 .build()
2844 .expect("build");
2845 let err = run_claude(&claude, vec![]).await.unwrap_err();
2846 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2847 if marker.exists() {
2848 observed = true;
2849 break;
2850 }
2851 }
2852 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2853 }
2854
2855 #[cfg(feature = "sync")]
2857 #[test]
2858 fn sync_timeout_with_grace_delivers_sigterm_first() {
2859 let mut observed = false;
2860 for _ in 0..5 {
2861 let workdir = tempfile::tempdir().expect("workdir");
2862 let marker = workdir.path().join("term-marker");
2863 let (_dir, path) = term_trap_script(&marker);
2864 let claude = Claude::builder()
2865 .binary(&path)
2866 .timeout(Duration::from_millis(500))
2867 .kill_grace(Duration::from_secs(1))
2868 .build()
2869 .expect("build");
2870 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2871 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2872 if marker.exists() {
2873 observed = true;
2874 break;
2875 }
2876 }
2877 assert!(observed, "TERM marker never appeared within 5 timeout runs");
2878 }
2879
2880 #[cfg(feature = "async")]
2882 #[tokio::test]
2883 async fn async_dropping_in_flight_stdin_future_kills_child() {
2884 let workdir = tempfile::tempdir().expect("workdir");
2885 let pid_path = workdir.path().join("pid");
2886 let (_dir, path) = fake_script(&format!(
2887 r#"echo $$ > "{}"; exec sleep 30"#,
2888 pid_path.display()
2889 ));
2890 let claude = client(&path);
2891 let pid = drop_in_flight_and_capture_pid(
2892 run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
2893 &pid_path,
2894 )
2895 .await;
2896 assert_pid_killed(pid);
2897 }
2898
2899 #[cfg(feature = "async")]
2900 #[tokio::test]
2901 async fn async_spawn_failure_maps_io() {
2902 let claude = Claude::builder()
2903 .binary("/nonexistent/definitely/not/here")
2904 .build()
2905 .expect("build");
2906 let err = run_claude(&claude, vec![]).await.unwrap_err();
2907 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
2908 }
2909
2910 #[cfg(feature = "sync")]
2913 #[test]
2914 fn sync_success_maps_output() {
2915 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
2916 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2917 assert!(out.success);
2918 assert!(out.stdout.contains("hi sync"));
2919 }
2920
2921 #[cfg(feature = "sync")]
2922 #[test]
2923 fn sync_nonzero_exit_maps_command_failed() {
2924 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2925 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
2926 match err {
2927 Error::CommandFailed {
2928 exit_code, stderr, ..
2929 } => {
2930 assert_eq!(exit_code, 3);
2931 assert!(stderr.contains("boom"));
2932 }
2933 other => panic!("expected CommandFailed, got {other:?}"),
2934 }
2935 }
2936
2937 #[cfg(feature = "sync")]
2938 #[test]
2939 fn sync_scrubs_claude_env_vars() {
2940 let (_dir, path) =
2941 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2942 set_scrub_vars();
2943 let out = run_claude_sync(&client(&path), vec![]).expect("success");
2944 clear_scrub_vars();
2945 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2946 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2947 }
2948
2949 #[cfg(feature = "sync")]
2950 #[test]
2951 fn sync_stdin_prompt_round_trips() {
2952 let (_dir, path) = fake_script(r#"cat"#);
2953 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
2954 .expect("success");
2955 assert!(out.stdout.contains("sync stdin"));
2956 }
2957
2958 #[cfg(feature = "sync")]
2961 #[test]
2962 fn sync_spawn_retry_passes_through_non_txtbsy_error() {
2963 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2964 let err =
2965 spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
2966 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2967 }
2968
2969 #[cfg(feature = "sync")]
2970 #[test]
2971 fn sync_allow_exit_codes_permits_listed_code() {
2972 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2973 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
2974 .expect("allowed code is Ok");
2975 assert!(!out.success);
2976 assert_eq!(out.exit_code, 2);
2977 }
2978
2979 #[cfg(feature = "sync")]
2980 #[test]
2981 fn sync_timeout_fires_on_slow_child() {
2982 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2983 let claude = Claude::builder()
2984 .binary(&path)
2985 .timeout(Duration::from_millis(300))
2986 .build()
2987 .expect("build");
2988 let err = run_claude_sync(&claude, vec![]).unwrap_err();
2989 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2990 }
2991
2992 #[cfg(feature = "sync")]
2996 #[test]
2997 fn sync_timeout_kills_process_group() {
2998 let mut observed = false;
2999 for _ in 0..5 {
3000 let workdir = tempfile::tempdir().expect("workdir");
3001 let pid_path = workdir.path().join("pid");
3002 let gpid_path = workdir.path().join("gpid");
3003 let (_dir, path) = group_script(&pid_path, &gpid_path);
3004 let claude = Claude::builder()
3005 .binary(&path)
3006 .timeout(Duration::from_millis(1000))
3007 .build()
3008 .expect("build");
3009 let err = run_claude_sync(&claude, vec![]).unwrap_err();
3010 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
3011 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
3012 assert_pid_killed(pid);
3013 assert_pid_killed(gpid);
3014 observed = true;
3015 break;
3016 }
3017 }
3018 assert!(observed, "child never recorded pids within 5 timeout runs");
3019 }
3020
3021 #[cfg(feature = "sync")]
3022 #[test]
3023 fn sync_timeout_path_returns_output_when_fast() {
3024 let (_dir, path) = fake_script(r#"echo quick"#);
3025 let claude = Claude::builder()
3026 .binary(&path)
3027 .timeout(Duration::from_secs(30))
3028 .build()
3029 .expect("build");
3030 let out = run_claude_sync(&claude, vec![]).expect("success");
3031 assert!(out.stdout.contains("quick"));
3032 }
3033
3034 #[cfg(feature = "sync")]
3035 #[test]
3036 fn sync_stdin_with_timeout_round_trips() {
3037 let (_dir, path) = fake_script(r#"cat"#);
3038 let claude = Claude::builder()
3039 .binary(&path)
3040 .timeout(Duration::from_secs(30))
3041 .build()
3042 .expect("build");
3043 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
3044 .expect("success");
3045 assert!(out.stdout.contains("sync piped"));
3046 }
3047}