1#[cfg(any(feature = "async", feature = "sync"))]
29use std::time::Duration;
30
31#[cfg(feature = "async")]
32use tokio::io::AsyncReadExt;
33#[cfg(feature = "async")]
34use tokio::process::Command;
35#[cfg(any(feature = "async", feature = "sync"))]
36use tracing::{debug, warn};
37
38use crate::Claude;
39#[cfg(any(feature = "async", feature = "sync"))]
40use crate::error::{Error, Result};
41
42pub(crate) fn full_command_args(claude: &Claude, args: Vec<String>) -> Vec<String> {
49 let mut command_args = claude.global_args.clone();
50 command_args.extend(args);
51 command_args
52}
53
54#[cfg(any(feature = "async", feature = "sync"))]
61pub(crate) fn apply_child_environment(
62 cmd: &mut std::process::Command,
63 clear_env: bool,
64 env: &std::collections::HashMap<String, String>,
65) {
66 if clear_env {
67 cmd.env_clear();
68 }
69 cmd.env_remove("CLAUDECODE");
70 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
71 cmd.envs(env);
72}
73
74#[cfg(any(feature = "async", feature = "sync"))]
76pub(crate) fn span_command(args: &[String]) -> &'static str {
77 match args.first().map(String::as_str) {
78 None => "<none>",
79 Some("--print" | "-p") => "print",
80 Some("mcp") => "mcp",
81 Some("plugin") => "plugin",
82 Some("auth") => "auth",
83 Some("agents") => "agents",
84 Some("auto-mode") => "auto-mode",
85 Some("doctor") => "doctor",
86 Some("install") => "install",
87 Some("marketplace") => "marketplace",
88 Some("update") => "update",
89 Some("setup-token") => "setup-token",
90 Some("project") => "project",
91 Some("ultrareview") => "ultrareview",
92 Some("--version" | "-v") => "version",
93 Some(_) => "other",
94 }
95}
96
97#[cfg(any(feature = "async", feature = "sync"))]
103pub(crate) fn exec_span(claude: &Claude, args: &[String], mode: &'static str) -> tracing::Span {
104 tracing::debug_span!(
105 "claude.exec",
106 command = span_command(args),
107 mode,
108 arg_count = args.len(),
109 working_dir_set = claude.working_dir.is_some(),
110 exit_code = tracing::field::Empty,
111 duration_ms = tracing::field::Empty,
112 )
113}
114
115#[cfg(any(feature = "async", feature = "sync"))]
117pub(crate) fn record_exec_outcome(
118 span: &tracing::Span,
119 exit_code: i32,
120 started: std::time::Instant,
121) {
122 span.record("exit_code", exit_code);
123 span.record("duration_ms", started.elapsed().as_millis() as u64);
124}
125
126#[derive(Debug, Clone)]
128pub struct CommandOutput {
129 pub stdout: String,
131 pub stderr: String,
133 pub exit_code: i32,
135 pub success: bool,
137}
138
139#[cfg(any(feature = "async", feature = "sync"))]
168#[derive(Clone, Copy)]
169pub(crate) struct SpawnPolicy<'a> {
170 pub(crate) process_group: bool,
171 pub(crate) kill_grace: Option<Duration>,
172 pub(crate) termination_signal: crate::TerminationSignal,
173 pub(crate) output_limit: Option<usize>,
174 pub(crate) die_with_parent: bool,
175 pub(crate) on_spawn: Option<&'a crate::SpawnObserver>,
176}
177
178#[cfg(any(feature = "async", feature = "sync"))]
179impl SpawnPolicy<'_> {
180 pub(crate) fn of(claude: &Claude) -> SpawnPolicy<'_> {
182 SpawnPolicy {
183 process_group: claude.process_group,
184 kill_grace: claude.kill_grace,
185 termination_signal: claude.termination_signal,
186 output_limit: claude.output_limit,
187 die_with_parent: claude.die_with_parent,
188 on_spawn: claude.on_spawn.as_ref(),
189 }
190 }
191}
192
193#[cfg(any(feature = "async", feature = "sync"))]
194pub(crate) fn arm_and_notify(
195 process_group: bool,
196 pid: Option<u32>,
197 on_spawn: Option<&crate::SpawnObserver>,
198) -> GroupKillGuard {
199 if let (Some(pid), Some(observer)) = (pid, on_spawn) {
200 observer(crate::SpawnInfo {
201 pid,
202 pgid: process_group.then_some(pid),
203 });
204 }
205 GroupKillGuard::new_if(process_group, pid)
206}
207
208#[cfg(any(feature = "async", feature = "sync"))]
209pub(crate) struct GroupKillGuard {
210 #[cfg(unix)]
211 pgid: Option<i32>,
212}
213
214#[cfg(any(feature = "async", feature = "sync"))]
215impl GroupKillGuard {
216 pub(crate) fn new_if(enabled: bool, pid: Option<u32>) -> Self {
221 Self::new(if enabled { pid } else { None })
222 }
223
224 pub(crate) fn new(pid: Option<u32>) -> Self {
228 #[cfg(unix)]
229 {
230 Self {
231 pgid: pid.and_then(|p| i32::try_from(p).ok()),
232 }
233 }
234 #[cfg(not(unix))]
235 {
236 let _ = pid;
237 Self {}
238 }
239 }
240
241 pub(crate) fn disarm(&mut self) {
244 #[cfg(unix)]
245 {
246 self.pgid = None;
247 }
248 }
249
250 pub(crate) fn is_armed(&self) -> bool {
252 #[cfg(unix)]
253 {
254 self.pgid.is_some()
255 }
256 #[cfg(not(unix))]
257 {
258 false
259 }
260 }
261
262 pub(crate) fn signal_now(&self, signal: crate::TerminationSignal) {
265 #[cfg(not(unix))]
266 let _ = signal;
267 #[cfg(unix)]
268 if let Some(pgid) = self.pgid {
269 let signal = match signal {
272 crate::TerminationSignal::Terminate => libc::SIGTERM,
273 crate::TerminationSignal::Interrupt => libc::SIGINT,
274 };
275 let _ = unsafe { libc::killpg(pgid, signal) };
276 }
277 }
278
279 pub(crate) fn kill_now(&mut self) {
281 #[cfg(unix)]
282 if let Some(pgid) = self.pgid.take() {
283 let _ = unsafe { libc::killpg(pgid, libc::SIGKILL) };
286 }
287 }
288}
289
290#[cfg(any(feature = "async", feature = "sync"))]
291impl Drop for GroupKillGuard {
292 fn drop(&mut self) {
293 self.kill_now();
294 }
295}
296
297#[must_use]
306pub const fn die_with_parent_supported() -> bool {
307 cfg!(target_os = "linux")
308}
309
310#[cfg(all(unix, any(feature = "async", feature = "sync")))]
326fn pdeathsig_hook() -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static {
327 let parent = std::process::id();
330 move || {
331 #[cfg(target_os = "linux")]
332 {
333 unsafe {
335 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
336 return Err(std::io::Error::last_os_error());
337 }
338 if libc::getppid() as u32 != parent {
340 libc::_exit(1);
341 }
342 }
343 }
344 #[cfg(not(target_os = "linux"))]
345 {
346 let _ = parent;
347 }
348 Ok(())
349 }
350}
351
352#[cfg(feature = "async")]
355pub(crate) fn apply_die_with_parent(cmd: &mut Command, enabled: bool) {
356 #[cfg(unix)]
357 if enabled {
358 unsafe {
360 cmd.pre_exec(pdeathsig_hook());
361 }
362 }
363 #[cfg(not(unix))]
364 {
365 let _ = (cmd, enabled);
366 }
367}
368
369#[cfg(feature = "sync")]
371pub(crate) fn apply_die_with_parent_sync(cmd: &mut std::process::Command, enabled: bool) {
372 #[cfg(unix)]
373 if enabled {
374 use std::os::unix::process::CommandExt;
375 unsafe {
377 cmd.pre_exec(pdeathsig_hook());
378 }
379 }
380 #[cfg(not(unix))]
381 {
382 let _ = (cmd, enabled);
383 }
384}
385
386#[cfg(feature = "async")]
390pub(crate) fn apply_process_group(cmd: &mut Command, enabled: bool) {
391 #[cfg(unix)]
392 if enabled {
393 cmd.process_group(0);
394 }
395 #[cfg(not(unix))]
396 {
397 let _ = (cmd, enabled);
398 }
399}
400
401#[cfg(feature = "sync")]
403pub(crate) fn apply_process_group_sync(cmd: &mut std::process::Command, enabled: bool) {
404 #[cfg(unix)]
405 if enabled {
406 use std::os::unix::process::CommandExt;
407 cmd.process_group(0);
408 }
409 #[cfg(not(unix))]
410 {
411 let _ = (cmd, enabled);
412 }
413}
414
415#[cfg(feature = "async")]
423pub(crate) async fn kill_group_with_grace(
424 group: &mut GroupKillGuard,
425 grace: Option<Duration>,
426 signal: crate::TerminationSignal,
427) {
428 if let Some(g) = grace
429 && !g.is_zero()
430 && group.is_armed()
431 {
432 group.signal_now(signal);
433 tokio::time::sleep(g).await;
434 }
435 group.kill_now();
436}
437
438#[cfg(feature = "async")]
439enum StopReason {
440 Timeout { timeout_seconds: u64 },
441 Cancelled,
442}
443
444#[cfg(feature = "async")]
445impl StopReason {
446 fn into_error(self) -> Error {
447 match self {
448 Self::Timeout { timeout_seconds } => Error::Timeout { timeout_seconds },
449 Self::Cancelled => Error::Cancelled,
450 }
451 }
452}
453
454#[cfg(feature = "async")]
457fn never_stop() -> StopFuture<'static> {
458 Box::pin(std::future::pending())
459}
460
461#[cfg(feature = "async")]
462type StopFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = StopReason> + Send + 'a>>;
463
464#[cfg(feature = "async")]
465fn timeout_stop(timeout: Duration) -> StopFuture<'static> {
466 Box::pin(async move {
467 tokio::time::sleep(timeout).await;
468 StopReason::Timeout {
469 timeout_seconds: timeout.as_secs(),
470 }
471 })
472}
473
474#[cfg(feature = "async")]
475fn cancellation_or_timeout<'a, C>(cancel: C, timeout: Option<Duration>) -> StopFuture<'a>
476where
477 C: std::future::Future<Output = ()> + Send + 'a,
478{
479 Box::pin(async move {
480 match timeout {
481 Some(timeout) => tokio::select! {
482 () = cancel => StopReason::Cancelled,
483 () = tokio::time::sleep(timeout) => StopReason::Timeout {
484 timeout_seconds: timeout.as_secs(),
485 },
486 },
487 None => {
488 cancel.await;
489 StopReason::Cancelled
490 }
491 }
492 })
493}
494
495#[cfg(feature = "async")]
496pub(crate) async fn stop_and_reap(
497 child: &mut tokio::process::Child,
498 group: &mut GroupKillGuard,
499 grace: Option<Duration>,
500 signal: crate::TerminationSignal,
501 working_dir: Option<&std::path::Path>,
502) -> Result<()> {
503 kill_group_with_grace(group, grace, signal).await;
504 if child
505 .try_wait()
506 .map_err(|e| wait_error(e, working_dir))?
507 .is_none()
508 && let Err(error) = child.start_kill()
509 && error.kind() != std::io::ErrorKind::InvalidInput
510 {
511 return Err(wait_error(error, working_dir));
512 }
513 child.wait().await.map_err(|e| wait_error(e, working_dir))?;
514 Ok(())
515}
516
517#[cfg(feature = "async")]
520pub(crate) async fn drain_until<F, O, E>(future: F, stdout: &mut O, stderr: &mut E) -> F::Output
521where
522 F: std::future::Future,
523 O: tokio::io::AsyncRead + Unpin,
524 E: tokio::io::AsyncRead + Unpin,
525{
526 tokio::pin!(future);
527 let drain = async {
528 let mut out_sink = tokio::io::sink();
529 let mut err_sink = tokio::io::sink();
530 tokio::join!(
531 tokio::io::copy(stdout, &mut out_sink),
532 tokio::io::copy(stderr, &mut err_sink),
533 )
534 };
535 tokio::pin!(drain);
536 tokio::select! {
537 result = &mut future => result,
538 _ = &mut drain => future.await,
539 }
540}
541
542#[cfg(feature = "async")]
543fn wait_error(error: std::io::Error, working_dir: Option<&std::path::Path>) -> Error {
544 Error::Io {
545 message: "failed to wait for claude process".to_string(),
546 source: error,
547 working_dir: working_dir.map(|p| p.to_path_buf()),
548 }
549}
550
551#[cfg(feature = "sync")]
553pub(crate) fn kill_group_with_grace_sync(
554 group: &mut GroupKillGuard,
555 grace: Option<Duration>,
556 signal: crate::TerminationSignal,
557) {
558 if let Some(g) = grace
559 && !g.is_zero()
560 && group.is_armed()
561 {
562 group.signal_now(signal);
563 std::thread::sleep(g);
564 }
565 group.kill_now();
566}
567
568#[cfg(feature = "async")]
578pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
579 run_claude_with_retry(claude, args, None).await
580}
581
582#[cfg(feature = "async")]
588pub async fn run_claude_cancellable<C>(
589 claude: &Claude,
590 args: Vec<String>,
591 cancel: C,
592) -> Result<CommandOutput>
593where
594 C: std::future::Future<Output = ()> + Send,
595{
596 let span = exec_span(claude, &args, "cancellable");
597 let command_args = full_command_args(claude, args);
598 let _enter = span.enter();
599 let started = std::time::Instant::now();
600 let stop = cancellation_or_timeout(cancel, claude.timeout);
601 let output = run_with_stop(
602 &claude.binary,
603 &command_args,
604 &claude.env,
605 claude.clear_env,
606 claude.working_dir.as_deref(),
607 stop,
608 SpawnPolicy::of(claude),
609 )
610 .await?;
611 record_exec_outcome(&span, output.exit_code, started);
612 Ok(output)
613}
614
615#[cfg(feature = "async")]
619pub async fn run_claude_with_retry(
620 claude: &Claude,
621 args: Vec<String>,
622 retry_override: Option<&crate::retry::RetryPolicy>,
623) -> Result<CommandOutput> {
624 let policy = retry_override.or(claude.retry_policy.as_ref());
625
626 match policy {
627 Some(policy) => {
628 crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
629 }
630 None => run_claude_once(claude, args).await,
631 }
632}
633
634#[cfg(feature = "async")]
642pub async fn run_claude_with_stdin_prompt(
643 claude: &Claude,
644 args: Vec<String>,
645 stdin_content: String,
646) -> Result<CommandOutput> {
647 run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
648}
649
650#[cfg(feature = "async")]
655pub async fn run_claude_with_stdin_prompt_cancellable<C>(
656 claude: &Claude,
657 args: Vec<String>,
658 stdin_content: String,
659 cancel: C,
660) -> Result<CommandOutput>
661where
662 C: std::future::Future<Output = ()> + Send,
663{
664 let span = exec_span(claude, &args, "stdin-cancellable");
665 let command_args = full_command_args(claude, args);
666 let _enter = span.enter();
667 let started = std::time::Instant::now();
668 let stop = cancellation_or_timeout(cancel, claude.timeout);
669 let output = run_stdin_with_stop(
670 &claude.binary,
671 &command_args,
672 &claude.env,
673 claude.clear_env,
674 claude.working_dir.as_deref(),
675 stdin_content,
676 stop,
677 SpawnPolicy::of(claude),
678 )
679 .await?;
680 record_exec_outcome(&span, output.exit_code, started);
681 Ok(output)
682}
683
684#[cfg(feature = "async")]
685async fn run_claude_with_stdin_prompt_internal(
686 claude: &Claude,
687 args: Vec<String>,
688 stdin_content: String,
689) -> Result<CommandOutput> {
690 let command_args = full_command_args(claude, args);
691
692 let span = exec_span(claude, &command_args, "stdin");
693 let _enter = span.enter();
694 let started = std::time::Instant::now();
695 debug!(
696 arg_count = command_args.len(),
697 "executing claude command (stdin prompt)"
698 );
699
700 let binary = &claude.binary;
701 let env = &claude.env;
702 let clear_env = claude.clear_env;
703 let working_dir = claude.working_dir.as_deref();
704
705 let result = if let Some(timeout) = claude.timeout {
706 run_with_timeout_stdin(
707 binary,
708 &command_args,
709 env,
710 clear_env,
711 working_dir,
712 timeout,
713 stdin_content,
714 SpawnPolicy::of(claude),
715 )
716 .await
717 } else {
718 run_internal_stdin(
719 binary,
720 &command_args,
721 env,
722 clear_env,
723 working_dir,
724 stdin_content,
725 SpawnPolicy::of(claude),
726 )
727 .await
728 };
729
730 if let Ok(output) = &result {
731 record_exec_outcome(&span, output.exit_code, started);
732 }
733 result
734}
735
736#[cfg(feature = "async")]
744async fn run_internal_stdin(
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 policy: SpawnPolicy<'_>,
752) -> Result<CommandOutput> {
753 run_stdin_with_stop(
754 binary,
755 args,
756 env,
757 clear_env,
758 working_dir,
759 stdin_content,
760 never_stop(),
761 policy,
762 )
763 .await
764}
765
766#[cfg(feature = "async")]
767#[allow(clippy::too_many_arguments)]
768async fn run_with_timeout_stdin(
769 binary: &std::path::Path,
770 args: &[String],
771 env: &std::collections::HashMap<String, String>,
772 clear_env: bool,
773 working_dir: Option<&std::path::Path>,
774 timeout: Duration,
775 stdin_content: String,
776 policy: SpawnPolicy<'_>,
777) -> Result<CommandOutput> {
778 run_stdin_with_stop(
779 binary,
780 args,
781 env,
782 clear_env,
783 working_dir,
784 stdin_content,
785 timeout_stop(timeout),
786 policy,
787 )
788 .await
789}
790
791#[cfg(feature = "async")]
792#[allow(clippy::too_many_arguments)]
793async fn run_stdin_with_stop(
794 binary: &std::path::Path,
795 args: &[String],
796 env: &std::collections::HashMap<String, String>,
797 clear_env: bool,
798 working_dir: Option<&std::path::Path>,
799 stdin_content: String,
800 stop: StopFuture<'_>,
801 policy: SpawnPolicy<'_>,
802) -> Result<CommandOutput> {
803 let SpawnPolicy {
804 process_group,
805 kill_grace,
806 termination_signal,
807 output_limit,
808 die_with_parent,
809 on_spawn,
810 } = policy;
811 use tokio::io::AsyncWriteExt;
812
813 let mut cmd = Command::new(binary);
814 cmd.args(args);
815 cmd.stdin(std::process::Stdio::piped());
816 cmd.stdout(std::process::Stdio::piped());
817 cmd.stderr(std::process::Stdio::piped());
818 cmd.kill_on_drop(true);
821 apply_process_group(&mut cmd, process_group);
825 apply_die_with_parent(&mut cmd, die_with_parent);
826 apply_child_environment(cmd.as_std_mut(), clear_env, env);
827
828 if let Some(dir) = working_dir {
829 cmd.current_dir(dir);
830 }
831
832 let mut child = spawn_retrying_txtbsy(&mut cmd)
833 .await
834 .map_err(|e| Error::Io {
835 message: format!("failed to spawn claude: {e}"),
836 source: e,
837 working_dir: working_dir.map(|p| p.to_path_buf()),
838 })?;
839 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
840
841 let child_stdin = child.stdin.take();
842 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
843 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
844 let write = async move {
845 let Some(mut stdin) = child_stdin else {
846 return Ok(());
847 };
848 stdin
849 .write_all(stdin_content.as_bytes())
850 .await
851 .map_err(|e| Error::Io {
852 message: format!("failed to write to claude stdin: {e}"),
853 source: e,
854 working_dir: working_dir.map(|p| p.to_path_buf()),
855 })
856 };
857 let read_stdout = capture_stream(
858 &mut stdout_handle,
859 output_limit,
860 crate::OutputStream::Stdout,
861 working_dir,
862 );
863 let read_stderr = capture_stream(
864 &mut stderr_handle,
865 output_limit,
866 crate::OutputStream::Stderr,
867 working_dir,
868 );
869 let run = async {
870 let ((), stdout, stderr) = tokio::try_join!(write, read_stdout, read_stderr)?;
871 let status = child.wait().await.map_err(|e| wait_error(e, working_dir))?;
872 Ok::<_, Error>((status, stdout, stderr))
873 };
874
875 match tokio::select! {
876 outcome = run => Ok(outcome),
877 reason = stop => Err(reason),
878 } {
879 Ok(Ok((status, stdout, stderr))) => {
880 group.disarm();
881 let exit_code = status.code().unwrap_or(-1);
882
883 if !status.success() {
884 return Err(Error::from_command_failure(
885 format!("{} {}", binary.display(), args.join(" ")),
886 exit_code,
887 stdout,
888 stderr,
889 working_dir.map(|p| p.to_path_buf()),
890 ));
891 }
892
893 Ok(CommandOutput {
894 stdout,
895 stderr,
896 exit_code,
897 success: true,
898 })
899 }
900 Ok(Err(error)) => {
901 drain_until(
902 stop_and_reap(
903 &mut child,
904 &mut group,
905 kill_grace,
906 termination_signal,
907 working_dir,
908 ),
909 &mut stdout_handle,
910 &mut stderr_handle,
911 )
912 .await?;
913 Err(error)
914 }
915 Err(reason) => {
916 drain_until(
920 stop_and_reap(
921 &mut child,
922 &mut group,
923 kill_grace,
924 termination_signal,
925 working_dir,
926 ),
927 &mut stdout_handle,
928 &mut stderr_handle,
929 )
930 .await?;
931 Err(reason.into_error())
932 }
933 }
934}
935
936#[cfg(feature = "async")]
937async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
938 let command_args = full_command_args(claude, args);
939
940 let span = exec_span(claude, &command_args, "oneshot");
941 let _enter = span.enter();
942 let started = std::time::Instant::now();
943 debug!(arg_count = command_args.len(), "executing claude command");
944
945 let output = if let Some(timeout) = claude.timeout {
946 run_with_timeout(
947 &claude.binary,
948 &command_args,
949 &claude.env,
950 claude.clear_env,
951 claude.working_dir.as_deref(),
952 timeout,
953 SpawnPolicy::of(claude),
954 )
955 .await?
956 } else {
957 run_internal(
958 &claude.binary,
959 &command_args,
960 &claude.env,
961 claude.clear_env,
962 claude.working_dir.as_deref(),
963 SpawnPolicy::of(claude),
964 )
965 .await?
966 };
967
968 record_exec_outcome(&span, output.exit_code, started);
969 Ok(output)
970}
971
972#[cfg(feature = "async")]
976pub async fn run_claude_allow_exit_codes(
977 claude: &Claude,
978 args: Vec<String>,
979 allowed_codes: &[i32],
980) -> Result<CommandOutput> {
981 let output = run_claude(claude, args).await;
982
983 match output {
984 Err(Error::CommandFailed {
985 exit_code,
986 stdout,
987 stderr,
988 ..
989 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
990 stdout,
991 stderr,
992 exit_code,
993 success: false,
994 }),
995 other => other,
996 }
997}
998
999#[cfg(feature = "async")]
1007async fn run_internal(
1008 binary: &std::path::Path,
1009 args: &[String],
1010 env: &std::collections::HashMap<String, String>,
1011 clear_env: bool,
1012 working_dir: Option<&std::path::Path>,
1013 policy: SpawnPolicy<'_>,
1014) -> Result<CommandOutput> {
1015 run_with_stop(
1016 binary,
1017 args,
1018 env,
1019 clear_env,
1020 working_dir,
1021 never_stop(),
1022 policy,
1023 )
1024 .await
1025}
1026
1027#[cfg(feature = "async")]
1040async fn run_with_timeout(
1041 binary: &std::path::Path,
1042 args: &[String],
1043 env: &std::collections::HashMap<String, String>,
1044 clear_env: bool,
1045 working_dir: Option<&std::path::Path>,
1046 timeout: Duration,
1047 policy: SpawnPolicy<'_>,
1048) -> Result<CommandOutput> {
1049 run_with_stop(
1050 binary,
1051 args,
1052 env,
1053 clear_env,
1054 working_dir,
1055 timeout_stop(timeout),
1056 policy,
1057 )
1058 .await
1059}
1060
1061#[cfg(feature = "async")]
1062async fn run_with_stop(
1063 binary: &std::path::Path,
1064 args: &[String],
1065 env: &std::collections::HashMap<String, String>,
1066 clear_env: bool,
1067 working_dir: Option<&std::path::Path>,
1068 stop: StopFuture<'_>,
1069 policy: SpawnPolicy<'_>,
1070) -> Result<CommandOutput> {
1071 let SpawnPolicy {
1072 process_group,
1073 kill_grace,
1074 termination_signal,
1075 output_limit,
1076 die_with_parent,
1077 on_spawn,
1078 } = policy;
1079 let mut cmd = Command::new(binary);
1080 cmd.args(args);
1081 cmd.stdin(std::process::Stdio::null());
1082 cmd.stdout(std::process::Stdio::piped());
1083 cmd.stderr(std::process::Stdio::piped());
1084 cmd.kill_on_drop(true);
1087 apply_process_group(&mut cmd, process_group);
1091 apply_die_with_parent(&mut cmd, die_with_parent);
1092 apply_child_environment(cmd.as_std_mut(), clear_env, env);
1093
1094 if let Some(dir) = working_dir {
1095 cmd.current_dir(dir);
1096 }
1097
1098 let mut child = spawn_retrying_txtbsy(&mut cmd)
1099 .await
1100 .map_err(|e| Error::Io {
1101 message: format!("failed to spawn claude: {e}"),
1102 source: e,
1103 working_dir: working_dir.map(|p| p.to_path_buf()),
1104 })?;
1105 let mut group = arm_and_notify(process_group, child.id(), on_spawn);
1106
1107 let mut stdout = child.stdout.take().expect("stdout was piped");
1108 let mut stderr = child.stderr.take().expect("stderr was piped");
1109
1110 let wait_and_drain = async {
1114 let (stdout, stderr) = tokio::try_join!(
1115 capture_stream(
1116 &mut stdout,
1117 output_limit,
1118 crate::OutputStream::Stdout,
1119 working_dir
1120 ),
1121 capture_stream(
1122 &mut stderr,
1123 output_limit,
1124 crate::OutputStream::Stderr,
1125 working_dir
1126 ),
1127 )?;
1128 let status = child.wait().await.map_err(|e| wait_error(e, working_dir))?;
1129 Ok::<_, Error>((status, stdout, stderr))
1130 };
1131
1132 match tokio::select! {
1133 outcome = wait_and_drain => Ok(outcome),
1134 reason = stop => Err(reason),
1135 } {
1136 Ok(Ok((status, stdout, stderr))) => {
1137 group.disarm();
1138 let exit_code = status.code().unwrap_or(-1);
1139
1140 if !status.success() {
1141 return Err(Error::from_command_failure(
1142 format!("{} {}", binary.display(), args.join(" ")),
1143 exit_code,
1144 stdout,
1145 stderr,
1146 working_dir.map(|p| p.to_path_buf()),
1147 ));
1148 }
1149
1150 Ok(CommandOutput {
1151 stdout,
1152 stderr,
1153 exit_code,
1154 success: true,
1155 })
1156 }
1157 Ok(Err(error)) => {
1161 drain_until(
1162 stop_and_reap(
1163 &mut child,
1164 &mut group,
1165 kill_grace,
1166 termination_signal,
1167 working_dir,
1168 ),
1169 &mut stdout,
1170 &mut stderr,
1171 )
1172 .await?;
1173 Err(error)
1174 }
1175 Err(reason) => {
1176 drain_until(
1181 stop_and_reap(
1182 &mut child,
1183 &mut group,
1184 kill_grace,
1185 termination_signal,
1186 working_dir,
1187 ),
1188 &mut stdout,
1189 &mut stderr,
1190 )
1191 .await?;
1192 let stdout_str = tokio::time::timeout(
1193 PARTIAL_CAPTURE_BUDGET,
1194 capture_partial(&mut stdout, output_limit),
1195 )
1196 .await
1197 .unwrap_or_default();
1198 let stderr_str = tokio::time::timeout(
1199 PARTIAL_CAPTURE_BUDGET,
1200 capture_partial(&mut stderr, output_limit),
1201 )
1202 .await
1203 .unwrap_or_default();
1204 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1205 warn!(
1206 stdout_bytes = stdout_str.len(),
1207 stderr_bytes = stderr_str.len(),
1208 "partial output from stopped process",
1209 );
1210 }
1211 Err(reason.into_error())
1212 }
1213 }
1214}
1215
1216#[cfg(any(feature = "async", feature = "sync"))]
1219const CAPTURE_CHUNK: usize = 8 * 1024;
1220
1221#[cfg(any(feature = "async", feature = "sync"))]
1224const BREACH_LOG_BYTES: usize = 2 * 1024;
1225
1226#[cfg(any(feature = "async", feature = "sync"))]
1230const PARTIAL_CAPTURE_BUDGET: Duration = Duration::from_millis(200);
1231
1232#[cfg(any(feature = "async", feature = "sync"))]
1234enum Captured {
1235 Complete(String),
1237 Exceeded { head: String },
1242}
1243
1244#[cfg(feature = "async")]
1250async fn capture<R: AsyncReadExt + Unpin>(
1251 reader: &mut R,
1252 limit: Option<usize>,
1253) -> std::io::Result<Captured> {
1254 let Some(limit) = limit else {
1255 let mut buf = Vec::new();
1256 reader.read_to_end(&mut buf).await?;
1257 return Ok(Captured::Complete(
1258 String::from_utf8_lossy(&buf).into_owned(),
1259 ));
1260 };
1261
1262 let mut buf = Vec::new();
1263 let mut chunk = [0u8; CAPTURE_CHUNK];
1264 loop {
1265 let read = reader.read(&mut chunk).await?;
1266 if read == 0 {
1267 return Ok(Captured::Complete(
1268 String::from_utf8_lossy(&buf).into_owned(),
1269 ));
1270 }
1271 if buf.len() + read > limit {
1272 buf.truncate(BREACH_LOG_BYTES.min(buf.len()));
1273 return Ok(Captured::Exceeded {
1274 head: String::from_utf8_lossy(&buf).into_owned(),
1275 });
1276 }
1277 buf.extend_from_slice(&chunk[..read]);
1278 }
1279}
1280
1281#[cfg(feature = "async")]
1288pub(crate) async fn capture_stream<R: AsyncReadExt + Unpin>(
1289 reader: &mut R,
1290 limit: Option<usize>,
1291 stream: crate::OutputStream,
1292 working_dir: Option<&std::path::Path>,
1293) -> Result<String> {
1294 match capture(reader, limit).await {
1295 Ok(Captured::Complete(text)) => Ok(text),
1296 Ok(Captured::Exceeded { head }) => {
1297 let limit_bytes = limit.unwrap_or_default();
1298 warn!(
1299 %stream,
1300 limit_bytes,
1301 retained_prefix_bytes = head.len(),
1302 "captured output exceeded its ceiling; terminating the child",
1303 );
1304 Err(Error::OutputLimitExceeded {
1305 stream,
1306 limit_bytes,
1307 })
1308 }
1309 Err(source) => Err(Error::Io {
1310 message: format!("failed to read claude {stream}: {source}"),
1311 source,
1312 working_dir: working_dir.map(|p| p.to_path_buf()),
1313 }),
1314 }
1315}
1316
1317#[cfg(feature = "async")]
1323async fn capture_partial<R: AsyncReadExt + Unpin>(reader: &mut R, limit: Option<usize>) -> String {
1324 match capture(reader, limit).await {
1325 Ok(Captured::Complete(text) | Captured::Exceeded { head: text }) => text,
1326 Err(_) => String::new(),
1327 }
1328}
1329
1330#[cfg(any(feature = "async", feature = "sync"))]
1339const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
1340
1341#[cfg(any(feature = "async", feature = "sync"))]
1348const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
1349
1350#[cfg(feature = "async")]
1361pub(crate) async fn spawn_retrying_txtbsy(
1362 cmd: &mut Command,
1363) -> std::io::Result<tokio::process::Child> {
1364 let start = std::time::Instant::now();
1365 let mut backoff = Duration::from_millis(1);
1366 loop {
1367 match cmd.spawn() {
1368 Err(e)
1369 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1370 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1371 {
1372 tokio::time::sleep(backoff).await;
1373 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1374 }
1375 other => return other,
1376 }
1377 }
1378}
1379
1380#[cfg(feature = "sync")]
1384pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1385 run_claude_with_retry_sync(claude, args, None)
1386}
1387
1388#[cfg(feature = "sync")]
1390pub fn run_claude_with_retry_sync(
1391 claude: &Claude,
1392 args: Vec<String>,
1393 retry_override: Option<&crate::retry::RetryPolicy>,
1394) -> Result<CommandOutput> {
1395 let policy = retry_override.or(claude.retry_policy.as_ref());
1396
1397 match policy {
1398 Some(policy) => {
1399 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
1400 }
1401 None => run_claude_once_sync(claude, args),
1402 }
1403}
1404
1405#[cfg(feature = "sync")]
1410pub fn run_claude_with_stdin_prompt_sync(
1411 claude: &Claude,
1412 args: Vec<String>,
1413 stdin_content: String,
1414) -> Result<CommandOutput> {
1415 let command_args = full_command_args(claude, args);
1416
1417 let span = exec_span(claude, &command_args, "stdin-sync");
1418 let _enter = span.enter();
1419 let started = std::time::Instant::now();
1420 debug!(
1421 arg_count = command_args.len(),
1422 "executing claude command (stdin prompt, sync)"
1423 );
1424
1425 let result = if let Some(timeout) = claude.timeout {
1426 run_with_deadline_stdin_sync(
1427 &claude.binary,
1428 &command_args,
1429 &claude.env,
1430 claude.clear_env,
1431 claude.working_dir.as_deref(),
1432 Some(timeout),
1433 stdin_content,
1434 SpawnPolicy::of(claude),
1435 )
1436 } else {
1437 run_internal_stdin_sync(
1438 &claude.binary,
1439 &command_args,
1440 &claude.env,
1441 claude.clear_env,
1442 claude.working_dir.as_deref(),
1443 stdin_content,
1444 SpawnPolicy::of(claude),
1445 )
1446 };
1447
1448 if let Ok(output) = &result {
1449 record_exec_outcome(&span, output.exit_code, started);
1450 }
1451 result
1452}
1453
1454#[cfg(feature = "sync")]
1458fn run_internal_stdin_sync(
1459 binary: &std::path::Path,
1460 args: &[String],
1461 env: &std::collections::HashMap<String, String>,
1462 clear_env: bool,
1463 working_dir: Option<&std::path::Path>,
1464 stdin_content: String,
1465 policy: SpawnPolicy<'_>,
1466) -> Result<CommandOutput> {
1467 run_with_deadline_stdin_sync(
1468 binary,
1469 args,
1470 env,
1471 clear_env,
1472 working_dir,
1473 None,
1474 stdin_content,
1475 policy,
1476 )
1477}
1478
1479#[cfg(feature = "sync")]
1483#[allow(clippy::too_many_arguments)]
1484fn run_with_deadline_stdin_sync(
1485 binary: &std::path::Path,
1486 args: &[String],
1487 env: &std::collections::HashMap<String, String>,
1488 clear_env: bool,
1489 working_dir: Option<&std::path::Path>,
1490 deadline: Option<Duration>,
1491 stdin_content: String,
1492 policy: SpawnPolicy<'_>,
1493) -> Result<CommandOutput> {
1494 let SpawnPolicy {
1495 process_group,
1496 kill_grace,
1497 termination_signal,
1498 output_limit,
1499 die_with_parent,
1500 on_spawn,
1501 } = policy;
1502 use std::io::Write;
1503 use std::process::{Command as StdCommand, Stdio};
1504
1505 let mut cmd = StdCommand::new(binary);
1506 cmd.args(args);
1507 cmd.stdin(Stdio::piped());
1508 cmd.stdout(Stdio::piped());
1509 cmd.stderr(Stdio::piped());
1510 apply_process_group_sync(&mut cmd, process_group);
1514 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1515 apply_child_environment(&mut cmd, clear_env, env);
1516
1517 if let Some(dir) = working_dir {
1518 cmd.current_dir(dir);
1519 }
1520
1521 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1522 message: format!("failed to spawn claude: {e}"),
1523 source: e,
1524 working_dir: working_dir.map(|p| p.to_path_buf()),
1525 })?;
1526 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1527
1528 let capture = SyncCapture::start(
1532 child.stdout.take().expect("stdout was piped"),
1533 child.stderr.take().expect("stderr was piped"),
1534 output_limit,
1535 );
1536
1537 if let Some(mut stdin) = child.stdin.take() {
1539 let written = stdin
1540 .write_all(stdin_content.as_bytes())
1541 .and_then(|()| stdin.flush());
1542 if let Err(source) = written {
1543 capture.discard();
1544 stop_and_reap_sync(&mut child, &mut group, kill_grace, termination_signal);
1545 log_partial(capture, "partial output from process with failed stdin");
1546 return Err(Error::Io {
1547 message: format!("failed to write to claude stdin: {source}"),
1548 source,
1549 working_dir: working_dir.map(|p| p.to_path_buf()),
1550 });
1551 }
1552 }
1554
1555 finish_sync_run(
1556 &mut child,
1557 &mut group,
1558 capture,
1559 SyncRun {
1560 binary,
1561 args,
1562 working_dir,
1563 deadline,
1564 kill_grace,
1565 termination_signal,
1566 output_limit,
1567 },
1568 )
1569}
1570
1571#[cfg(feature = "sync")]
1572fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1573 let command_args = full_command_args(claude, args);
1574
1575 let span = exec_span(claude, &command_args, "oneshot-sync");
1576 let _enter = span.enter();
1577 let started = std::time::Instant::now();
1578 debug!(
1579 arg_count = command_args.len(),
1580 "executing claude command (sync)"
1581 );
1582
1583 let result = if let Some(timeout) = claude.timeout {
1584 run_with_deadline_sync(
1585 &claude.binary,
1586 &command_args,
1587 &claude.env,
1588 claude.clear_env,
1589 claude.working_dir.as_deref(),
1590 Some(timeout),
1591 SpawnPolicy::of(claude),
1592 )
1593 } else {
1594 run_internal_sync(
1595 &claude.binary,
1596 &command_args,
1597 &claude.env,
1598 claude.clear_env,
1599 claude.working_dir.as_deref(),
1600 SpawnPolicy::of(claude),
1601 )
1602 };
1603
1604 if let Ok(output) = &result {
1605 record_exec_outcome(&span, output.exit_code, started);
1606 }
1607 result
1608}
1609
1610#[cfg(feature = "sync")]
1612pub fn run_claude_allow_exit_codes_sync(
1613 claude: &Claude,
1614 args: Vec<String>,
1615 allowed_codes: &[i32],
1616) -> Result<CommandOutput> {
1617 match run_claude_sync(claude, args) {
1618 Err(Error::CommandFailed {
1619 exit_code,
1620 stdout,
1621 stderr,
1622 ..
1623 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
1624 stdout,
1625 stderr,
1626 exit_code,
1627 success: false,
1628 }),
1629 other => other,
1630 }
1631}
1632
1633#[cfg(feature = "sync")]
1640fn run_internal_sync(
1641 binary: &std::path::Path,
1642 args: &[String],
1643 env: &std::collections::HashMap<String, String>,
1644 clear_env: bool,
1645 working_dir: Option<&std::path::Path>,
1646 policy: SpawnPolicy<'_>,
1647) -> Result<CommandOutput> {
1648 run_with_deadline_sync(binary, args, env, clear_env, working_dir, None, policy)
1649}
1650
1651#[cfg(feature = "sync")]
1660fn run_with_deadline_sync(
1661 binary: &std::path::Path,
1662 args: &[String],
1663 env: &std::collections::HashMap<String, String>,
1664 clear_env: bool,
1665 working_dir: Option<&std::path::Path>,
1666 deadline: Option<Duration>,
1667 policy: SpawnPolicy<'_>,
1668) -> Result<CommandOutput> {
1669 let SpawnPolicy {
1670 process_group,
1671 kill_grace,
1672 termination_signal,
1673 output_limit,
1674 die_with_parent,
1675 on_spawn,
1676 } = policy;
1677 use std::process::{Command as StdCommand, Stdio};
1678
1679 let mut cmd = StdCommand::new(binary);
1680 cmd.args(args);
1681 cmd.stdin(Stdio::null());
1682 cmd.stdout(Stdio::piped());
1683 cmd.stderr(Stdio::piped());
1684 apply_process_group_sync(&mut cmd, process_group);
1688 apply_die_with_parent_sync(&mut cmd, die_with_parent);
1689 apply_child_environment(&mut cmd, clear_env, env);
1690
1691 if let Some(dir) = working_dir {
1692 cmd.current_dir(dir);
1693 }
1694
1695 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1696 message: format!("failed to spawn claude: {e}"),
1697 source: e,
1698 working_dir: working_dir.map(|p| p.to_path_buf()),
1699 })?;
1700 let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1701
1702 let capture = SyncCapture::start(
1703 child.stdout.take().expect("stdout was piped"),
1704 child.stderr.take().expect("stderr was piped"),
1705 output_limit,
1706 );
1707
1708 finish_sync_run(
1709 &mut child,
1710 &mut group,
1711 capture,
1712 SyncRun {
1713 binary,
1714 args,
1715 working_dir,
1716 deadline,
1717 kill_grace,
1718 termination_signal,
1719 output_limit,
1720 },
1721 )
1722}
1723
1724#[cfg(feature = "sync")]
1727pub(crate) fn read_discardable<R, F>(
1728 mut reader: R,
1729 discard: &std::sync::atomic::AtomicBool,
1730 mut consume: F,
1731) -> std::io::Result<()>
1732where
1733 R: std::io::Read,
1734 F: FnMut(&[u8]),
1735{
1736 let mut chunk = [0u8; CAPTURE_CHUNK];
1737 loop {
1738 let read = match reader.read(&mut chunk) {
1739 Ok(read) => read,
1740 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1741 Err(error) => return Err(error),
1742 };
1743 if read == 0 {
1744 return Ok(());
1745 }
1746 if !discard.load(std::sync::atomic::Ordering::Acquire) {
1747 consume(&chunk[..read]);
1748 }
1749 }
1750}
1751
1752#[cfg(feature = "sync")]
1753fn capture_sync<R: std::io::Read>(
1754 reader: R,
1755 limit: Option<usize>,
1756 discard: &std::sync::atomic::AtomicBool,
1757 breached: &std::sync::atomic::AtomicU8,
1758 code: u8,
1759) -> std::io::Result<Captured> {
1760 let mut buf = Vec::new();
1761 let mut exceeded = false;
1762 read_discardable(reader, discard, |chunk| {
1763 if exceeded {
1764 return;
1765 }
1766 if limit.is_some_and(|limit| buf.len().saturating_add(chunk.len()) > limit) {
1767 exceeded = true;
1768 buf.truncate(BREACH_LOG_BYTES.min(buf.len()));
1769 let _ = breached.compare_exchange(
1771 breach::NONE,
1772 code,
1773 std::sync::atomic::Ordering::AcqRel,
1774 std::sync::atomic::Ordering::Acquire,
1775 );
1776 } else {
1777 buf.extend_from_slice(chunk);
1778 }
1779 })?;
1780 let text = String::from_utf8_lossy(&buf).into_owned();
1781 Ok(if exceeded {
1782 Captured::Exceeded { head: text }
1783 } else {
1784 Captured::Complete(text)
1785 })
1786}
1787
1788#[cfg(feature = "sync")]
1795mod breach {
1796 pub(super) const NONE: u8 = 0;
1797 pub(super) const STDOUT: u8 = 1;
1798 pub(super) const STDERR: u8 = 2;
1799
1800 pub(super) fn stream(code: u8) -> Option<crate::OutputStream> {
1801 match code {
1802 STDOUT => Some(crate::OutputStream::Stdout),
1803 STDERR => Some(crate::OutputStream::Stderr),
1804 _ => None,
1805 }
1806 }
1807}
1808
1809#[cfg(feature = "sync")]
1811const CAPTURE_POLL_INTERVAL: Duration = Duration::from_millis(10);
1812
1813#[cfg(feature = "sync")]
1815struct SyncCapture {
1816 stdout: std::thread::JoinHandle<std::io::Result<Captured>>,
1817 stderr: std::thread::JoinHandle<std::io::Result<Captured>>,
1818 limit: Option<usize>,
1819 breached: std::sync::Arc<std::sync::atomic::AtomicU8>,
1820 discard: std::sync::Arc<std::sync::atomic::AtomicBool>,
1821}
1822
1823#[cfg(feature = "sync")]
1824impl SyncCapture {
1825 fn start(
1830 stdout: std::process::ChildStdout,
1831 stderr: std::process::ChildStderr,
1832 limit: Option<usize>,
1833 ) -> Self {
1834 let breached = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(breach::NONE));
1835 let discard = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1836 Self {
1837 stdout: spawn_capture_thread(stdout, limit, breach::STDOUT, &breached, &discard),
1838 stderr: spawn_capture_thread(stderr, limit, breach::STDERR, &breached, &discard),
1839 limit,
1840 breached,
1841 discard,
1842 }
1843 }
1844
1845 fn discard(&self) {
1846 self.discard
1847 .store(true, std::sync::atomic::Ordering::Release);
1848 }
1849
1850 fn settle(self, working_dir: Option<&std::path::Path>) -> Result<(String, String)> {
1854 let limit = self.limit;
1855 let stdout = join_capture(self.stdout, crate::OutputStream::Stdout, limit, working_dir);
1856 let stderr = join_capture(self.stderr, crate::OutputStream::Stderr, limit, working_dir);
1857 Ok((stdout?, stderr?))
1858 }
1859
1860 fn partial(self, budget: Duration) -> (String, String) {
1864 let (stdout, stderr) = join_with_deadline(self.stdout, self.stderr, budget);
1865 (partial_text(stdout), partial_text(stderr))
1866 }
1867}
1868
1869#[cfg(feature = "sync")]
1870fn spawn_capture_thread<R: std::io::Read + Send + 'static>(
1871 reader: R,
1872 limit: Option<usize>,
1873 code: u8,
1874 breached: &std::sync::Arc<std::sync::atomic::AtomicU8>,
1875 discard: &std::sync::Arc<std::sync::atomic::AtomicBool>,
1876) -> std::thread::JoinHandle<std::io::Result<Captured>> {
1877 let breached = std::sync::Arc::clone(breached);
1878 let discard = std::sync::Arc::clone(discard);
1879 std::thread::spawn(move || capture_sync(reader, limit, &discard, &breached, code))
1880}
1881
1882#[cfg(feature = "sync")]
1883fn join_capture(
1884 handle: std::thread::JoinHandle<std::io::Result<Captured>>,
1885 stream: crate::OutputStream,
1886 limit: Option<usize>,
1887 working_dir: Option<&std::path::Path>,
1888) -> Result<String> {
1889 match handle.join() {
1890 Ok(Ok(Captured::Complete(text))) => Ok(text),
1891 Ok(Ok(Captured::Exceeded { head })) => {
1892 let limit_bytes = limit.unwrap_or_default();
1893 warn!(
1894 %stream,
1895 limit_bytes,
1896 retained_prefix_bytes = head.len(),
1897 "captured output exceeded its ceiling; terminating the child",
1898 );
1899 Err(Error::OutputLimitExceeded {
1900 stream,
1901 limit_bytes,
1902 })
1903 }
1904 Ok(Err(source)) => Err(Error::Io {
1905 message: format!("failed to read claude {stream}: {source}"),
1906 source,
1907 working_dir: working_dir.map(|p| p.to_path_buf()),
1908 }),
1909 Err(_) => Err(Error::Io {
1910 message: format!("claude {stream} capture thread panicked"),
1911 source: std::io::Error::other(format!("{stream} capture thread panicked")),
1912 working_dir: working_dir.map(|p| p.to_path_buf()),
1913 }),
1914 }
1915}
1916
1917#[cfg(feature = "sync")]
1918fn partial_text(outcome: Option<std::io::Result<Captured>>) -> String {
1919 match outcome {
1920 Some(Ok(Captured::Complete(text) | Captured::Exceeded { head: text })) => text,
1921 Some(Err(_)) | None => String::new(),
1922 }
1923}
1924
1925#[cfg(feature = "sync")]
1927enum SyncWait {
1928 Exited(std::process::ExitStatus),
1929 TimedOut,
1930 Breached(crate::OutputStream),
1932}
1933
1934#[cfg(feature = "sync")]
1938fn wait_for_child_sync(
1939 child: &mut std::process::Child,
1940 deadline: Option<Duration>,
1941 capture: &SyncCapture,
1942) -> std::io::Result<SyncWait> {
1943 use std::sync::atomic::Ordering;
1944 use wait_timeout::ChildExt;
1945
1946 let expiry = deadline.map(|d| std::time::Instant::now() + d);
1947 loop {
1948 if let Some(stream) = breach::stream(capture.breached.load(Ordering::Acquire)) {
1949 return Ok(SyncWait::Breached(stream));
1950 }
1951 let remaining =
1952 expiry.map(|expiry| expiry.saturating_duration_since(std::time::Instant::now()));
1953 if remaining.is_some_and(|remaining| remaining.is_zero()) {
1954 return Ok(SyncWait::TimedOut);
1955 }
1956 if capture.stdout.is_finished() && capture.stderr.is_finished() {
1957 if let Some(stream) = breach::stream(capture.breached.load(Ordering::Acquire)) {
1960 return Ok(SyncWait::Breached(stream));
1961 }
1962 return Ok(match remaining {
1963 Some(remaining) => child
1964 .wait_timeout(remaining)?
1965 .map_or(SyncWait::TimedOut, SyncWait::Exited),
1966 None => SyncWait::Exited(child.wait()?),
1967 });
1968 }
1969 std::thread::sleep(
1970 remaining.map_or(CAPTURE_POLL_INTERVAL, |r| r.min(CAPTURE_POLL_INTERVAL)),
1971 );
1972 }
1973}
1974
1975#[cfg(feature = "sync")]
1979struct SyncRun<'a> {
1980 binary: &'a std::path::Path,
1981 args: &'a [String],
1982 working_dir: Option<&'a std::path::Path>,
1983 deadline: Option<Duration>,
1984 kill_grace: Option<Duration>,
1985 termination_signal: crate::TerminationSignal,
1986 output_limit: Option<usize>,
1987}
1988
1989#[cfg(feature = "sync")]
1995fn finish_sync_run(
1996 child: &mut std::process::Child,
1997 group: &mut GroupKillGuard,
1998 capture: SyncCapture,
1999 run: SyncRun<'_>,
2000) -> Result<CommandOutput> {
2001 let waited = match wait_for_child_sync(child, run.deadline, &capture) {
2002 Ok(waited) => waited,
2003 Err(source) => {
2004 capture.discard();
2005 stop_and_reap_sync(child, group, run.kill_grace, run.termination_signal);
2006 return Err(Error::Io {
2007 message: "failed to wait for claude process".to_string(),
2008 source,
2009 working_dir: run.working_dir.map(|p| p.to_path_buf()),
2010 });
2011 }
2012 };
2013
2014 match waited {
2015 SyncWait::Exited(status) => {
2016 group.disarm();
2017 let (stdout, stderr) = capture.settle(run.working_dir)?;
2021 let exit_code = status.code().unwrap_or(-1);
2022
2023 if !status.success() {
2024 return Err(Error::from_command_failure(
2025 format!("{} {}", run.binary.display(), run.args.join(" ")),
2026 exit_code,
2027 stdout,
2028 stderr,
2029 run.working_dir.map(|p| p.to_path_buf()),
2030 ));
2031 }
2032
2033 Ok(CommandOutput {
2034 stdout,
2035 stderr,
2036 exit_code,
2037 success: true,
2038 })
2039 }
2040 SyncWait::TimedOut => {
2044 capture.discard();
2045 stop_and_reap_sync(child, group, run.kill_grace, run.termination_signal);
2046 log_partial(capture, "partial output from timed-out process");
2047 Err(Error::Timeout {
2048 timeout_seconds: run.deadline.unwrap_or_default().as_secs(),
2049 })
2050 }
2051 SyncWait::Breached(stream) => {
2052 capture.discard();
2053 stop_and_reap_sync(child, group, run.kill_grace, run.termination_signal);
2054 log_partial(capture, "partial output from over-limit process");
2055 Err(Error::OutputLimitExceeded {
2056 stream,
2057 limit_bytes: run.output_limit.unwrap_or_default(),
2058 })
2059 }
2060 }
2061}
2062
2063#[cfg(feature = "sync")]
2064fn log_partial(capture: SyncCapture, message: &'static str) {
2065 let (stdout, stderr) = capture.partial(PARTIAL_CAPTURE_BUDGET);
2066 if !stdout.is_empty() || !stderr.is_empty() {
2067 warn!(
2068 stdout_bytes = stdout.len(),
2069 stderr_bytes = stderr.len(),
2070 "{message}"
2071 );
2072 }
2073}
2074
2075#[cfg(feature = "sync")]
2078fn stop_and_reap_sync(
2079 child: &mut std::process::Child,
2080 group: &mut GroupKillGuard,
2081 grace: Option<Duration>,
2082 signal: crate::TerminationSignal,
2083) {
2084 kill_group_with_grace_sync(group, grace, signal);
2085 let _ = child.kill();
2086 let _ = child.wait();
2087}
2088
2089#[cfg(feature = "sync")]
2092fn spawn_retrying_txtbsy_sync(
2093 cmd: &mut std::process::Command,
2094) -> std::io::Result<std::process::Child> {
2095 let start = std::time::Instant::now();
2096 let mut backoff = Duration::from_millis(1);
2097 loop {
2098 match cmd.spawn() {
2099 Err(e)
2100 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
2101 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
2102 {
2103 std::thread::sleep(backoff);
2104 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
2105 }
2106 other => return other,
2107 }
2108 }
2109}
2110
2111#[cfg(feature = "sync")]
2119#[cfg(feature = "sync")]
2127fn join_with_deadline<T: Send + 'static>(
2128 stdout_thread: std::thread::JoinHandle<T>,
2129 stderr_thread: std::thread::JoinHandle<T>,
2130 budget: Duration,
2131) -> (Option<T>, Option<T>) {
2132 use std::sync::mpsc;
2133 use std::thread;
2134
2135 let (tx, rx) = mpsc::channel::<(&'static str, T)>();
2136
2137 let tx_out = tx.clone();
2138 let tx_err = tx;
2139
2140 thread::spawn(move || {
2141 if let Ok(value) = stdout_thread.join() {
2142 let _ = tx_out.send(("stdout", value));
2143 }
2144 });
2145 thread::spawn(move || {
2146 if let Ok(value) = stderr_thread.join() {
2147 let _ = tx_err.send(("stderr", value));
2148 }
2149 });
2150
2151 let mut stdout = None;
2152 let mut stderr = None;
2153 let deadline = std::time::Instant::now() + budget;
2154
2155 for _ in 0..2 {
2156 let now = std::time::Instant::now();
2157 if now >= deadline {
2158 break;
2159 }
2160 match rx.recv_timeout(deadline - now) {
2161 Ok(("stdout", value)) => stdout = Some(value),
2162 Ok(("stderr", value)) => stderr = Some(value),
2163 Ok(_) => unreachable!(),
2164 Err(_) => break,
2165 }
2166 }
2167
2168 (stdout, stderr)
2169}
2170
2171#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
2178mod tests {
2179 use super::*;
2180 use std::io::Write;
2181 use std::os::unix::fs::PermissionsExt;
2182
2183 #[cfg(feature = "sync")]
2184 #[test]
2185 fn shutdown_capture_retries_interrupted_reads() {
2186 struct InterruptedReader {
2187 data: std::io::Cursor<&'static [u8]>,
2188 interrupt: bool,
2189 }
2190 impl std::io::Read for InterruptedReader {
2191 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2192 self.interrupt = !self.interrupt;
2193 if self.interrupt {
2194 return Err(std::io::ErrorKind::Interrupted.into());
2195 }
2196 let len = buf.len().min(2);
2197 std::io::Read::read(&mut self.data, &mut buf[..len])
2198 }
2199 }
2200
2201 for discard in [false, true] {
2202 let reader = InterruptedReader {
2203 data: std::io::Cursor::new(b"payload"),
2204 interrupt: false,
2205 };
2206 let mut received = Vec::new();
2207 read_discardable(
2208 reader,
2209 &std::sync::atomic::AtomicBool::new(discard),
2210 |chunk| {
2211 received.extend_from_slice(chunk);
2212 },
2213 )
2214 .expect("an interrupted read is retried during capture and shutdown");
2215 assert_eq!(received, if discard { &b""[..] } else { &b"payload"[..] });
2216 }
2217 }
2218
2219 use crate::Claude;
2220
2221 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
2225 let dir = tempfile::tempdir().expect("tempdir");
2226 let path = dir.path().join("fake-claude.sh");
2227 {
2232 let mut f = std::fs::File::create(&path).expect("create script");
2233 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
2234 f.sync_all().expect("sync script");
2235 }
2236 let perms = std::fs::Permissions::from_mode(0o755);
2237 std::fs::set_permissions(&path, perms).expect("chmod");
2238 (dir, path)
2239 }
2240
2241 fn client(path: &std::path::Path) -> Claude {
2242 Claude::builder()
2243 .binary(path)
2244 .build()
2245 .expect("build client")
2246 }
2247
2248 #[test]
2249 fn full_command_args_puts_global_args_first() {
2250 let claude = Claude::builder()
2251 .binary("/usr/local/bin/claude")
2252 .arg("--debug")
2253 .arg("--verbose")
2254 .build()
2255 .expect("build client");
2256 let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
2257 assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
2258 }
2259
2260 #[test]
2261 fn full_command_args_without_global_args_is_passthrough() {
2262 let claude = Claude::builder()
2263 .binary("/usr/local/bin/claude")
2264 .build()
2265 .expect("build client");
2266 let args = full_command_args(&claude, vec!["--print".to_string()]);
2267 assert_eq!(args, ["--print"]);
2268 }
2269
2270 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2272
2273 fn set_scrub_vars() {
2274 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2275 unsafe {
2278 std::env::set_var("CLAUDECODE", "1");
2279 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
2280 }
2281 }
2282
2283 fn clear_scrub_vars() {
2284 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2285 unsafe {
2287 std::env::remove_var("CLAUDECODE");
2288 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
2289 }
2290 }
2291
2292 #[cfg(feature = "async")]
2295 #[tokio::test]
2296 async fn async_success_maps_output() {
2297 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
2298 let out = run_claude(&client(&path), vec!["--version".into()])
2299 .await
2300 .expect("success");
2301 assert!(out.success);
2302 assert_eq!(out.exit_code, 0);
2303 assert!(out.stdout.contains("hi there"));
2304 }
2305
2306 #[cfg(feature = "async")]
2307 #[tokio::test]
2308 async fn async_nonzero_exit_maps_command_failed() {
2309 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2310 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2311 match err {
2312 Error::CommandFailed {
2313 exit_code, stderr, ..
2314 } => {
2315 assert_eq!(exit_code, 3);
2316 assert!(stderr.contains("boom"));
2317 }
2318 other => panic!("expected CommandFailed, got {other:?}"),
2319 }
2320 }
2321
2322 #[cfg(feature = "async")]
2323 #[tokio::test]
2324 async fn async_rail_stop_maps_max_turns() {
2325 let (_dir, path) = fake_script(
2326 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
2327 );
2328 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2329 assert!(
2330 matches!(
2331 err,
2332 Error::MaxTurnsExceeded {
2333 max_turns: Some(2),
2334 ..
2335 }
2336 ),
2337 "got: {err:?}"
2338 );
2339 }
2340
2341 #[cfg(feature = "async")]
2342 #[tokio::test]
2343 async fn async_auth_shaped_stderr_maps_auth() {
2344 let (_dir, path) =
2345 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
2346 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2347 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
2348 }
2349
2350 #[cfg(feature = "async")]
2351 #[tokio::test]
2352 async fn async_scrubs_claude_env_vars() {
2353 let (_dir, path) =
2354 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2355 set_scrub_vars();
2361 let out = run_claude(&client(&path), vec![]).await.expect("success");
2362 clear_scrub_vars();
2363 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2364 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2365 }
2366
2367 #[cfg(feature = "async")]
2368 #[tokio::test]
2369 async fn async_applies_working_dir() {
2370 let (_dir, path) = fake_script(r#"pwd"#);
2371 let workdir = tempfile::tempdir().expect("workdir");
2372 let claude = Claude::builder()
2373 .binary(&path)
2374 .working_dir(workdir.path())
2375 .build()
2376 .expect("build");
2377 let out = run_claude(&claude, vec![]).await.expect("success");
2378 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
2379 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
2380 assert_eq!(got, want);
2381 }
2382
2383 #[cfg(feature = "async")]
2384 #[tokio::test]
2385 async fn async_stdin_prompt_round_trips() {
2386 let (_dir, path) = fake_script(r#"cat"#);
2387 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
2388 .await
2389 .expect("success");
2390 assert!(out.stdout.contains("hello via stdin"));
2391 }
2392
2393 #[cfg(feature = "async")]
2398 #[tokio::test]
2399 async fn async_spawn_retry_passes_through_non_txtbsy_error() {
2400 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
2401 let err = spawn_retrying_txtbsy(&mut cmd)
2402 .await
2403 .expect_err("spawn of missing binary should fail");
2404 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2405 }
2406
2407 #[cfg(feature = "async")]
2408 #[tokio::test]
2409 async fn async_allow_exit_codes_permits_listed_code() {
2410 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2411 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
2412 .await
2413 .expect("allowed code is Ok");
2414 assert!(!out.success);
2415 assert_eq!(out.exit_code, 2);
2416 assert!(out.stdout.contains("out"));
2417 }
2418
2419 #[cfg(feature = "async")]
2420 #[tokio::test]
2421 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
2422 let (_dir, path) = fake_script(r#"exit 2"#);
2423 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
2424 .await
2425 .unwrap_err();
2426 assert!(
2427 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
2428 "got: {err:?}"
2429 );
2430 }
2431
2432 #[cfg(feature = "async")]
2433 #[tokio::test]
2434 async fn async_timeout_fires_on_slow_child() {
2435 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2436 let claude = Claude::builder()
2437 .binary(&path)
2438 .timeout(Duration::from_millis(300))
2439 .build()
2440 .expect("build");
2441 let err = run_claude(&claude, vec![]).await.unwrap_err();
2442 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2443 }
2444
2445 #[cfg(feature = "async")]
2446 #[tokio::test]
2447 async fn async_timeout_path_returns_output_when_fast() {
2448 let (_dir, path) = fake_script(r#"echo quick"#);
2449 let claude = Claude::builder()
2450 .binary(&path)
2451 .timeout(Duration::from_secs(30))
2452 .build()
2453 .expect("build");
2454 let out = run_claude(&claude, vec![]).await.expect("success");
2455 assert!(out.stdout.contains("quick"));
2456 }
2457
2458 #[cfg(feature = "async")]
2459 #[tokio::test]
2460 async fn async_timeout_path_maps_command_failed() {
2461 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
2462 let claude = Claude::builder()
2463 .binary(&path)
2464 .timeout(Duration::from_secs(30))
2465 .build()
2466 .expect("build");
2467 let err = run_claude(&claude, vec![]).await.unwrap_err();
2468 assert!(
2469 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
2470 "got: {err:?}"
2471 );
2472 }
2473
2474 #[cfg(feature = "async")]
2475 #[tokio::test]
2476 async fn async_stdin_with_timeout_round_trips() {
2477 let (_dir, path) = fake_script(r#"cat"#);
2478 let claude = Claude::builder()
2479 .binary(&path)
2480 .timeout(Duration::from_secs(30))
2481 .build()
2482 .expect("build");
2483 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
2484 .await
2485 .expect("success");
2486 assert!(out.stdout.contains("piped under timeout"));
2487 }
2488
2489 #[cfg(feature = "async")]
2490 #[tokio::test]
2491 async fn async_stdin_timeout_fires_on_slow_child() {
2492 let (_dir, path) = fake_script(r#"sleep 3"#);
2493 let claude = Claude::builder()
2494 .binary(&path)
2495 .timeout(Duration::from_millis(300))
2496 .build()
2497 .expect("build");
2498 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
2499 .await
2500 .unwrap_err();
2501 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2502 }
2503
2504 #[cfg(feature = "async")]
2507 async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
2508 where
2509 F: std::future::Future,
2510 F::Output: std::fmt::Debug,
2511 {
2512 tokio::pin!(fut);
2513 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2514 loop {
2515 if let Some(pid) = std::fs::read_to_string(pid_path)
2516 .ok()
2517 .and_then(|s| s.trim().parse().ok())
2518 {
2519 return pid;
2521 }
2522 assert!(
2523 std::time::Instant::now() < deadline,
2524 "child never wrote its pid file"
2525 );
2526 tokio::select! {
2527 out = &mut fut => panic!("future completed before drop: {out:?}"),
2528 _ = tokio::time::sleep(Duration::from_millis(10)) => {}
2529 }
2530 }
2531 }
2532
2533 fn assert_pid_killed(pid: u32) {
2539 let deadline = std::time::Instant::now() + Duration::from_secs(10);
2540 loop {
2541 let out = std::process::Command::new("ps")
2542 .args(["-o", "stat=", "-p", &pid.to_string()])
2543 .output()
2544 .expect("run ps");
2545 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2546 if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
2547 return;
2548 }
2549 assert!(
2550 std::time::Instant::now() < deadline,
2551 "process {pid} still alive (stat {stat}) after kill"
2552 );
2553 std::thread::sleep(Duration::from_millis(25));
2554 }
2555 }
2556
2557 #[cfg(feature = "async")]
2558 fn assert_pid_not_live_now(pid: u32) {
2559 let out = std::process::Command::new("ps")
2560 .args(["-o", "stat=", "-p", &pid.to_string()])
2561 .output()
2562 .expect("run ps");
2563 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2564 assert!(
2565 !out.status.success() || stat.is_empty() || stat.starts_with('Z'),
2566 "process {pid} still live after terminal settlement (stat {stat})"
2567 );
2568 }
2569
2570 fn group_script(
2578 pid_path: &std::path::Path,
2579 gpid_path: &std::path::Path,
2580 ) -> (tempfile::TempDir, std::path::PathBuf) {
2581 fake_script(&format!(
2586 concat!(
2587 "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2588 "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2589 "echo $$ > \"{p}\"\n",
2590 "exec sleep 300",
2591 ),
2592 g = gpid_path.display(),
2593 p = pid_path.display(),
2594 ))
2595 }
2596
2597 fn spew_script(stream: &str, bytes: usize) -> (tempfile::TempDir, std::path::PathBuf) {
2600 let redirect = if stream == "stderr" { ">&2" } else { "" };
2601 fake_script(&format!(
2602 "head -c {bytes} /dev/zero | tr '\\0' 'x' {redirect}\nexit 0"
2603 ))
2604 }
2605
2606 fn limited_client(path: &std::path::Path, limit: usize) -> Claude {
2607 Claude::builder()
2608 .binary(path)
2609 .output_limit(limit)
2610 .build()
2611 .expect("build client")
2612 }
2613
2614 fn assert_limit_error(error: &Error, want_stream: crate::OutputStream, want_limit: usize) {
2615 match error {
2616 Error::OutputLimitExceeded {
2617 stream,
2618 limit_bytes,
2619 } => {
2620 assert_eq!(*stream, want_stream);
2621 assert_eq!(*limit_bytes, want_limit);
2622 }
2623 other => panic!("expected OutputLimitExceeded, got: {other:?}"),
2624 }
2625 }
2626
2627 #[cfg(feature = "async")]
2628 #[tokio::test]
2629 async fn async_stdout_over_limit_is_typed_not_truncated() {
2630 let (_dir, path) = spew_script("stdout", 64 * 1024);
2631 let error = run_claude(&limited_client(&path, 4096), vec![])
2632 .await
2633 .expect_err("over-limit stdout must fail");
2634 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2635 }
2636
2637 #[cfg(feature = "async")]
2638 #[tokio::test]
2639 async fn async_stderr_over_limit_names_stderr() {
2640 let (_dir, path) = spew_script("stderr", 64 * 1024);
2641 let error = run_claude(&limited_client(&path, 4096), vec![])
2642 .await
2643 .expect_err("over-limit stderr must fail");
2644 assert_limit_error(&error, crate::OutputStream::Stderr, 4096);
2645 }
2646
2647 #[cfg(feature = "async")]
2650 #[tokio::test]
2651 async fn async_output_at_limit_succeeds() {
2652 let (_dir, path) = fake_script(r#"printf 'abcde'"#);
2653 let out = run_claude(&limited_client(&path, 5), vec![])
2654 .await
2655 .expect("output at the ceiling is complete");
2656 assert_eq!(out.stdout, "abcde");
2657 }
2658
2659 #[cfg(feature = "async")]
2660 #[tokio::test]
2661 async fn async_unset_limit_leaves_large_output_intact() {
2662 let (_dir, path) = spew_script("stdout", 256 * 1024);
2663 let out = run_claude(&client(&path), vec![])
2664 .await
2665 .expect("no ceiling means no failure");
2666 assert_eq!(out.stdout.len(), 256 * 1024);
2667 }
2668
2669 #[cfg(feature = "async")]
2670 #[tokio::test]
2671 async fn async_stdin_prompt_path_honors_limit() {
2672 let (_dir, path) = spew_script("stdout", 64 * 1024);
2673 let error = run_claude_with_stdin_prompt_sync_free(&limited_client(&path, 4096))
2674 .await
2675 .expect_err("over-limit stdout must fail on the stdin path");
2676 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2677 }
2678
2679 #[cfg(feature = "async")]
2680 async fn run_claude_with_stdin_prompt_sync_free(claude: &Claude) -> Result<CommandOutput> {
2681 run_claude_with_stdin_prompt(claude, vec![], "prompt".to_string()).await
2682 }
2683
2684 #[cfg(feature = "async")]
2688 #[tokio::test]
2689 async fn async_limit_breach_kills_process_group() {
2690 let workdir = tempfile::tempdir().expect("workdir");
2691 let pid_path = workdir.path().join("pid");
2692 let gpid_path = workdir.path().join("gpid");
2693 let (_dir, path) = fake_script(&format!(
2694 concat!(
2695 "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2696 "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2697 "echo $$ > \"{p}\"\n",
2698 "head -c 65536 /dev/zero | tr '\\0' 'x'\n",
2699 "exec sleep 300",
2700 ),
2701 g = gpid_path.display(),
2702 p = pid_path.display(),
2703 ));
2704 let error = run_claude(&limited_client(&path, 4096), vec![])
2705 .await
2706 .expect_err("over-limit stdout must fail");
2707 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2708
2709 let pid = try_read_pid(&pid_path).expect("child recorded its pid");
2710 let gpid = try_read_pid(&gpid_path).expect("grandchild recorded its pid");
2711 assert_pid_killed(pid);
2712 assert_pid_killed(gpid);
2713 }
2714
2715 #[cfg(feature = "sync")]
2716 #[test]
2717 fn sync_stdout_over_limit_is_typed_not_truncated() {
2718 let (_dir, path) = spew_script("stdout", 64 * 1024);
2719 let error = run_claude_sync(&limited_client(&path, 4096), vec![])
2720 .expect_err("over-limit stdout must fail");
2721 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2722 }
2723
2724 #[cfg(feature = "sync")]
2725 #[test]
2726 fn sync_stderr_over_limit_names_stderr() {
2727 let (_dir, path) = spew_script("stderr", 64 * 1024);
2728 let error = run_claude_sync(&limited_client(&path, 4096), vec![])
2729 .expect_err("over-limit stderr must fail");
2730 assert_limit_error(&error, crate::OutputStream::Stderr, 4096);
2731 }
2732
2733 #[cfg(feature = "sync")]
2734 #[test]
2735 fn sync_output_at_limit_succeeds() {
2736 let (_dir, path) = fake_script(r#"printf 'abcde'"#);
2737 let out = run_claude_sync(&limited_client(&path, 5), vec![])
2738 .expect("output at the ceiling is complete");
2739 assert_eq!(out.stdout, "abcde");
2740 }
2741
2742 #[cfg(feature = "sync")]
2743 #[test]
2744 fn sync_unset_limit_leaves_large_output_intact() {
2745 let (_dir, path) = spew_script("stdout", 256 * 1024);
2746 let out = run_claude_sync(&client(&path), vec![]).expect("no ceiling means no failure");
2747 assert_eq!(out.stdout.len(), 256 * 1024);
2748 }
2749
2750 #[cfg(feature = "sync")]
2751 #[test]
2752 fn sync_stdin_prompt_path_honors_limit() {
2753 let (_dir, path) = spew_script("stdout", 64 * 1024);
2754 let error =
2755 run_claude_with_stdin_prompt_sync(&limited_client(&path, 4096), vec![], "p".into())
2756 .expect_err("over-limit stdout must fail on the stdin path");
2757 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2758 }
2759
2760 #[cfg(feature = "sync")]
2764 #[test]
2765 fn sync_limit_breach_terminates_a_child_with_no_deadline() {
2766 let (_dir, path) = fake_script(concat!(
2767 "head -c 65536 /dev/zero | tr '\\0' 'x'\n",
2768 "exec sleep 300",
2769 ));
2770 let started = std::time::Instant::now();
2771 let error = run_claude_sync(&limited_client(&path, 4096), vec![])
2772 .expect_err("over-limit stdout must fail");
2773 assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
2774 assert!(
2775 started.elapsed() < Duration::from_secs(30),
2776 "breach must not wait out the child, took {:?}",
2777 started.elapsed(),
2778 );
2779 }
2780
2781 fn try_read_pid(path: &std::path::Path) -> Option<u32> {
2783 std::fs::read_to_string(path).ok()?.trim().parse().ok()
2784 }
2785
2786 #[cfg(feature = "async")]
2790 fn read_pid(path: &std::path::Path) -> u32 {
2791 try_read_pid(path).expect("pid file readable")
2792 }
2793
2794 #[cfg(feature = "async")]
2800 #[tokio::test]
2801 async fn async_dropping_in_flight_future_kills_child() {
2802 let workdir = tempfile::tempdir().expect("workdir");
2803 let pid_path = workdir.path().join("pid");
2804 let (_dir, path) = fake_script(&format!(
2805 r#"echo $$ > "{}"; exec sleep 30"#,
2806 pid_path.display()
2807 ));
2808 let claude = client(&path);
2809 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2810 assert_pid_killed(pid);
2811 }
2812
2813 #[cfg(feature = "async")]
2818 #[tokio::test]
2819 async fn async_dropping_in_flight_future_kills_child_with_timeout() {
2820 let workdir = tempfile::tempdir().expect("workdir");
2821 let pid_path = workdir.path().join("pid");
2822 let (_dir, path) = fake_script(&format!(
2823 r#"echo $$ > "{}"; exec sleep 30"#,
2824 pid_path.display()
2825 ));
2826 let claude = Claude::builder()
2827 .binary(&path)
2828 .timeout(Duration::from_secs(120))
2829 .build()
2830 .expect("build");
2831 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2832 assert_pid_killed(pid);
2833 }
2834
2835 #[cfg(feature = "async")]
2839 #[tokio::test]
2840 async fn async_dropping_in_flight_future_kills_process_group() {
2841 let workdir = tempfile::tempdir().expect("workdir");
2842 let pid_path = workdir.path().join("pid");
2843 let gpid_path = workdir.path().join("gpid");
2844 let (_dir, path) = group_script(&pid_path, &gpid_path);
2845 let claude = client(&path);
2846 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2847 assert_pid_killed(pid);
2848 assert_pid_killed(read_pid(&gpid_path));
2849 }
2850
2851 #[cfg(feature = "async")]
2857 #[tokio::test]
2858 async fn async_timeout_kills_process_group() {
2859 let mut observed = false;
2860 for _ in 0..5 {
2861 let workdir = tempfile::tempdir().expect("workdir");
2862 let pid_path = workdir.path().join("pid");
2863 let gpid_path = workdir.path().join("gpid");
2864 let (_dir, path) = group_script(&pid_path, &gpid_path);
2865 let claude = Claude::builder()
2866 .binary(&path)
2867 .timeout(Duration::from_millis(1000))
2868 .build()
2869 .expect("build");
2870 let err = run_claude(&claude, vec![]).await.unwrap_err();
2871 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2872 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2873 assert_pid_killed(pid);
2874 assert_pid_killed(gpid);
2875 observed = true;
2876 break;
2877 }
2878 }
2879 assert!(observed, "child never recorded pids within 5 timeout runs");
2880 }
2881
2882 #[cfg(feature = "async")]
2883 #[tokio::test]
2884 async fn async_explicit_cancellation_settles_process_group() {
2885 let workdir = tempfile::tempdir().expect("workdir");
2886 let pid_path = workdir.path().join("pid");
2887 let gpid_path = workdir.path().join("gpid");
2888 let (_dir, path) = group_script(&pid_path, &gpid_path);
2889 let claude = Claude::builder()
2890 .binary(&path)
2891 .kill_grace(Duration::from_millis(10))
2892 .build()
2893 .expect("build");
2894 let cancel = async {
2895 while try_read_pid(&pid_path).is_none() || try_read_pid(&gpid_path).is_none() {
2898 tokio::time::sleep(Duration::from_millis(10)).await;
2899 }
2900 };
2901
2902 let error = run_claude_cancellable(&claude, vec![], cancel)
2903 .await
2904 .expect_err("run must be cancelled");
2905 assert!(matches!(error, Error::Cancelled));
2906 assert_pid_not_live_now(read_pid(&pid_path));
2907 assert_pid_not_live_now(read_pid(&gpid_path));
2908 }
2909
2910 #[cfg(feature = "async")]
2911 #[tokio::test]
2912 async fn async_stdin_failure_settles_process_group() {
2913 let workdir = tempfile::tempdir().expect("workdir");
2914 let pid_path = workdir.path().join("pid");
2915 let gpid_path = workdir.path().join("gpid");
2916 let (_dir, path) = fake_script(&format!(
2917 concat!(
2918 "sleep 300 </dev/null &\n",
2919 "child=$!\n",
2920 "echo $$ > '{p}'\n",
2921 "echo $child > '{g}'\n",
2922 "exec 0<&-\n",
2923 "wait $child",
2924 ),
2925 p = pid_path.display(),
2926 g = gpid_path.display(),
2927 ));
2928 let claude = Claude::builder()
2929 .binary(&path)
2930 .kill_grace(Duration::from_millis(10))
2931 .build()
2932 .expect("build");
2933
2934 let error = run_claude_with_stdin_prompt(&claude, vec![], "x".repeat(4 * 1024 * 1024))
2935 .await
2936 .expect_err("stdin write must fail");
2937 assert!(matches!(error, Error::Io { .. }));
2938 assert_pid_not_live_now(read_pid(&pid_path));
2939 assert_pid_not_live_now(read_pid(&gpid_path));
2940 }
2941
2942 #[cfg(feature = "async")]
2948 #[tokio::test]
2949 async fn async_process_group_opt_out_kills_only_direct_child() {
2950 let workdir = tempfile::tempdir().expect("workdir");
2951 let pid_path = workdir.path().join("pid");
2952 let gpid_path = workdir.path().join("gpid");
2953 let (_dir, path) = group_script(&pid_path, &gpid_path);
2954 let claude = Claude::builder()
2955 .binary(&path)
2956 .process_group(false)
2957 .build()
2958 .expect("build");
2959 let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2960 assert_pid_killed(pid);
2961
2962 let gpid = read_pid(&gpid_path);
2964 let out = std::process::Command::new("ps")
2965 .args(["-o", "stat=", "-p", &gpid.to_string()])
2966 .output()
2967 .expect("run ps");
2968 let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2969 assert!(
2970 out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
2971 "grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
2972 );
2973
2974 let _ = std::process::Command::new("kill")
2976 .args(["-9", &gpid.to_string()])
2977 .status();
2978 }
2979
2980 struct InterruptFixture {
2981 _script_dir: tempfile::TempDir,
2982 workdir: tempfile::TempDir,
2983 binary: std::path::PathBuf,
2984 }
2985
2986 impl InterruptFixture {
2987 fn new() -> Self {
2988 let workdir = tempfile::tempdir().unwrap();
2989 let (script_dir, binary) = fake_script(&format!(
2990 r#"set -m
2991sleep 30 &
2992detached=$!
2993set +m
2994trap 'echo received > "{dir}/cleanup-stage"; head -c 1048576 /dev/zero || exit 97; echo stdout > "{dir}/cleanup-stage"; head -c 1048576 /dev/zero >&2 || exit 98; echo stderr > "{dir}/cleanup-stage"; kill "$detached"; wait "$detached"; echo cleaned > "{dir}/cleaned"; exit 0' INT
2995echo $$ > "{dir}/provider"
2996echo "$detached" > "{dir}/detached"
2997ps -o pgid= -p $$ > "{dir}/provider-group"
2998ps -o pgid= -p "$detached" > "{dir}/detached-group"
2999echo ready > "{dir}/ready"
3000wait "$detached"
3001"#,
3002 dir = workdir.path().display(),
3003 ));
3004 Self {
3005 _script_dir: script_dir,
3006 workdir,
3007 binary,
3008 }
3009 }
3010
3011 fn path(&self, name: &str) -> std::path::PathBuf {
3012 self.workdir.path().join(name)
3013 }
3014
3015 fn client(&self) -> Claude {
3016 Claude::builder()
3017 .binary(&self.binary)
3018 .termination_signal(crate::TerminationSignal::Interrupt)
3019 .kill_grace(Duration::from_secs(2))
3022 .output_limit(4096)
3023 .build()
3024 .unwrap()
3025 }
3026
3027 #[cfg(feature = "async")]
3028 async fn ready(&self) {
3029 tokio::time::timeout(Duration::from_secs(5), async {
3030 while !self.path("ready").exists() {
3031 tokio::time::sleep(Duration::from_millis(5)).await;
3032 }
3033 })
3034 .await
3035 .expect("fake provider ready");
3036 self.assert_detached();
3037 }
3038
3039 fn assert_detached(&self) {
3040 let provider = try_read_pid(&self.path("provider-group")).unwrap();
3041 let detached = try_read_pid(&self.path("detached-group")).unwrap();
3042 assert_ne!(
3043 provider, detached,
3044 "fixture child must escape the owned group"
3045 );
3046 }
3047
3048 fn assert_cleaned(&self) {
3049 self.assert_detached();
3050 assert!(
3053 self.path("cleaned").exists(),
3054 "SIGINT handler must drain both large writes and clean its child; stage={:?}",
3055 std::fs::read_to_string(self.path("cleanup-stage")).ok()
3056 );
3057 assert_pid_killed(try_read_pid(&self.path("provider")).unwrap());
3058 assert_pid_killed(try_read_pid(&self.path("detached")).unwrap());
3059 }
3060 }
3061
3062 impl Drop for InterruptFixture {
3063 fn drop(&mut self) {
3064 if let Some(pid) = try_read_pid(&self.path("detached")) {
3067 let _ = std::process::Command::new("kill")
3068 .args(["-9", &pid.to_string()])
3069 .output();
3070 }
3071 }
3072 }
3073
3074 #[cfg(feature = "async")]
3075 #[tokio::test]
3076 async fn interrupt_cancellation_drains_large_output_and_detached_cleanup() {
3077 for stdin in [false, true] {
3078 let fixture = InterruptFixture::new();
3079 let claude = fixture.client();
3080 let result = if stdin {
3081 run_claude_with_stdin_prompt_cancellable(
3082 &claude,
3083 vec![],
3084 "prompt".into(),
3085 fixture.ready(),
3086 )
3087 .await
3088 } else {
3089 run_claude_cancellable(&claude, vec![], fixture.ready()).await
3090 };
3091 assert!(matches!(result, Err(Error::Cancelled)), "{result:?}");
3092 fixture.assert_cleaned();
3093 }
3094 }
3095
3096 #[cfg(all(feature = "async", feature = "json"))]
3097 #[tokio::test]
3098 async fn interrupt_stream_cancellation_drains_without_delivering_shutdown_output() {
3099 for stdin in [false, true] {
3100 let fixture = InterruptFixture::new();
3101 let claude = fixture.client();
3102 let mut cmd = crate::QueryCommand::new("prompt");
3103 if stdin {
3104 cmd = cmd.prompt_via_stdin(true);
3105 }
3106 let mut events = 0;
3107 let result =
3108 crate::streaming::stream_query_cancellable(&claude, &cmd, fixture.ready(), |_| {
3109 events += 1
3110 })
3111 .await;
3112 assert!(matches!(result, Err(Error::Cancelled)), "{result:?}");
3113 assert_eq!(events, 0);
3114 fixture.assert_cleaned();
3115 }
3116 }
3117
3118 #[cfg(feature = "async")]
3119 #[tokio::test]
3120 async fn interrupt_process_group_opt_out_skips_signal_and_grace() {
3121 let fixture = InterruptFixture::new();
3122 let mut claude = fixture.client();
3123 claude.process_group = false;
3124 claude.kill_grace = Some(Duration::from_secs(30));
3125 claude.timeout = Some(Duration::from_millis(500));
3126 let started = std::time::Instant::now();
3127 let result = run_claude(&claude, vec![]).await;
3128 assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
3129 assert!(started.elapsed() < Duration::from_secs(5));
3130 fixture.assert_detached();
3131 assert!(
3132 !fixture.path("cleaned").exists(),
3133 "soft signal must be skipped"
3134 );
3135 assert_pid_killed(try_read_pid(&fixture.path("provider")).unwrap());
3136 let detached = try_read_pid(&fixture.path("detached")).unwrap();
3137 assert_eq!(unsafe { libc::kill(detached as i32, 0) }, 0);
3139 }
3141
3142 #[cfg(feature = "async")]
3143 #[tokio::test]
3144 async fn interrupt_timeout_stays_timeout_after_clean_exit() {
3145 let fixture = InterruptFixture::new();
3146 let mut claude = fixture.client();
3147 claude.timeout = Some(Duration::from_millis(600));
3148 let result = run_claude(&claude, vec![]).await;
3149 assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
3150 fixture.assert_cleaned();
3151 }
3152
3153 #[cfg(all(feature = "async", feature = "json"))]
3154 #[tokio::test]
3155 async fn interrupt_duplex_close_overrun_drains_and_cleans_detached_child() {
3156 let fixture = InterruptFixture::new();
3157 let session =
3158 crate::DuplexSession::spawn(&fixture.client(), crate::DuplexOptions::default())
3159 .await
3160 .unwrap();
3161 fixture.ready().await;
3162 session.close().await.unwrap();
3163 fixture.assert_cleaned();
3164 }
3165
3166 #[cfg(feature = "sync")]
3167 #[test]
3168 fn interrupt_sync_timeout_drains_large_output_and_detached_cleanup() {
3169 for stdin in [false, true] {
3170 let fixture = InterruptFixture::new();
3171 let mut claude = fixture.client();
3172 claude.timeout = Some(Duration::from_millis(600));
3173 let result = if stdin {
3174 run_claude_with_stdin_prompt_sync(&claude, vec![], "prompt".into())
3175 } else {
3176 run_claude_sync(&claude, vec![])
3177 };
3178 assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
3179 fixture.assert_cleaned();
3180 }
3181 }
3182
3183 #[cfg(all(feature = "sync", feature = "json"))]
3184 #[test]
3185 fn interrupt_sync_stream_timeout_drains_large_output_and_detached_cleanup() {
3186 let fixture = InterruptFixture::new();
3187 let mut claude = fixture.client();
3188 claude.timeout = Some(Duration::from_millis(600));
3189 let mut events = 0;
3190 let result = crate::streaming::stream_query_sync(
3191 &claude,
3192 &crate::QueryCommand::new("prompt"),
3193 |_| events += 1,
3194 );
3195 assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
3196 assert_eq!(events, 0);
3197 fixture.assert_cleaned();
3198 }
3199
3200 #[cfg(feature = "async")]
3201 #[tokio::test]
3202 async fn interrupt_stubborn_group_escalates_and_drop_during_grace_is_immediate() {
3203 for drop_during_grace in [false, true] {
3204 let dir = tempfile::tempdir().unwrap();
3205 let ready = dir.path().join("ready");
3206 let signalled = dir.path().join("signalled");
3207 let child_pid = dir.path().join("child");
3208 let provider_pid = dir.path().join("provider");
3209 let (_script_dir, binary) = fake_script(&format!(
3210 "trap 'echo int > \"{}\"' INT\nsleep 30 &\necho $! > \"{}\"\necho $$ > \"{}\"\necho ready > \"{}\"\nwhile :; do wait; done",
3211 signalled.display(),
3212 child_pid.display(),
3213 provider_pid.display(),
3214 ready.display(),
3215 ));
3216 let claude = Claude::builder()
3217 .binary(binary)
3218 .termination_signal(crate::TerminationSignal::Interrupt)
3219 .kill_grace(if drop_during_grace {
3220 Duration::from_secs(30)
3221 } else {
3222 Duration::from_millis(200)
3223 })
3224 .build()
3225 .unwrap();
3226 let cancel = async {
3227 while !ready.exists() {
3228 tokio::time::sleep(Duration::from_millis(5)).await;
3229 }
3230 };
3231 let started = std::time::Instant::now();
3232 {
3233 let run = run_claude_cancellable(&claude, vec![], cancel);
3234 tokio::pin!(run);
3235 if drop_during_grace {
3236 tokio::select! {
3237 result = &mut run => panic!("unexpected completion: {result:?}"),
3238 () = async {
3239 while !signalled.exists() { tokio::time::sleep(Duration::from_millis(5)).await; }
3240 } => {}
3241 }
3242 } else {
3244 assert!(matches!(run.await, Err(Error::Cancelled)));
3245 assert!(signalled.exists());
3246 }
3247 }
3248 assert!(started.elapsed() < Duration::from_secs(5));
3249 assert_pid_killed(read_pid(&provider_pid));
3250 assert_pid_killed(read_pid(&child_pid));
3251 }
3252 }
3253
3254 #[cfg(feature = "async")]
3255 #[tokio::test]
3256 async fn interrupt_leader_exit_with_open_pipe_still_times_out_safely() {
3257 let dir = tempfile::tempdir().unwrap();
3258 let child_pid = dir.path().join("child");
3259 let (_script_dir, binary) = fake_script(&format!(
3260 "sleep 30 &\necho $! > '{}'\nexit 0",
3261 child_pid.display()
3262 ));
3263 let claude = Claude::builder()
3264 .binary(binary)
3265 .termination_signal(crate::TerminationSignal::Interrupt)
3266 .kill_grace(Duration::from_millis(100))
3267 .timeout(Duration::from_millis(300))
3268 .build()
3269 .unwrap();
3270 let result = run_claude(&claude, vec![]).await;
3271 assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
3272 assert_pid_killed(read_pid(&child_pid));
3273 }
3274
3275 #[cfg(feature = "sync")]
3276 #[test]
3277 fn interrupt_sync_output_breach_with_exited_leader_is_settled() {
3278 let dir = tempfile::tempdir().unwrap();
3279 let child_pid = dir.path().join("child");
3280 let (_script_dir, binary) = fake_script(&format!(
3281 "sleep 30 2>/dev/null &\necho $! > '{}'\nhead -c 65536 /dev/zero\nexit 0",
3282 child_pid.display()
3283 ));
3284 let claude = Claude::builder()
3285 .binary(binary)
3286 .termination_signal(crate::TerminationSignal::Interrupt)
3287 .kill_grace(Duration::from_millis(100))
3288 .output_limit(4096)
3289 .timeout(Duration::from_secs(3))
3290 .build()
3291 .unwrap();
3292 let started = std::time::Instant::now();
3293 let result = run_claude_sync(&claude, vec![]);
3294 assert!(
3295 matches!(result, Err(Error::OutputLimitExceeded { .. })),
3296 "{result:?}"
3297 );
3298 assert!(started.elapsed() < Duration::from_secs(2));
3299 assert_pid_killed(try_read_pid(&child_pid).unwrap());
3300 }
3301
3302 fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
3308 fake_script(&format!(
3309 concat!(
3310 "trap 'echo term > \"{m}\"; exit 0' TERM\n",
3311 "sleep 300 &\n",
3312 "wait $!",
3313 ),
3314 m = marker.display(),
3315 ))
3316 }
3317
3318 #[cfg(feature = "async")]
3324 #[tokio::test]
3325 async fn async_timeout_with_grace_delivers_sigterm_first() {
3326 let mut observed = false;
3327 for _ in 0..5 {
3328 let workdir = tempfile::tempdir().expect("workdir");
3329 let marker = workdir.path().join("term-marker");
3330 let (_dir, path) = term_trap_script(&marker);
3331 let claude = Claude::builder()
3332 .binary(&path)
3333 .timeout(Duration::from_millis(500))
3334 .kill_grace(Duration::from_secs(1))
3335 .build()
3336 .expect("build");
3337 let err = run_claude(&claude, vec![]).await.unwrap_err();
3338 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
3339 if marker.exists() {
3340 observed = true;
3341 break;
3342 }
3343 }
3344 assert!(observed, "TERM marker never appeared within 5 timeout runs");
3345 }
3346
3347 #[cfg(feature = "sync")]
3349 #[test]
3350 fn sync_timeout_with_grace_delivers_sigterm_first() {
3351 let mut observed = false;
3352 for _ in 0..5 {
3353 let workdir = tempfile::tempdir().expect("workdir");
3354 let marker = workdir.path().join("term-marker");
3355 let (_dir, path) = term_trap_script(&marker);
3356 let claude = Claude::builder()
3357 .binary(&path)
3358 .timeout(Duration::from_millis(500))
3359 .kill_grace(Duration::from_secs(1))
3360 .build()
3361 .expect("build");
3362 let err = run_claude_sync(&claude, vec![]).unwrap_err();
3363 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
3364 if marker.exists() {
3365 observed = true;
3366 break;
3367 }
3368 }
3369 assert!(observed, "TERM marker never appeared within 5 timeout runs");
3370 }
3371
3372 #[cfg(feature = "async")]
3374 #[tokio::test]
3375 async fn async_dropping_in_flight_stdin_future_kills_child() {
3376 let workdir = tempfile::tempdir().expect("workdir");
3377 let pid_path = workdir.path().join("pid");
3378 let (_dir, path) = fake_script(&format!(
3379 r#"echo $$ > "{}"; exec sleep 30"#,
3380 pid_path.display()
3381 ));
3382 let claude = client(&path);
3383 let pid = drop_in_flight_and_capture_pid(
3384 run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
3385 &pid_path,
3386 )
3387 .await;
3388 assert_pid_killed(pid);
3389 }
3390
3391 #[cfg(feature = "async")]
3392 #[tokio::test]
3393 async fn async_spawn_failure_maps_io() {
3394 let claude = Claude::builder()
3395 .binary("/nonexistent/definitely/not/here")
3396 .build()
3397 .expect("build");
3398 let err = run_claude(&claude, vec![]).await.unwrap_err();
3399 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
3400 }
3401
3402 #[cfg(feature = "sync")]
3405 #[test]
3406 fn sync_success_maps_output() {
3407 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
3408 let out = run_claude_sync(&client(&path), vec![]).expect("success");
3409 assert!(out.success);
3410 assert!(out.stdout.contains("hi sync"));
3411 }
3412
3413 #[cfg(feature = "sync")]
3414 #[test]
3415 fn sync_nonzero_exit_maps_command_failed() {
3416 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
3417 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
3418 match err {
3419 Error::CommandFailed {
3420 exit_code, stderr, ..
3421 } => {
3422 assert_eq!(exit_code, 3);
3423 assert!(stderr.contains("boom"));
3424 }
3425 other => panic!("expected CommandFailed, got {other:?}"),
3426 }
3427 }
3428
3429 #[cfg(feature = "sync")]
3430 #[test]
3431 fn sync_scrubs_claude_env_vars() {
3432 let (_dir, path) =
3433 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
3434 set_scrub_vars();
3435 let out = run_claude_sync(&client(&path), vec![]).expect("success");
3436 clear_scrub_vars();
3437 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
3438 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
3439 }
3440
3441 #[cfg(feature = "sync")]
3442 #[test]
3443 fn sync_stdin_prompt_round_trips() {
3444 let (_dir, path) = fake_script(r#"cat"#);
3445 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
3446 .expect("success");
3447 assert!(out.stdout.contains("sync stdin"));
3448 }
3449
3450 #[cfg(feature = "sync")]
3453 #[test]
3454 fn sync_spawn_retry_passes_through_non_txtbsy_error() {
3455 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
3456 let err =
3457 spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
3458 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
3459 }
3460
3461 #[cfg(feature = "sync")]
3462 #[test]
3463 fn sync_allow_exit_codes_permits_listed_code() {
3464 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
3465 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
3466 .expect("allowed code is Ok");
3467 assert!(!out.success);
3468 assert_eq!(out.exit_code, 2);
3469 }
3470
3471 #[cfg(feature = "sync")]
3472 #[test]
3473 fn sync_timeout_fires_on_slow_child() {
3474 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
3475 let claude = Claude::builder()
3476 .binary(&path)
3477 .timeout(Duration::from_millis(300))
3478 .build()
3479 .expect("build");
3480 let err = run_claude_sync(&claude, vec![]).unwrap_err();
3481 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
3482 }
3483
3484 #[cfg(feature = "sync")]
3488 #[test]
3489 fn sync_timeout_kills_process_group() {
3490 let mut observed = false;
3491 for _ in 0..5 {
3492 let workdir = tempfile::tempdir().expect("workdir");
3493 let pid_path = workdir.path().join("pid");
3494 let gpid_path = workdir.path().join("gpid");
3495 let (_dir, path) = group_script(&pid_path, &gpid_path);
3496 let claude = Claude::builder()
3497 .binary(&path)
3498 .timeout(Duration::from_millis(1000))
3499 .build()
3500 .expect("build");
3501 let err = run_claude_sync(&claude, vec![]).unwrap_err();
3502 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
3503 if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
3504 assert_pid_killed(pid);
3505 assert_pid_killed(gpid);
3506 observed = true;
3507 break;
3508 }
3509 }
3510 assert!(observed, "child never recorded pids within 5 timeout runs");
3511 }
3512
3513 #[cfg(feature = "sync")]
3514 #[test]
3515 fn sync_timeout_path_returns_output_when_fast() {
3516 let (_dir, path) = fake_script(r#"echo quick"#);
3517 let claude = Claude::builder()
3518 .binary(&path)
3519 .timeout(Duration::from_secs(30))
3520 .build()
3521 .expect("build");
3522 let out = run_claude_sync(&claude, vec![]).expect("success");
3523 assert!(out.stdout.contains("quick"));
3524 }
3525
3526 #[cfg(feature = "sync")]
3527 #[test]
3528 fn sync_stdin_with_timeout_round_trips() {
3529 let (_dir, path) = fake_script(r#"cat"#);
3530 let claude = Claude::builder()
3531 .binary(&path)
3532 .timeout(Duration::from_secs(30))
3533 .build()
3534 .expect("build");
3535 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
3536 .expect("success");
3537 assert!(out.stdout.contains("sync piped"));
3538 }
3539}