1use std::time::Duration;
12
13#[cfg(feature = "async")]
14use tokio::io::AsyncReadExt;
15#[cfg(feature = "async")]
16use tokio::process::Command;
17use tracing::{debug, warn};
18
19use crate::Claude;
20use crate::error::{Error, Result};
21
22pub(crate) fn full_command_args(claude: &Claude, args: Vec<String>) -> Vec<String> {
29 let mut command_args = claude.global_args.clone();
30 command_args.extend(args);
31 command_args
32}
33
34#[derive(Debug, Clone)]
36pub struct CommandOutput {
37 pub stdout: String,
39 pub stderr: String,
41 pub exit_code: i32,
43 pub success: bool,
45}
46
47#[cfg(feature = "async")]
53pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
54 run_claude_with_retry(claude, args, None).await
55}
56
57#[cfg(feature = "async")]
59pub async fn run_claude_with_retry(
60 claude: &Claude,
61 args: Vec<String>,
62 retry_override: Option<&crate::retry::RetryPolicy>,
63) -> Result<CommandOutput> {
64 let policy = retry_override.or(claude.retry_policy.as_ref());
65
66 match policy {
67 Some(policy) => {
68 crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
69 }
70 None => run_claude_once(claude, args).await,
71 }
72}
73
74#[cfg(feature = "async")]
80pub async fn run_claude_with_stdin_prompt(
81 claude: &Claude,
82 args: Vec<String>,
83 stdin_content: String,
84) -> Result<CommandOutput> {
85 run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
86}
87
88#[cfg(feature = "async")]
89async fn run_claude_with_stdin_prompt_internal(
90 claude: &Claude,
91 args: Vec<String>,
92 stdin_content: String,
93) -> Result<CommandOutput> {
94 let command_args = full_command_args(claude, args);
95
96 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
97
98 let binary = &claude.binary;
99 let env = &claude.env;
100 let working_dir = claude.working_dir.as_deref();
101
102 if let Some(timeout) = claude.timeout {
103 run_with_timeout_stdin(
104 binary,
105 &command_args,
106 env,
107 working_dir,
108 timeout,
109 stdin_content,
110 )
111 .await
112 } else {
113 run_internal_stdin(binary, &command_args, env, working_dir, stdin_content).await
114 }
115}
116
117#[cfg(feature = "async")]
118async fn run_internal_stdin(
119 binary: &std::path::Path,
120 args: &[String],
121 env: &std::collections::HashMap<String, String>,
122 working_dir: Option<&std::path::Path>,
123 stdin_content: String,
124) -> Result<CommandOutput> {
125 use tokio::io::AsyncWriteExt;
126
127 let mut cmd = Command::new(binary);
128 cmd.args(args);
129 cmd.stdin(std::process::Stdio::piped());
130 cmd.stdout(std::process::Stdio::piped());
131 cmd.stderr(std::process::Stdio::piped());
132 cmd.env_remove("CLAUDECODE");
133 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
134
135 if let Some(dir) = working_dir {
136 cmd.current_dir(dir);
137 }
138
139 for (key, value) in env {
140 cmd.env(key, value);
141 }
142
143 let mut child = spawn_retrying_txtbsy(&mut cmd)
144 .await
145 .map_err(|e| Error::Io {
146 message: format!("failed to spawn claude: {e}"),
147 source: e,
148 working_dir: working_dir.map(|p| p.to_path_buf()),
149 })?;
150
151 if let Some(mut stdin) = child.stdin.take() {
153 stdin
154 .write_all(stdin_content.as_bytes())
155 .await
156 .map_err(|e| Error::Io {
157 message: format!("failed to write to claude stdin: {e}"),
158 source: e,
159 working_dir: working_dir.map(|p| p.to_path_buf()),
160 })?;
161 }
163
164 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
165 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
166
167 let (status, stdout_str, stderr_str) = tokio::join!(
168 child.wait(),
169 drain(&mut stdout_handle),
170 drain(&mut stderr_handle),
171 );
172
173 let status = status.map_err(|e| Error::Io {
174 message: "failed to wait for claude process".to_string(),
175 source: e,
176 working_dir: working_dir.map(|p| p.to_path_buf()),
177 })?;
178
179 let exit_code = status.code().unwrap_or(-1);
180
181 if !status.success() {
182 return Err(Error::from_command_failure(
183 format!("{} {}", binary.display(), args.join(" ")),
184 exit_code,
185 stdout_str,
186 stderr_str,
187 working_dir.map(|p| p.to_path_buf()),
188 ));
189 }
190
191 Ok(CommandOutput {
192 stdout: stdout_str,
193 stderr: stderr_str,
194 exit_code,
195 success: true,
196 })
197}
198
199#[cfg(feature = "async")]
200async fn run_with_timeout_stdin(
201 binary: &std::path::Path,
202 args: &[String],
203 env: &std::collections::HashMap<String, String>,
204 working_dir: Option<&std::path::Path>,
205 timeout: Duration,
206 stdin_content: String,
207) -> Result<CommandOutput> {
208 use tokio::io::AsyncWriteExt;
209
210 let mut cmd = Command::new(binary);
211 cmd.args(args);
212 cmd.stdin(std::process::Stdio::piped());
213 cmd.stdout(std::process::Stdio::piped());
214 cmd.stderr(std::process::Stdio::piped());
215 cmd.env_remove("CLAUDECODE");
216 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
217
218 if let Some(dir) = working_dir {
219 cmd.current_dir(dir);
220 }
221
222 for (key, value) in env {
223 cmd.env(key, value);
224 }
225
226 let mut child = spawn_retrying_txtbsy(&mut cmd)
227 .await
228 .map_err(|e| Error::Io {
229 message: format!("failed to spawn claude: {e}"),
230 source: e,
231 working_dir: working_dir.map(|p| p.to_path_buf()),
232 })?;
233
234 if let Some(mut stdin) = child.stdin.take() {
236 stdin
237 .write_all(stdin_content.as_bytes())
238 .await
239 .map_err(|e| Error::Io {
240 message: format!("failed to write to claude stdin: {e}"),
241 source: e,
242 working_dir: working_dir.map(|p| p.to_path_buf()),
243 })?;
244 }
246
247 let mut stdout_handle = child.stdout.take().expect("stdout was piped");
248 let mut stderr_handle = child.stderr.take().expect("stderr was piped");
249
250 let wait_and_drain = async {
251 let (status, stdout_str, stderr_str) = tokio::join!(
252 child.wait(),
253 drain(&mut stdout_handle),
254 drain(&mut stderr_handle),
255 );
256 (status, stdout_str, stderr_str)
257 };
258
259 match tokio::time::timeout(timeout, wait_and_drain).await {
260 Ok((Ok(status), stdout, stderr)) => {
261 let exit_code = status.code().unwrap_or(-1);
262
263 if !status.success() {
264 return Err(Error::from_command_failure(
265 format!("{} {}", binary.display(), args.join(" ")),
266 exit_code,
267 stdout,
268 stderr,
269 working_dir.map(|p| p.to_path_buf()),
270 ));
271 }
272
273 Ok(CommandOutput {
274 stdout,
275 stderr,
276 exit_code,
277 success: true,
278 })
279 }
280 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
281 message: "failed to wait for claude process".to_string(),
282 source: e,
283 working_dir: working_dir.map(|p| p.to_path_buf()),
284 }),
285 Err(_) => {
286 let _ = child.kill().await;
287 let drain_budget = Duration::from_millis(200);
288 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
289 .await
290 .unwrap_or_default();
291 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
292 .await
293 .unwrap_or_default();
294 if !stdout_str.is_empty() || !stderr_str.is_empty() {
295 warn!(
296 stdout = %stdout_str,
297 stderr = %stderr_str,
298 "partial output from timed-out process",
299 );
300 }
301 Err(Error::Timeout {
302 timeout_seconds: timeout.as_secs(),
303 })
304 }
305 }
306}
307
308#[cfg(feature = "async")]
309async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
310 let command_args = full_command_args(claude, args);
311
312 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
313
314 let output = if let Some(timeout) = claude.timeout {
315 run_with_timeout(
316 &claude.binary,
317 &command_args,
318 &claude.env,
319 claude.working_dir.as_deref(),
320 timeout,
321 )
322 .await?
323 } else {
324 run_internal(
325 &claude.binary,
326 &command_args,
327 &claude.env,
328 claude.working_dir.as_deref(),
329 )
330 .await?
331 };
332
333 Ok(output)
334}
335
336#[cfg(feature = "async")]
338pub async fn run_claude_allow_exit_codes(
339 claude: &Claude,
340 args: Vec<String>,
341 allowed_codes: &[i32],
342) -> Result<CommandOutput> {
343 let output = run_claude(claude, args).await;
344
345 match output {
346 Err(Error::CommandFailed {
347 exit_code,
348 stdout,
349 stderr,
350 ..
351 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
352 stdout,
353 stderr,
354 exit_code,
355 success: false,
356 }),
357 other => other,
358 }
359}
360
361#[cfg(feature = "async")]
362async fn run_internal(
363 binary: &std::path::Path,
364 args: &[String],
365 env: &std::collections::HashMap<String, String>,
366 working_dir: Option<&std::path::Path>,
367) -> Result<CommandOutput> {
368 let mut cmd = Command::new(binary);
369 cmd.args(args);
370
371 cmd.stdin(std::process::Stdio::null());
373
374 cmd.env_remove("CLAUDECODE");
376 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
377
378 if let Some(dir) = working_dir {
379 cmd.current_dir(dir);
380 }
381
382 for (key, value) in env {
383 cmd.env(key, value);
384 }
385
386 let output = output_retrying_txtbsy(&mut cmd)
387 .await
388 .map_err(|e| Error::Io {
389 message: format!("failed to spawn claude: {e}"),
390 source: e,
391 working_dir: working_dir.map(|p| p.to_path_buf()),
392 })?;
393
394 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
395 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
396 let exit_code = output.status.code().unwrap_or(-1);
397
398 if !output.status.success() {
399 return Err(Error::from_command_failure(
400 format!("{} {}", binary.display(), args.join(" ")),
401 exit_code,
402 stdout,
403 stderr,
404 working_dir.map(|p| p.to_path_buf()),
405 ));
406 }
407
408 Ok(CommandOutput {
409 stdout,
410 stderr,
411 exit_code,
412 success: true,
413 })
414}
415
416#[cfg(feature = "async")]
428async fn run_with_timeout(
429 binary: &std::path::Path,
430 args: &[String],
431 env: &std::collections::HashMap<String, String>,
432 working_dir: Option<&std::path::Path>,
433 timeout: Duration,
434) -> Result<CommandOutput> {
435 let mut cmd = Command::new(binary);
436 cmd.args(args);
437 cmd.stdin(std::process::Stdio::null());
438 cmd.stdout(std::process::Stdio::piped());
439 cmd.stderr(std::process::Stdio::piped());
440 cmd.env_remove("CLAUDECODE");
441 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
442
443 if let Some(dir) = working_dir {
444 cmd.current_dir(dir);
445 }
446
447 for (key, value) in env {
448 cmd.env(key, value);
449 }
450
451 let mut child = spawn_retrying_txtbsy(&mut cmd)
452 .await
453 .map_err(|e| Error::Io {
454 message: format!("failed to spawn claude: {e}"),
455 source: e,
456 working_dir: working_dir.map(|p| p.to_path_buf()),
457 })?;
458
459 let mut stdout = child.stdout.take().expect("stdout was piped");
460 let mut stderr = child.stderr.take().expect("stderr was piped");
461
462 let wait_and_drain = async {
467 let (status, stdout_str, stderr_str) =
468 tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
469 (status, stdout_str, stderr_str)
470 };
471
472 match tokio::time::timeout(timeout, wait_and_drain).await {
473 Ok((Ok(status), stdout, stderr)) => {
474 let exit_code = status.code().unwrap_or(-1);
475
476 if !status.success() {
477 return Err(Error::from_command_failure(
478 format!("{} {}", binary.display(), args.join(" ")),
479 exit_code,
480 stdout,
481 stderr,
482 working_dir.map(|p| p.to_path_buf()),
483 ));
484 }
485
486 Ok(CommandOutput {
487 stdout,
488 stderr,
489 exit_code,
490 success: true,
491 })
492 }
493 Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
494 message: "failed to wait for claude process".to_string(),
495 source: e,
496 working_dir: working_dir.map(|p| p.to_path_buf()),
497 }),
498 Err(_) => {
499 let _ = child.kill().await;
505 let drain_budget = Duration::from_millis(200);
506 let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
507 .await
508 .unwrap_or_default();
509 let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
510 .await
511 .unwrap_or_default();
512 if !stdout_str.is_empty() || !stderr_str.is_empty() {
513 warn!(
514 stdout = %stdout_str,
515 stderr = %stderr_str,
516 "partial output from timed-out process",
517 );
518 }
519 Err(Error::Timeout {
520 timeout_seconds: timeout.as_secs(),
521 })
522 }
523 }
524}
525
526#[cfg(feature = "async")]
527async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
528 let mut buf = Vec::new();
529 let _ = reader.read_to_end(&mut buf).await;
530 String::from_utf8_lossy(&buf).into_owned()
531}
532
533#[cfg(any(feature = "async", feature = "sync"))]
542const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
543
544#[cfg(any(feature = "async", feature = "sync"))]
551const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
552
553#[cfg(feature = "async")]
564async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
565 let start = std::time::Instant::now();
566 let mut backoff = Duration::from_millis(1);
567 loop {
568 match cmd.spawn() {
569 Err(e)
570 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
571 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
572 {
573 tokio::time::sleep(backoff).await;
574 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
575 }
576 other => return other,
577 }
578 }
579}
580
581#[cfg(feature = "async")]
589async fn output_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<std::process::Output> {
590 let start = std::time::Instant::now();
591 let mut backoff = Duration::from_millis(1);
592 loop {
593 match cmd.output().await {
594 Err(e)
595 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
596 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
597 {
598 tokio::time::sleep(backoff).await;
599 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
600 }
601 other => return other,
602 }
603 }
604}
605
606#[cfg(feature = "sync")]
610pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
611 run_claude_with_retry_sync(claude, args, None)
612}
613
614#[cfg(feature = "sync")]
616pub fn run_claude_with_retry_sync(
617 claude: &Claude,
618 args: Vec<String>,
619 retry_override: Option<&crate::retry::RetryPolicy>,
620) -> Result<CommandOutput> {
621 let policy = retry_override.or(claude.retry_policy.as_ref());
622
623 match policy {
624 Some(policy) => {
625 crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
626 }
627 None => run_claude_once_sync(claude, args),
628 }
629}
630
631#[cfg(feature = "sync")]
636pub fn run_claude_with_stdin_prompt_sync(
637 claude: &Claude,
638 args: Vec<String>,
639 stdin_content: String,
640) -> Result<CommandOutput> {
641 let command_args = full_command_args(claude, args);
642
643 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
644
645 if let Some(timeout) = claude.timeout {
646 run_with_timeout_stdin_sync(
647 &claude.binary,
648 &command_args,
649 &claude.env,
650 claude.working_dir.as_deref(),
651 timeout,
652 stdin_content,
653 )
654 } else {
655 run_internal_stdin_sync(
656 &claude.binary,
657 &command_args,
658 &claude.env,
659 claude.working_dir.as_deref(),
660 stdin_content,
661 )
662 }
663}
664
665#[cfg(feature = "sync")]
666fn run_internal_stdin_sync(
667 binary: &std::path::Path,
668 args: &[String],
669 env: &std::collections::HashMap<String, String>,
670 working_dir: Option<&std::path::Path>,
671 stdin_content: String,
672) -> Result<CommandOutput> {
673 use std::io::Write;
674 use std::process::{Command as StdCommand, Stdio};
675
676 let mut cmd = StdCommand::new(binary);
677 cmd.args(args);
678 cmd.stdin(Stdio::piped());
679 cmd.stdout(Stdio::piped());
680 cmd.stderr(Stdio::piped());
681 cmd.env_remove("CLAUDECODE");
682 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
683
684 if let Some(dir) = working_dir {
685 cmd.current_dir(dir);
686 }
687
688 for (key, value) in env {
689 cmd.env(key, value);
690 }
691
692 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
693 message: format!("failed to spawn claude: {e}"),
694 source: e,
695 working_dir: working_dir.map(|p| p.to_path_buf()),
696 })?;
697
698 if let Some(mut stdin) = child.stdin.take() {
700 stdin
701 .write_all(stdin_content.as_bytes())
702 .map_err(|e| Error::Io {
703 message: format!("failed to write to claude stdin: {e}"),
704 source: e,
705 working_dir: working_dir.map(|p| p.to_path_buf()),
706 })?;
707 stdin.flush().map_err(|e| Error::Io {
708 message: format!("failed to flush claude stdin: {e}"),
709 source: e,
710 working_dir: working_dir.map(|p| p.to_path_buf()),
711 })?;
712 }
714
715 let output = child.wait_with_output().map_err(|e| Error::Io {
716 message: "failed to wait for claude process".to_string(),
717 source: e,
718 working_dir: working_dir.map(|p| p.to_path_buf()),
719 })?;
720
721 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
722 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
723 let exit_code = output.status.code().unwrap_or(-1);
724
725 if !output.status.success() {
726 return Err(Error::from_command_failure(
727 format!("{} {}", binary.display(), args.join(" ")),
728 exit_code,
729 stdout,
730 stderr,
731 working_dir.map(|p| p.to_path_buf()),
732 ));
733 }
734
735 Ok(CommandOutput {
736 stdout,
737 stderr,
738 exit_code,
739 success: true,
740 })
741}
742
743#[cfg(feature = "sync")]
744fn run_with_timeout_stdin_sync(
745 binary: &std::path::Path,
746 args: &[String],
747 env: &std::collections::HashMap<String, String>,
748 working_dir: Option<&std::path::Path>,
749 timeout: Duration,
750 stdin_content: String,
751) -> Result<CommandOutput> {
752 use std::io::Write;
753 use std::process::{Command as StdCommand, Stdio};
754 use std::thread;
755 use wait_timeout::ChildExt;
756
757 let mut cmd = StdCommand::new(binary);
758 cmd.args(args);
759 cmd.stdin(Stdio::piped());
760 cmd.stdout(Stdio::piped());
761 cmd.stderr(Stdio::piped());
762 cmd.env_remove("CLAUDECODE");
763 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
764
765 if let Some(dir) = working_dir {
766 cmd.current_dir(dir);
767 }
768
769 for (key, value) in env {
770 cmd.env(key, value);
771 }
772
773 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
774 message: format!("failed to spawn claude: {e}"),
775 source: e,
776 working_dir: working_dir.map(|p| p.to_path_buf()),
777 })?;
778
779 if let Some(mut stdin) = child.stdin.take() {
781 stdin
782 .write_all(stdin_content.as_bytes())
783 .map_err(|e| Error::Io {
784 message: format!("failed to write to claude stdin: {e}"),
785 source: e,
786 working_dir: working_dir.map(|p| p.to_path_buf()),
787 })?;
788 stdin.flush().map_err(|e| Error::Io {
789 message: format!("failed to flush claude stdin: {e}"),
790 source: e,
791 working_dir: working_dir.map(|p| p.to_path_buf()),
792 })?;
793 }
795
796 let stdout = child.stdout.take().expect("stdout was piped");
797 let stderr = child.stderr.take().expect("stderr was piped");
798
799 let stdout_thread = thread::spawn(move || drain_sync(stdout));
800 let stderr_thread = thread::spawn(move || drain_sync(stderr));
801
802 match child.wait_timeout(timeout).map_err(|e| Error::Io {
803 message: "failed to wait for claude process".to_string(),
804 source: e,
805 working_dir: working_dir.map(|p| p.to_path_buf()),
806 })? {
807 Some(status) => {
808 let stdout = stdout_thread.join().unwrap_or_default();
809 let stderr = stderr_thread.join().unwrap_or_default();
810 let exit_code = status.code().unwrap_or(-1);
811
812 if !status.success() {
813 return Err(Error::from_command_failure(
814 format!("{} {}", binary.display(), args.join(" ")),
815 exit_code,
816 stdout,
817 stderr,
818 working_dir.map(|p| p.to_path_buf()),
819 ));
820 }
821
822 Ok(CommandOutput {
823 stdout,
824 stderr,
825 exit_code,
826 success: true,
827 })
828 }
829 None => {
830 let _ = child.kill();
831 let _ = child.wait();
832 let (stdout_str, stderr_str) =
833 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
834 if !stdout_str.is_empty() || !stderr_str.is_empty() {
835 warn!(
836 stdout = %stdout_str,
837 stderr = %stderr_str,
838 "partial output from timed-out process",
839 );
840 }
841 Err(Error::Timeout {
842 timeout_seconds: timeout.as_secs(),
843 })
844 }
845 }
846}
847
848#[cfg(feature = "sync")]
849fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
850 let command_args = full_command_args(claude, args);
851
852 debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
853
854 if let Some(timeout) = claude.timeout {
855 run_with_timeout_sync(
856 &claude.binary,
857 &command_args,
858 &claude.env,
859 claude.working_dir.as_deref(),
860 timeout,
861 )
862 } else {
863 run_internal_sync(
864 &claude.binary,
865 &command_args,
866 &claude.env,
867 claude.working_dir.as_deref(),
868 )
869 }
870}
871
872#[cfg(feature = "sync")]
874pub fn run_claude_allow_exit_codes_sync(
875 claude: &Claude,
876 args: Vec<String>,
877 allowed_codes: &[i32],
878) -> Result<CommandOutput> {
879 match run_claude_sync(claude, args) {
880 Err(Error::CommandFailed {
881 exit_code,
882 stdout,
883 stderr,
884 ..
885 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
886 stdout,
887 stderr,
888 exit_code,
889 success: false,
890 }),
891 other => other,
892 }
893}
894
895#[cfg(feature = "sync")]
896fn run_internal_sync(
897 binary: &std::path::Path,
898 args: &[String],
899 env: &std::collections::HashMap<String, String>,
900 working_dir: Option<&std::path::Path>,
901) -> Result<CommandOutput> {
902 use std::process::{Command as StdCommand, Stdio};
903
904 let mut cmd = StdCommand::new(binary);
905 cmd.args(args);
906 cmd.stdin(Stdio::null());
907 cmd.env_remove("CLAUDECODE");
908 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
909
910 if let Some(dir) = working_dir {
911 cmd.current_dir(dir);
912 }
913
914 for (key, value) in env {
915 cmd.env(key, value);
916 }
917
918 let output = output_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
919 message: format!("failed to spawn claude: {e}"),
920 source: e,
921 working_dir: working_dir.map(|p| p.to_path_buf()),
922 })?;
923
924 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
925 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
926 let exit_code = output.status.code().unwrap_or(-1);
927
928 if !output.status.success() {
929 return Err(Error::from_command_failure(
930 format!("{} {}", binary.display(), args.join(" ")),
931 exit_code,
932 stdout,
933 stderr,
934 working_dir.map(|p| p.to_path_buf()),
935 ));
936 }
937
938 Ok(CommandOutput {
939 stdout,
940 stderr,
941 exit_code,
942 success: true,
943 })
944}
945
946#[cfg(feature = "sync")]
953fn run_with_timeout_sync(
954 binary: &std::path::Path,
955 args: &[String],
956 env: &std::collections::HashMap<String, String>,
957 working_dir: Option<&std::path::Path>,
958 timeout: Duration,
959) -> Result<CommandOutput> {
960 use std::process::{Command as StdCommand, Stdio};
961 use std::thread;
962 use wait_timeout::ChildExt;
963
964 let mut cmd = StdCommand::new(binary);
965 cmd.args(args);
966 cmd.stdin(Stdio::null());
967 cmd.stdout(Stdio::piped());
968 cmd.stderr(Stdio::piped());
969 cmd.env_remove("CLAUDECODE");
970 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
971
972 if let Some(dir) = working_dir {
973 cmd.current_dir(dir);
974 }
975
976 for (key, value) in env {
977 cmd.env(key, value);
978 }
979
980 let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
981 message: format!("failed to spawn claude: {e}"),
982 source: e,
983 working_dir: working_dir.map(|p| p.to_path_buf()),
984 })?;
985
986 let stdout = child.stdout.take().expect("stdout was piped");
991 let stderr = child.stderr.take().expect("stderr was piped");
992
993 let stdout_thread = thread::spawn(move || drain_sync(stdout));
994 let stderr_thread = thread::spawn(move || drain_sync(stderr));
995
996 match child.wait_timeout(timeout).map_err(|e| Error::Io {
997 message: "failed to wait for claude process".to_string(),
998 source: e,
999 working_dir: working_dir.map(|p| p.to_path_buf()),
1000 })? {
1001 Some(status) => {
1002 let stdout = stdout_thread.join().unwrap_or_default();
1003 let stderr = stderr_thread.join().unwrap_or_default();
1004 let exit_code = status.code().unwrap_or(-1);
1005
1006 if !status.success() {
1007 return Err(Error::from_command_failure(
1008 format!("{} {}", binary.display(), args.join(" ")),
1009 exit_code,
1010 stdout,
1011 stderr,
1012 working_dir.map(|p| p.to_path_buf()),
1013 ));
1014 }
1015
1016 Ok(CommandOutput {
1017 stdout,
1018 stderr,
1019 exit_code,
1020 success: true,
1021 })
1022 }
1023 None => {
1024 let _ = child.kill();
1029 let _ = child.wait();
1030
1031 let (stdout_str, stderr_str) =
1032 join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1033
1034 if !stdout_str.is_empty() || !stderr_str.is_empty() {
1035 warn!(
1036 stdout = %stdout_str,
1037 stderr = %stderr_str,
1038 "partial output from timed-out process",
1039 );
1040 }
1041
1042 Err(Error::Timeout {
1043 timeout_seconds: timeout.as_secs(),
1044 })
1045 }
1046 }
1047}
1048
1049#[cfg(feature = "sync")]
1050fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
1051 let mut buf = Vec::new();
1052 let _ = reader.read_to_end(&mut buf);
1053 String::from_utf8_lossy(&buf).into_owned()
1054}
1055
1056#[cfg(feature = "sync")]
1059fn spawn_retrying_txtbsy_sync(
1060 cmd: &mut std::process::Command,
1061) -> std::io::Result<std::process::Child> {
1062 let start = std::time::Instant::now();
1063 let mut backoff = Duration::from_millis(1);
1064 loop {
1065 match cmd.spawn() {
1066 Err(e)
1067 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1068 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1069 {
1070 std::thread::sleep(backoff);
1071 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1072 }
1073 other => return other,
1074 }
1075 }
1076}
1077
1078#[cfg(feature = "sync")]
1081fn output_retrying_txtbsy_sync(
1082 cmd: &mut std::process::Command,
1083) -> std::io::Result<std::process::Output> {
1084 let start = std::time::Instant::now();
1085 let mut backoff = Duration::from_millis(1);
1086 loop {
1087 match cmd.output() {
1088 Err(e)
1089 if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1090 && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1091 {
1092 std::thread::sleep(backoff);
1093 backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1094 }
1095 other => return other,
1096 }
1097 }
1098}
1099
1100#[cfg(feature = "sync")]
1106fn join_with_deadline(
1107 stdout_thread: std::thread::JoinHandle<String>,
1108 stderr_thread: std::thread::JoinHandle<String>,
1109 budget: Duration,
1110) -> (String, String) {
1111 use std::sync::mpsc;
1112 use std::thread;
1113
1114 let (tx, rx) = mpsc::channel::<(&'static str, String)>();
1115
1116 let tx_out = tx.clone();
1117 let tx_err = tx;
1118
1119 thread::spawn(move || {
1120 let s = stdout_thread.join().unwrap_or_default();
1121 let _ = tx_out.send(("stdout", s));
1122 });
1123 thread::spawn(move || {
1124 let s = stderr_thread.join().unwrap_or_default();
1125 let _ = tx_err.send(("stderr", s));
1126 });
1127
1128 let mut stdout = String::new();
1129 let mut stderr = String::new();
1130 let deadline = std::time::Instant::now() + budget;
1131
1132 for _ in 0..2 {
1133 let now = std::time::Instant::now();
1134 if now >= deadline {
1135 break;
1136 }
1137 match rx.recv_timeout(deadline - now) {
1138 Ok(("stdout", s)) => stdout = s,
1139 Ok(("stderr", s)) => stderr = s,
1140 Ok(_) => unreachable!(),
1141 Err(_) => break,
1142 }
1143 }
1144
1145 (stdout, stderr)
1146}
1147
1148#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1155mod tests {
1156 use super::*;
1157 use std::io::Write;
1158 use std::os::unix::fs::PermissionsExt;
1159
1160 use crate::Claude;
1161
1162 fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1166 let dir = tempfile::tempdir().expect("tempdir");
1167 let path = dir.path().join("fake-claude.sh");
1168 {
1173 let mut f = std::fs::File::create(&path).expect("create script");
1174 write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1175 f.sync_all().expect("sync script");
1176 }
1177 let perms = std::fs::Permissions::from_mode(0o755);
1178 std::fs::set_permissions(&path, perms).expect("chmod");
1179 (dir, path)
1180 }
1181
1182 fn client(path: &std::path::Path) -> Claude {
1183 Claude::builder()
1184 .binary(path)
1185 .build()
1186 .expect("build client")
1187 }
1188
1189 #[test]
1190 fn full_command_args_puts_global_args_first() {
1191 let claude = Claude::builder()
1192 .binary("/usr/local/bin/claude")
1193 .arg("--debug")
1194 .arg("--verbose")
1195 .build()
1196 .expect("build client");
1197 let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
1198 assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
1199 }
1200
1201 #[test]
1202 fn full_command_args_without_global_args_is_passthrough() {
1203 let claude = Claude::builder()
1204 .binary("/usr/local/bin/claude")
1205 .build()
1206 .expect("build client");
1207 let args = full_command_args(&claude, vec!["--print".to_string()]);
1208 assert_eq!(args, ["--print"]);
1209 }
1210
1211 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1213
1214 fn set_scrub_vars() {
1215 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1216 unsafe {
1219 std::env::set_var("CLAUDECODE", "1");
1220 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1221 }
1222 }
1223
1224 fn clear_scrub_vars() {
1225 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1226 unsafe {
1228 std::env::remove_var("CLAUDECODE");
1229 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
1230 }
1231 }
1232
1233 #[cfg(feature = "async")]
1236 #[tokio::test]
1237 async fn async_success_maps_output() {
1238 let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
1239 let out = run_claude(&client(&path), vec!["--version".into()])
1240 .await
1241 .expect("success");
1242 assert!(out.success);
1243 assert_eq!(out.exit_code, 0);
1244 assert!(out.stdout.contains("hi there"));
1245 }
1246
1247 #[cfg(feature = "async")]
1248 #[tokio::test]
1249 async fn async_nonzero_exit_maps_command_failed() {
1250 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1251 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1252 match err {
1253 Error::CommandFailed {
1254 exit_code, stderr, ..
1255 } => {
1256 assert_eq!(exit_code, 3);
1257 assert!(stderr.contains("boom"));
1258 }
1259 other => panic!("expected CommandFailed, got {other:?}"),
1260 }
1261 }
1262
1263 #[cfg(feature = "async")]
1264 #[tokio::test]
1265 async fn async_rail_stop_maps_max_turns() {
1266 let (_dir, path) = fake_script(
1267 r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
1268 );
1269 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1270 assert!(
1271 matches!(
1272 err,
1273 Error::MaxTurnsExceeded {
1274 max_turns: Some(2),
1275 ..
1276 }
1277 ),
1278 "got: {err:?}"
1279 );
1280 }
1281
1282 #[cfg(feature = "async")]
1283 #[tokio::test]
1284 async fn async_auth_shaped_stderr_maps_auth() {
1285 let (_dir, path) =
1286 fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
1287 let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1288 assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
1289 }
1290
1291 #[cfg(feature = "async")]
1292 #[tokio::test]
1293 async fn async_scrubs_claude_env_vars() {
1294 let (_dir, path) =
1295 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1296 set_scrub_vars();
1302 let out = run_claude(&client(&path), vec![]).await.expect("success");
1303 clear_scrub_vars();
1304 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1305 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1306 }
1307
1308 #[cfg(feature = "async")]
1309 #[tokio::test]
1310 async fn async_applies_working_dir() {
1311 let (_dir, path) = fake_script(r#"pwd"#);
1312 let workdir = tempfile::tempdir().expect("workdir");
1313 let claude = Claude::builder()
1314 .binary(&path)
1315 .working_dir(workdir.path())
1316 .build()
1317 .expect("build");
1318 let out = run_claude(&claude, vec![]).await.expect("success");
1319 let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
1320 let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
1321 assert_eq!(got, want);
1322 }
1323
1324 #[cfg(feature = "async")]
1325 #[tokio::test]
1326 async fn async_stdin_prompt_round_trips() {
1327 let (_dir, path) = fake_script(r#"cat"#);
1328 let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
1329 .await
1330 .expect("success");
1331 assert!(out.stdout.contains("hello via stdin"));
1332 }
1333
1334 #[cfg(feature = "async")]
1339 #[tokio::test]
1340 async fn async_spawn_retry_passes_through_non_txtbsy_error() {
1341 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
1342 let err = spawn_retrying_txtbsy(&mut cmd)
1343 .await
1344 .expect_err("spawn of missing binary should fail");
1345 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1346 }
1347
1348 #[cfg(feature = "async")]
1352 #[tokio::test]
1353 async fn async_output_retry_passes_through_non_txtbsy_error() {
1354 let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
1355 let err = output_retrying_txtbsy(&mut cmd)
1356 .await
1357 .expect_err("output of missing binary should fail");
1358 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1359 }
1360
1361 #[cfg(feature = "async")]
1362 #[tokio::test]
1363 async fn async_allow_exit_codes_permits_listed_code() {
1364 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1365 let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
1366 .await
1367 .expect("allowed code is Ok");
1368 assert!(!out.success);
1369 assert_eq!(out.exit_code, 2);
1370 assert!(out.stdout.contains("out"));
1371 }
1372
1373 #[cfg(feature = "async")]
1374 #[tokio::test]
1375 async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
1376 let (_dir, path) = fake_script(r#"exit 2"#);
1377 let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
1378 .await
1379 .unwrap_err();
1380 assert!(
1381 matches!(err, Error::CommandFailed { exit_code: 2, .. }),
1382 "got: {err:?}"
1383 );
1384 }
1385
1386 #[cfg(feature = "async")]
1387 #[tokio::test]
1388 async fn async_timeout_fires_on_slow_child() {
1389 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1390 let claude = Claude::builder()
1391 .binary(&path)
1392 .timeout(Duration::from_millis(300))
1393 .build()
1394 .expect("build");
1395 let err = run_claude(&claude, vec![]).await.unwrap_err();
1396 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1397 }
1398
1399 #[cfg(feature = "async")]
1400 #[tokio::test]
1401 async fn async_timeout_path_returns_output_when_fast() {
1402 let (_dir, path) = fake_script(r#"echo quick"#);
1403 let claude = Claude::builder()
1404 .binary(&path)
1405 .timeout(Duration::from_secs(30))
1406 .build()
1407 .expect("build");
1408 let out = run_claude(&claude, vec![]).await.expect("success");
1409 assert!(out.stdout.contains("quick"));
1410 }
1411
1412 #[cfg(feature = "async")]
1413 #[tokio::test]
1414 async fn async_timeout_path_maps_command_failed() {
1415 let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
1416 let claude = Claude::builder()
1417 .binary(&path)
1418 .timeout(Duration::from_secs(30))
1419 .build()
1420 .expect("build");
1421 let err = run_claude(&claude, vec![]).await.unwrap_err();
1422 assert!(
1423 matches!(err, Error::CommandFailed { exit_code: 4, .. }),
1424 "got: {err:?}"
1425 );
1426 }
1427
1428 #[cfg(feature = "async")]
1429 #[tokio::test]
1430 async fn async_stdin_with_timeout_round_trips() {
1431 let (_dir, path) = fake_script(r#"cat"#);
1432 let claude = Claude::builder()
1433 .binary(&path)
1434 .timeout(Duration::from_secs(30))
1435 .build()
1436 .expect("build");
1437 let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
1438 .await
1439 .expect("success");
1440 assert!(out.stdout.contains("piped under timeout"));
1441 }
1442
1443 #[cfg(feature = "async")]
1444 #[tokio::test]
1445 async fn async_stdin_timeout_fires_on_slow_child() {
1446 let (_dir, path) = fake_script(r#"sleep 3"#);
1447 let claude = Claude::builder()
1448 .binary(&path)
1449 .timeout(Duration::from_millis(300))
1450 .build()
1451 .expect("build");
1452 let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
1453 .await
1454 .unwrap_err();
1455 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1456 }
1457
1458 #[cfg(feature = "async")]
1459 #[tokio::test]
1460 async fn async_spawn_failure_maps_io() {
1461 let claude = Claude::builder()
1462 .binary("/nonexistent/definitely/not/here")
1463 .build()
1464 .expect("build");
1465 let err = run_claude(&claude, vec![]).await.unwrap_err();
1466 assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
1467 }
1468
1469 #[cfg(feature = "sync")]
1472 #[test]
1473 fn sync_success_maps_output() {
1474 let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
1475 let out = run_claude_sync(&client(&path), vec![]).expect("success");
1476 assert!(out.success);
1477 assert!(out.stdout.contains("hi sync"));
1478 }
1479
1480 #[cfg(feature = "sync")]
1481 #[test]
1482 fn sync_nonzero_exit_maps_command_failed() {
1483 let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1484 let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
1485 match err {
1486 Error::CommandFailed {
1487 exit_code, stderr, ..
1488 } => {
1489 assert_eq!(exit_code, 3);
1490 assert!(stderr.contains("boom"));
1491 }
1492 other => panic!("expected CommandFailed, got {other:?}"),
1493 }
1494 }
1495
1496 #[cfg(feature = "sync")]
1497 #[test]
1498 fn sync_scrubs_claude_env_vars() {
1499 let (_dir, path) =
1500 fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1501 set_scrub_vars();
1502 let out = run_claude_sync(&client(&path), vec![]).expect("success");
1503 clear_scrub_vars();
1504 assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1505 assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1506 }
1507
1508 #[cfg(feature = "sync")]
1509 #[test]
1510 fn sync_stdin_prompt_round_trips() {
1511 let (_dir, path) = fake_script(r#"cat"#);
1512 let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
1513 .expect("success");
1514 assert!(out.stdout.contains("sync stdin"));
1515 }
1516
1517 #[cfg(feature = "sync")]
1520 #[test]
1521 fn sync_spawn_retry_passes_through_non_txtbsy_error() {
1522 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
1523 let err =
1524 spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
1525 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1526 }
1527
1528 #[cfg(feature = "sync")]
1529 #[test]
1530 fn sync_output_retry_passes_through_non_txtbsy_error() {
1531 let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
1532 let err = output_retrying_txtbsy_sync(&mut cmd)
1533 .expect_err("output of missing binary should fail");
1534 assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1535 }
1536
1537 #[cfg(feature = "sync")]
1538 #[test]
1539 fn sync_allow_exit_codes_permits_listed_code() {
1540 let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1541 let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
1542 .expect("allowed code is Ok");
1543 assert!(!out.success);
1544 assert_eq!(out.exit_code, 2);
1545 }
1546
1547 #[cfg(feature = "sync")]
1548 #[test]
1549 fn sync_timeout_fires_on_slow_child() {
1550 let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1551 let claude = Claude::builder()
1552 .binary(&path)
1553 .timeout(Duration::from_millis(300))
1554 .build()
1555 .expect("build");
1556 let err = run_claude_sync(&claude, vec![]).unwrap_err();
1557 assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1558 }
1559
1560 #[cfg(feature = "sync")]
1561 #[test]
1562 fn sync_timeout_path_returns_output_when_fast() {
1563 let (_dir, path) = fake_script(r#"echo quick"#);
1564 let claude = Claude::builder()
1565 .binary(&path)
1566 .timeout(Duration::from_secs(30))
1567 .build()
1568 .expect("build");
1569 let out = run_claude_sync(&claude, vec![]).expect("success");
1570 assert!(out.stdout.contains("quick"));
1571 }
1572
1573 #[cfg(feature = "sync")]
1574 #[test]
1575 fn sync_stdin_with_timeout_round_trips() {
1576 let (_dir, path) = fake_script(r#"cat"#);
1577 let claude = Claude::builder()
1578 .binary(&path)
1579 .timeout(Duration::from_secs(30))
1580 .build()
1581 .expect("build");
1582 let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
1583 .expect("success");
1584 assert!(out.stdout.contains("sync piped"));
1585 }
1586}