Skip to main content

claude_wrapper/
exec.rs

1//! Process spawning and execution for the `claude` CLI.
2//!
3//! Builds and runs the child process behind every command: applies the
4//! [`Claude`] client's binary path, working directory, environment, and
5//! timeout, scrubs the `CLAUDECODE` env var so nested runs are not
6//! detected as recursive, drains stdout/stderr without deadlocking, and
7//! maps failures onto [`Error`] via
8//! [`from_command_failure`](crate::error::Error::from_command_failure).
9//! Both the async (tokio) and blocking (`sync` feature) paths live here.
10
11use 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
22/// Raw output from a claude CLI invocation.
23#[derive(Debug, Clone)]
24pub struct CommandOutput {
25    /// Captured standard output.
26    pub stdout: String,
27    /// Captured standard error.
28    pub stderr: String,
29    /// Process exit code.
30    pub exit_code: i32,
31    /// Whether the process exited successfully (exit code 0).
32    pub success: bool,
33}
34
35/// Run a claude command with the given arguments.
36///
37/// If the [`Claude`] client has a retry policy set, transient errors will be
38/// retried according to that policy. A per-command retry policy can be passed
39/// to override the client default.
40#[cfg(feature = "async")]
41pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
42    run_claude_with_retry(claude, args, None).await
43}
44
45/// Run a claude command with an optional per-command retry policy override.
46#[cfg(feature = "async")]
47pub async fn run_claude_with_retry(
48    claude: &Claude,
49    args: Vec<String>,
50    retry_override: Option<&crate::retry::RetryPolicy>,
51) -> Result<CommandOutput> {
52    let policy = retry_override.or(claude.retry_policy.as_ref());
53
54    match policy {
55        Some(policy) => {
56            crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
57        }
58        None => run_claude_once(claude, args).await,
59    }
60}
61
62/// Run claude, writing `stdin_content` to the child's stdin rather than
63/// passing the prompt as argv.
64///
65/// stdin mode does not retry -- the stdin pipe is consumed after the first
66/// attempt and cannot be rewound for a subsequent try.
67#[cfg(feature = "async")]
68pub async fn run_claude_with_stdin_prompt(
69    claude: &Claude,
70    args: Vec<String>,
71    stdin_content: String,
72) -> Result<CommandOutput> {
73    run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
74}
75
76#[cfg(feature = "async")]
77async fn run_claude_with_stdin_prompt_internal(
78    claude: &Claude,
79    args: Vec<String>,
80    stdin_content: String,
81) -> Result<CommandOutput> {
82    let mut command_args = Vec::new();
83    command_args.extend(claude.global_args.clone());
84    command_args.extend(args);
85
86    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
87
88    let binary = &claude.binary;
89    let env = &claude.env;
90    let working_dir = claude.working_dir.as_deref();
91
92    if let Some(timeout) = claude.timeout {
93        run_with_timeout_stdin(
94            binary,
95            &command_args,
96            env,
97            working_dir,
98            timeout,
99            stdin_content,
100        )
101        .await
102    } else {
103        run_internal_stdin(binary, &command_args, env, working_dir, stdin_content).await
104    }
105}
106
107#[cfg(feature = "async")]
108async fn run_internal_stdin(
109    binary: &std::path::Path,
110    args: &[String],
111    env: &std::collections::HashMap<String, String>,
112    working_dir: Option<&std::path::Path>,
113    stdin_content: String,
114) -> Result<CommandOutput> {
115    use tokio::io::AsyncWriteExt;
116
117    let mut cmd = Command::new(binary);
118    cmd.args(args);
119    cmd.stdin(std::process::Stdio::piped());
120    cmd.stdout(std::process::Stdio::piped());
121    cmd.stderr(std::process::Stdio::piped());
122    cmd.env_remove("CLAUDECODE");
123    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
124
125    if let Some(dir) = working_dir {
126        cmd.current_dir(dir);
127    }
128
129    for (key, value) in env {
130        cmd.env(key, value);
131    }
132
133    let mut child = cmd.spawn().map_err(|e| Error::Io {
134        message: format!("failed to spawn claude: {e}"),
135        source: e,
136        working_dir: working_dir.map(|p| p.to_path_buf()),
137    })?;
138
139    // Write the prompt to stdin, then drop the handle so the child sees EOF.
140    if let Some(mut stdin) = child.stdin.take() {
141        stdin
142            .write_all(stdin_content.as_bytes())
143            .await
144            .map_err(|e| Error::Io {
145                message: format!("failed to write to claude stdin: {e}"),
146                source: e,
147                working_dir: working_dir.map(|p| p.to_path_buf()),
148            })?;
149        // Drop stdin so the child sees EOF.
150    }
151
152    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
153    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
154
155    let (status, stdout_str, stderr_str) = tokio::join!(
156        child.wait(),
157        drain(&mut stdout_handle),
158        drain(&mut stderr_handle),
159    );
160
161    let status = status.map_err(|e| Error::Io {
162        message: "failed to wait for claude process".to_string(),
163        source: e,
164        working_dir: working_dir.map(|p| p.to_path_buf()),
165    })?;
166
167    let exit_code = status.code().unwrap_or(-1);
168
169    if !status.success() {
170        return Err(Error::from_command_failure(
171            format!("{} {}", binary.display(), args.join(" ")),
172            exit_code,
173            stdout_str,
174            stderr_str,
175            working_dir.map(|p| p.to_path_buf()),
176        ));
177    }
178
179    Ok(CommandOutput {
180        stdout: stdout_str,
181        stderr: stderr_str,
182        exit_code,
183        success: true,
184    })
185}
186
187#[cfg(feature = "async")]
188async fn run_with_timeout_stdin(
189    binary: &std::path::Path,
190    args: &[String],
191    env: &std::collections::HashMap<String, String>,
192    working_dir: Option<&std::path::Path>,
193    timeout: Duration,
194    stdin_content: String,
195) -> Result<CommandOutput> {
196    use tokio::io::AsyncWriteExt;
197
198    let mut cmd = Command::new(binary);
199    cmd.args(args);
200    cmd.stdin(std::process::Stdio::piped());
201    cmd.stdout(std::process::Stdio::piped());
202    cmd.stderr(std::process::Stdio::piped());
203    cmd.env_remove("CLAUDECODE");
204    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
205
206    if let Some(dir) = working_dir {
207        cmd.current_dir(dir);
208    }
209
210    for (key, value) in env {
211        cmd.env(key, value);
212    }
213
214    let mut child = cmd.spawn().map_err(|e| Error::Io {
215        message: format!("failed to spawn claude: {e}"),
216        source: e,
217        working_dir: working_dir.map(|p| p.to_path_buf()),
218    })?;
219
220    // Write the prompt to stdin, then drop the handle so the child sees EOF.
221    if let Some(mut stdin) = child.stdin.take() {
222        stdin
223            .write_all(stdin_content.as_bytes())
224            .await
225            .map_err(|e| Error::Io {
226                message: format!("failed to write to claude stdin: {e}"),
227                source: e,
228                working_dir: working_dir.map(|p| p.to_path_buf()),
229            })?;
230        // Drop stdin so the child sees EOF.
231    }
232
233    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
234    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
235
236    let wait_and_drain = async {
237        let (status, stdout_str, stderr_str) = tokio::join!(
238            child.wait(),
239            drain(&mut stdout_handle),
240            drain(&mut stderr_handle),
241        );
242        (status, stdout_str, stderr_str)
243    };
244
245    match tokio::time::timeout(timeout, wait_and_drain).await {
246        Ok((Ok(status), stdout, stderr)) => {
247            let exit_code = status.code().unwrap_or(-1);
248
249            if !status.success() {
250                return Err(Error::from_command_failure(
251                    format!("{} {}", binary.display(), args.join(" ")),
252                    exit_code,
253                    stdout,
254                    stderr,
255                    working_dir.map(|p| p.to_path_buf()),
256                ));
257            }
258
259            Ok(CommandOutput {
260                stdout,
261                stderr,
262                exit_code,
263                success: true,
264            })
265        }
266        Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
267            message: "failed to wait for claude process".to_string(),
268            source: e,
269            working_dir: working_dir.map(|p| p.to_path_buf()),
270        }),
271        Err(_) => {
272            let _ = child.kill().await;
273            let drain_budget = Duration::from_millis(200);
274            let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
275                .await
276                .unwrap_or_default();
277            let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
278                .await
279                .unwrap_or_default();
280            if !stdout_str.is_empty() || !stderr_str.is_empty() {
281                warn!(
282                    stdout = %stdout_str,
283                    stderr = %stderr_str,
284                    "partial output from timed-out process",
285                );
286            }
287            Err(Error::Timeout {
288                timeout_seconds: timeout.as_secs(),
289            })
290        }
291    }
292}
293
294#[cfg(feature = "async")]
295async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
296    let mut command_args = Vec::new();
297
298    // Global args first (before subcommand)
299    command_args.extend(claude.global_args.clone());
300
301    // Then command-specific args
302    command_args.extend(args);
303
304    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
305
306    let output = if let Some(timeout) = claude.timeout {
307        run_with_timeout(
308            &claude.binary,
309            &command_args,
310            &claude.env,
311            claude.working_dir.as_deref(),
312            timeout,
313        )
314        .await?
315    } else {
316        run_internal(
317            &claude.binary,
318            &command_args,
319            &claude.env,
320            claude.working_dir.as_deref(),
321        )
322        .await?
323    };
324
325    Ok(output)
326}
327
328/// Run a claude command and allow specific non-zero exit codes.
329#[cfg(feature = "async")]
330pub async fn run_claude_allow_exit_codes(
331    claude: &Claude,
332    args: Vec<String>,
333    allowed_codes: &[i32],
334) -> Result<CommandOutput> {
335    let output = run_claude(claude, args).await;
336
337    match output {
338        Err(Error::CommandFailed {
339            exit_code,
340            stdout,
341            stderr,
342            ..
343        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
344            stdout,
345            stderr,
346            exit_code,
347            success: false,
348        }),
349        other => other,
350    }
351}
352
353#[cfg(feature = "async")]
354async fn run_internal(
355    binary: &std::path::Path,
356    args: &[String],
357    env: &std::collections::HashMap<String, String>,
358    working_dir: Option<&std::path::Path>,
359) -> Result<CommandOutput> {
360    let mut cmd = Command::new(binary);
361    cmd.args(args);
362
363    // Prevent child from inheriting/blocking on parent's stdin.
364    cmd.stdin(std::process::Stdio::null());
365
366    // Remove Claude Code env vars to prevent nested session detection
367    cmd.env_remove("CLAUDECODE");
368    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
369
370    if let Some(dir) = working_dir {
371        cmd.current_dir(dir);
372    }
373
374    for (key, value) in env {
375        cmd.env(key, value);
376    }
377
378    let output = cmd.output().await.map_err(|e| Error::Io {
379        message: format!("failed to spawn claude: {e}"),
380        source: e,
381        working_dir: working_dir.map(|p| p.to_path_buf()),
382    })?;
383
384    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
385    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
386    let exit_code = output.status.code().unwrap_or(-1);
387
388    if !output.status.success() {
389        return Err(Error::from_command_failure(
390            format!("{} {}", binary.display(), args.join(" ")),
391            exit_code,
392            stdout,
393            stderr,
394            working_dir.map(|p| p.to_path_buf()),
395        ));
396    }
397
398    Ok(CommandOutput {
399        stdout,
400        stderr,
401        exit_code,
402        success: true,
403    })
404}
405
406/// Run a command with a timeout, killing and reaping the child on expiration.
407///
408/// Spawns the child explicitly (rather than wrapping `Command::output()` in a
409/// `tokio::time::timeout`) so that we retain the handle and can SIGKILL the
410/// child and wait for it when the timeout fires. Stdout and stderr are drained
411/// concurrently with `child.wait()` via `tokio::join!` so neither pipe buffer
412/// can fill up and deadlock the child.
413///
414/// On timeout, partial stdout/stderr captured before the kill is logged at
415/// warn level; the returned `Error::Timeout` itself does not carry the
416/// partial output.
417#[cfg(feature = "async")]
418async fn run_with_timeout(
419    binary: &std::path::Path,
420    args: &[String],
421    env: &std::collections::HashMap<String, String>,
422    working_dir: Option<&std::path::Path>,
423    timeout: Duration,
424) -> Result<CommandOutput> {
425    let mut cmd = Command::new(binary);
426    cmd.args(args);
427    cmd.stdin(std::process::Stdio::null());
428    cmd.stdout(std::process::Stdio::piped());
429    cmd.stderr(std::process::Stdio::piped());
430    cmd.env_remove("CLAUDECODE");
431    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
432
433    if let Some(dir) = working_dir {
434        cmd.current_dir(dir);
435    }
436
437    for (key, value) in env {
438        cmd.env(key, value);
439    }
440
441    let mut child = cmd.spawn().map_err(|e| Error::Io {
442        message: format!("failed to spawn claude: {e}"),
443        source: e,
444        working_dir: working_dir.map(|p| p.to_path_buf()),
445    })?;
446
447    let mut stdout = child.stdout.take().expect("stdout was piped");
448    let mut stderr = child.stderr.take().expect("stderr was piped");
449
450    // Drain stdout and stderr concurrently with the process wait so
451    // neither pipe buffer can fill up and deadlock the child.
452    // tokio::join! polls all three on the same task; no tokio::spawn
453    // (and therefore no `rt` feature) required.
454    let wait_and_drain = async {
455        let (status, stdout_str, stderr_str) =
456            tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
457        (status, stdout_str, stderr_str)
458    };
459
460    match tokio::time::timeout(timeout, wait_and_drain).await {
461        Ok((Ok(status), stdout, stderr)) => {
462            let exit_code = status.code().unwrap_or(-1);
463
464            if !status.success() {
465                return Err(Error::from_command_failure(
466                    format!("{} {}", binary.display(), args.join(" ")),
467                    exit_code,
468                    stdout,
469                    stderr,
470                    working_dir.map(|p| p.to_path_buf()),
471                ));
472            }
473
474            Ok(CommandOutput {
475                stdout,
476                stderr,
477                exit_code,
478                success: true,
479            })
480        }
481        Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
482            message: "failed to wait for claude process".to_string(),
483            source: e,
484            working_dir: working_dir.map(|p| p.to_path_buf()),
485        }),
486        Err(_) => {
487            // Timeout: kill the child (reaps via start_kill + wait).
488            // Note that kill() only targets the direct child; if it has
489            // spawned its own subprocesses that are holding our pipe
490            // fds open, draining would block. Cap the drain with a
491            // short deadline so the timeout error returns promptly.
492            let _ = child.kill().await;
493            let drain_budget = Duration::from_millis(200);
494            let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
495                .await
496                .unwrap_or_default();
497            let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
498                .await
499                .unwrap_or_default();
500            if !stdout_str.is_empty() || !stderr_str.is_empty() {
501                warn!(
502                    stdout = %stdout_str,
503                    stderr = %stderr_str,
504                    "partial output from timed-out process",
505                );
506            }
507            Err(Error::Timeout {
508                timeout_seconds: timeout.as_secs(),
509            })
510        }
511    }
512}
513
514#[cfg(feature = "async")]
515async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
516    let mut buf = Vec::new();
517    let _ = reader.read_to_end(&mut buf).await;
518    String::from_utf8_lossy(&buf).into_owned()
519}
520
521// ---------- sync twins ----------
522
523/// Blocking mirror of [`run_claude`]. Available with the `sync` feature.
524#[cfg(feature = "sync")]
525pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
526    run_claude_with_retry_sync(claude, args, None)
527}
528
529/// Blocking mirror of [`run_claude_with_retry`].
530#[cfg(feature = "sync")]
531pub fn run_claude_with_retry_sync(
532    claude: &Claude,
533    args: Vec<String>,
534    retry_override: Option<&crate::retry::RetryPolicy>,
535) -> Result<CommandOutput> {
536    let policy = retry_override.or(claude.retry_policy.as_ref());
537
538    match policy {
539        Some(policy) => {
540            crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
541        }
542        None => run_claude_once_sync(claude, args),
543    }
544}
545
546/// Blocking mirror of [`run_claude_with_stdin_prompt`].
547///
548/// stdin mode does not retry -- the stdin pipe is consumed after the first
549/// attempt and cannot be rewound.
550#[cfg(feature = "sync")]
551pub fn run_claude_with_stdin_prompt_sync(
552    claude: &Claude,
553    args: Vec<String>,
554    stdin_content: String,
555) -> Result<CommandOutput> {
556    let mut command_args = Vec::new();
557    command_args.extend(claude.global_args.clone());
558    command_args.extend(args);
559
560    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
561
562    if let Some(timeout) = claude.timeout {
563        run_with_timeout_stdin_sync(
564            &claude.binary,
565            &command_args,
566            &claude.env,
567            claude.working_dir.as_deref(),
568            timeout,
569            stdin_content,
570        )
571    } else {
572        run_internal_stdin_sync(
573            &claude.binary,
574            &command_args,
575            &claude.env,
576            claude.working_dir.as_deref(),
577            stdin_content,
578        )
579    }
580}
581
582#[cfg(feature = "sync")]
583fn run_internal_stdin_sync(
584    binary: &std::path::Path,
585    args: &[String],
586    env: &std::collections::HashMap<String, String>,
587    working_dir: Option<&std::path::Path>,
588    stdin_content: String,
589) -> Result<CommandOutput> {
590    use std::io::Write;
591    use std::process::{Command as StdCommand, Stdio};
592
593    let mut cmd = StdCommand::new(binary);
594    cmd.args(args);
595    cmd.stdin(Stdio::piped());
596    cmd.stdout(Stdio::piped());
597    cmd.stderr(Stdio::piped());
598    cmd.env_remove("CLAUDECODE");
599    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
600
601    if let Some(dir) = working_dir {
602        cmd.current_dir(dir);
603    }
604
605    for (key, value) in env {
606        cmd.env(key, value);
607    }
608
609    let mut child = cmd.spawn().map_err(|e| Error::Io {
610        message: format!("failed to spawn claude: {e}"),
611        source: e,
612        working_dir: working_dir.map(|p| p.to_path_buf()),
613    })?;
614
615    // Write the prompt to stdin, then drop the handle so the child sees EOF.
616    if let Some(mut stdin) = child.stdin.take() {
617        stdin
618            .write_all(stdin_content.as_bytes())
619            .map_err(|e| Error::Io {
620                message: format!("failed to write to claude stdin: {e}"),
621                source: e,
622                working_dir: working_dir.map(|p| p.to_path_buf()),
623            })?;
624        stdin.flush().map_err(|e| Error::Io {
625            message: format!("failed to flush claude stdin: {e}"),
626            source: e,
627            working_dir: working_dir.map(|p| p.to_path_buf()),
628        })?;
629        // Drop stdin so the child sees EOF.
630    }
631
632    let output = child.wait_with_output().map_err(|e| Error::Io {
633        message: "failed to wait for claude process".to_string(),
634        source: e,
635        working_dir: working_dir.map(|p| p.to_path_buf()),
636    })?;
637
638    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
639    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
640    let exit_code = output.status.code().unwrap_or(-1);
641
642    if !output.status.success() {
643        return Err(Error::from_command_failure(
644            format!("{} {}", binary.display(), args.join(" ")),
645            exit_code,
646            stdout,
647            stderr,
648            working_dir.map(|p| p.to_path_buf()),
649        ));
650    }
651
652    Ok(CommandOutput {
653        stdout,
654        stderr,
655        exit_code,
656        success: true,
657    })
658}
659
660#[cfg(feature = "sync")]
661fn run_with_timeout_stdin_sync(
662    binary: &std::path::Path,
663    args: &[String],
664    env: &std::collections::HashMap<String, String>,
665    working_dir: Option<&std::path::Path>,
666    timeout: Duration,
667    stdin_content: String,
668) -> Result<CommandOutput> {
669    use std::io::Write;
670    use std::process::{Command as StdCommand, Stdio};
671    use std::thread;
672    use wait_timeout::ChildExt;
673
674    let mut cmd = StdCommand::new(binary);
675    cmd.args(args);
676    cmd.stdin(Stdio::piped());
677    cmd.stdout(Stdio::piped());
678    cmd.stderr(Stdio::piped());
679    cmd.env_remove("CLAUDECODE");
680    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
681
682    if let Some(dir) = working_dir {
683        cmd.current_dir(dir);
684    }
685
686    for (key, value) in env {
687        cmd.env(key, value);
688    }
689
690    let mut child = cmd.spawn().map_err(|e| Error::Io {
691        message: format!("failed to spawn claude: {e}"),
692        source: e,
693        working_dir: working_dir.map(|p| p.to_path_buf()),
694    })?;
695
696    // Write the prompt to stdin, then drop the handle so the child sees EOF.
697    if let Some(mut stdin) = child.stdin.take() {
698        stdin
699            .write_all(stdin_content.as_bytes())
700            .map_err(|e| Error::Io {
701                message: format!("failed to write to claude stdin: {e}"),
702                source: e,
703                working_dir: working_dir.map(|p| p.to_path_buf()),
704            })?;
705        stdin.flush().map_err(|e| Error::Io {
706            message: format!("failed to flush claude stdin: {e}"),
707            source: e,
708            working_dir: working_dir.map(|p| p.to_path_buf()),
709        })?;
710        // Drop stdin so the child sees EOF.
711    }
712
713    let stdout = child.stdout.take().expect("stdout was piped");
714    let stderr = child.stderr.take().expect("stderr was piped");
715
716    let stdout_thread = thread::spawn(move || drain_sync(stdout));
717    let stderr_thread = thread::spawn(move || drain_sync(stderr));
718
719    match child.wait_timeout(timeout).map_err(|e| Error::Io {
720        message: "failed to wait for claude process".to_string(),
721        source: e,
722        working_dir: working_dir.map(|p| p.to_path_buf()),
723    })? {
724        Some(status) => {
725            let stdout = stdout_thread.join().unwrap_or_default();
726            let stderr = stderr_thread.join().unwrap_or_default();
727            let exit_code = status.code().unwrap_or(-1);
728
729            if !status.success() {
730                return Err(Error::from_command_failure(
731                    format!("{} {}", binary.display(), args.join(" ")),
732                    exit_code,
733                    stdout,
734                    stderr,
735                    working_dir.map(|p| p.to_path_buf()),
736                ));
737            }
738
739            Ok(CommandOutput {
740                stdout,
741                stderr,
742                exit_code,
743                success: true,
744            })
745        }
746        None => {
747            let _ = child.kill();
748            let _ = child.wait();
749            let (stdout_str, stderr_str) =
750                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
751            if !stdout_str.is_empty() || !stderr_str.is_empty() {
752                warn!(
753                    stdout = %stdout_str,
754                    stderr = %stderr_str,
755                    "partial output from timed-out process",
756                );
757            }
758            Err(Error::Timeout {
759                timeout_seconds: timeout.as_secs(),
760            })
761        }
762    }
763}
764
765#[cfg(feature = "sync")]
766fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
767    let mut command_args = Vec::new();
768    command_args.extend(claude.global_args.clone());
769    command_args.extend(args);
770
771    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
772
773    if let Some(timeout) = claude.timeout {
774        run_with_timeout_sync(
775            &claude.binary,
776            &command_args,
777            &claude.env,
778            claude.working_dir.as_deref(),
779            timeout,
780        )
781    } else {
782        run_internal_sync(
783            &claude.binary,
784            &command_args,
785            &claude.env,
786            claude.working_dir.as_deref(),
787        )
788    }
789}
790
791/// Blocking mirror of [`run_claude_allow_exit_codes`].
792#[cfg(feature = "sync")]
793pub fn run_claude_allow_exit_codes_sync(
794    claude: &Claude,
795    args: Vec<String>,
796    allowed_codes: &[i32],
797) -> Result<CommandOutput> {
798    match run_claude_sync(claude, args) {
799        Err(Error::CommandFailed {
800            exit_code,
801            stdout,
802            stderr,
803            ..
804        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
805            stdout,
806            stderr,
807            exit_code,
808            success: false,
809        }),
810        other => other,
811    }
812}
813
814#[cfg(feature = "sync")]
815fn run_internal_sync(
816    binary: &std::path::Path,
817    args: &[String],
818    env: &std::collections::HashMap<String, String>,
819    working_dir: Option<&std::path::Path>,
820) -> Result<CommandOutput> {
821    use std::process::{Command as StdCommand, Stdio};
822
823    let mut cmd = StdCommand::new(binary);
824    cmd.args(args);
825    cmd.stdin(Stdio::null());
826    cmd.env_remove("CLAUDECODE");
827    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
828
829    if let Some(dir) = working_dir {
830        cmd.current_dir(dir);
831    }
832
833    for (key, value) in env {
834        cmd.env(key, value);
835    }
836
837    let output = cmd.output().map_err(|e| Error::Io {
838        message: format!("failed to spawn claude: {e}"),
839        source: e,
840        working_dir: working_dir.map(|p| p.to_path_buf()),
841    })?;
842
843    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
844    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
845    let exit_code = output.status.code().unwrap_or(-1);
846
847    if !output.status.success() {
848        return Err(Error::from_command_failure(
849            format!("{} {}", binary.display(), args.join(" ")),
850            exit_code,
851            stdout,
852            stderr,
853            working_dir.map(|p| p.to_path_buf()),
854        ));
855    }
856
857    Ok(CommandOutput {
858        stdout,
859        stderr,
860        exit_code,
861        success: true,
862    })
863}
864
865/// Blocking run with a timeout. Mirrors [`run_with_timeout`]: spawns
866/// the child, drains stdout/stderr on dedicated threads so neither
867/// pipe buffer can fill up while we wait, then uses
868/// [`wait_timeout::ChildExt::wait_timeout`] to enforce the deadline.
869/// On timeout, the child is SIGKILLed and reaped; partial output is
870/// logged at warn but the returned [`Error::Timeout`] does not carry it.
871#[cfg(feature = "sync")]
872fn run_with_timeout_sync(
873    binary: &std::path::Path,
874    args: &[String],
875    env: &std::collections::HashMap<String, String>,
876    working_dir: Option<&std::path::Path>,
877    timeout: Duration,
878) -> Result<CommandOutput> {
879    use std::process::{Command as StdCommand, Stdio};
880    use std::thread;
881    use wait_timeout::ChildExt;
882
883    let mut cmd = StdCommand::new(binary);
884    cmd.args(args);
885    cmd.stdin(Stdio::null());
886    cmd.stdout(Stdio::piped());
887    cmd.stderr(Stdio::piped());
888    cmd.env_remove("CLAUDECODE");
889    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
890
891    if let Some(dir) = working_dir {
892        cmd.current_dir(dir);
893    }
894
895    for (key, value) in env {
896        cmd.env(key, value);
897    }
898
899    let mut child = cmd.spawn().map_err(|e| Error::Io {
900        message: format!("failed to spawn claude: {e}"),
901        source: e,
902        working_dir: working_dir.map(|p| p.to_path_buf()),
903    })?;
904
905    // Detach stdout/stderr onto their own threads so neither can block
906    // the child by filling its pipe buffer. Each thread owns its half
907    // and drops it on completion, which closes the parent's fd and
908    // lets read_to_end() return EOF once the child exits.
909    let stdout = child.stdout.take().expect("stdout was piped");
910    let stderr = child.stderr.take().expect("stderr was piped");
911
912    let stdout_thread = thread::spawn(move || drain_sync(stdout));
913    let stderr_thread = thread::spawn(move || drain_sync(stderr));
914
915    match child.wait_timeout(timeout).map_err(|e| Error::Io {
916        message: "failed to wait for claude process".to_string(),
917        source: e,
918        working_dir: working_dir.map(|p| p.to_path_buf()),
919    })? {
920        Some(status) => {
921            let stdout = stdout_thread.join().unwrap_or_default();
922            let stderr = stderr_thread.join().unwrap_or_default();
923            let exit_code = status.code().unwrap_or(-1);
924
925            if !status.success() {
926                return Err(Error::from_command_failure(
927                    format!("{} {}", binary.display(), args.join(" ")),
928                    exit_code,
929                    stdout,
930                    stderr,
931                    working_dir.map(|p| p.to_path_buf()),
932                ));
933            }
934
935            Ok(CommandOutput {
936                stdout,
937                stderr,
938                exit_code,
939                success: true,
940            })
941        }
942        None => {
943            // Timeout: SIGKILL and reap. If the child has spawned
944            // subprocesses that inherited our pipe fds, the drain
945            // threads can block indefinitely; cap the join with a
946            // short budget so the timeout error returns promptly.
947            let _ = child.kill();
948            let _ = child.wait();
949
950            let (stdout_str, stderr_str) =
951                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
952
953            if !stdout_str.is_empty() || !stderr_str.is_empty() {
954                warn!(
955                    stdout = %stdout_str,
956                    stderr = %stderr_str,
957                    "partial output from timed-out process",
958                );
959            }
960
961            Err(Error::Timeout {
962                timeout_seconds: timeout.as_secs(),
963            })
964        }
965    }
966}
967
968#[cfg(feature = "sync")]
969fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
970    let mut buf = Vec::new();
971    let _ = reader.read_to_end(&mut buf);
972    String::from_utf8_lossy(&buf).into_owned()
973}
974
975/// Wait for both drain threads to finish, returning "" for any that
976/// miss the deadline. Threads aren't cancellable in std; if the child's
977/// subprocesses are still holding a pipe fd open after kill(), the
978/// drain thread leaks. That's a pathological case; the common timeout
979/// path with a responsive child joins in microseconds.
980#[cfg(feature = "sync")]
981fn join_with_deadline(
982    stdout_thread: std::thread::JoinHandle<String>,
983    stderr_thread: std::thread::JoinHandle<String>,
984    budget: Duration,
985) -> (String, String) {
986    use std::sync::mpsc;
987    use std::thread;
988
989    let (tx, rx) = mpsc::channel::<(&'static str, String)>();
990
991    let tx_out = tx.clone();
992    let tx_err = tx;
993
994    thread::spawn(move || {
995        let s = stdout_thread.join().unwrap_or_default();
996        let _ = tx_out.send(("stdout", s));
997    });
998    thread::spawn(move || {
999        let s = stderr_thread.join().unwrap_or_default();
1000        let _ = tx_err.send(("stderr", s));
1001    });
1002
1003    let mut stdout = String::new();
1004    let mut stderr = String::new();
1005    let deadline = std::time::Instant::now() + budget;
1006
1007    for _ in 0..2 {
1008        let now = std::time::Instant::now();
1009        if now >= deadline {
1010            break;
1011        }
1012        match rx.recv_timeout(deadline - now) {
1013            Ok(("stdout", s)) => stdout = s,
1014            Ok(("stderr", s)) => stderr = s,
1015            Ok(_) => unreachable!(),
1016            Err(_) => break,
1017        }
1018    }
1019
1020    (stdout, stderr)
1021}
1022
1023// Fake-binary-driven tests for the spawn/execute paths. Unix-only: they
1024// write and run a small bash `claude` stand-in, which cannot execute on
1025// Windows. CI runs `cargo test --lib` on Windows too, so the module is
1026// gated on `unix` to compile out there; ubuntu/macOS (and `llvm-cov`)
1027// exercise it. `tempfile` is a dev-dependency, so it is always available
1028// under `#[cfg(test)]` regardless of the crate feature.
1029#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1030mod tests {
1031    use super::*;
1032    use std::io::Write;
1033    use std::os::unix::fs::PermissionsExt;
1034
1035    use crate::Claude;
1036
1037    /// Write `body` as an executable bash `claude` stand-in in a fresh
1038    /// tempdir. Returns the dir (keep it bound so it outlives the run)
1039    /// and the script path.
1040    fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1041        let dir = tempfile::tempdir().expect("tempdir");
1042        let path = dir.path().join("fake-claude.sh");
1043        let mut f = std::fs::File::create(&path).expect("create script");
1044        write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1045        let perms = std::fs::Permissions::from_mode(0o755);
1046        std::fs::set_permissions(&path, perms).expect("chmod");
1047        (dir, path)
1048    }
1049
1050    fn client(path: &std::path::Path) -> Claude {
1051        Claude::builder()
1052            .binary(path)
1053            .build()
1054            .expect("build client")
1055    }
1056
1057    // Serializes the env-scrub tests, which mutate process-global env.
1058    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1059
1060    fn set_scrub_vars() {
1061        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1062        // SAFETY: the synchronous mutation is serialized by ENV_LOCK and
1063        // not held across any await; no other test reads these vars.
1064        unsafe {
1065            std::env::set_var("CLAUDECODE", "1");
1066            std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1067        }
1068    }
1069
1070    fn clear_scrub_vars() {
1071        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1072        // SAFETY: see set_scrub_vars.
1073        unsafe {
1074            std::env::remove_var("CLAUDECODE");
1075            std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
1076        }
1077    }
1078
1079    // ---------- async ----------
1080
1081    #[cfg(feature = "async")]
1082    #[tokio::test]
1083    async fn async_success_maps_output() {
1084        let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
1085        let out = run_claude(&client(&path), vec!["--version".into()])
1086            .await
1087            .expect("success");
1088        assert!(out.success);
1089        assert_eq!(out.exit_code, 0);
1090        assert!(out.stdout.contains("hi there"));
1091    }
1092
1093    #[cfg(feature = "async")]
1094    #[tokio::test]
1095    async fn async_nonzero_exit_maps_command_failed() {
1096        let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1097        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1098        match err {
1099            Error::CommandFailed {
1100                exit_code, stderr, ..
1101            } => {
1102                assert_eq!(exit_code, 3);
1103                assert!(stderr.contains("boom"));
1104            }
1105            other => panic!("expected CommandFailed, got {other:?}"),
1106        }
1107    }
1108
1109    #[cfg(feature = "async")]
1110    #[tokio::test]
1111    async fn async_rail_stop_maps_max_turns() {
1112        let (_dir, path) = fake_script(
1113            r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
1114        );
1115        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1116        assert!(
1117            matches!(
1118                err,
1119                Error::MaxTurnsExceeded {
1120                    max_turns: Some(2),
1121                    ..
1122                }
1123            ),
1124            "got: {err:?}"
1125        );
1126    }
1127
1128    #[cfg(feature = "async")]
1129    #[tokio::test]
1130    async fn async_auth_shaped_stderr_maps_auth() {
1131        let (_dir, path) =
1132            fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
1133        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1134        assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
1135    }
1136
1137    #[cfg(feature = "async")]
1138    #[tokio::test]
1139    async fn async_scrubs_claude_env_vars() {
1140        let (_dir, path) =
1141            fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1142        // The child sees the vars scrubbed regardless; setting them in the
1143        // parent is what makes the assertion meaningful rather than
1144        // trivially empty. Correctness does not depend on the lock (the
1145        // scrub removes them either way), so it only wraps the synchronous
1146        // env mutations -- never held across the await, per clippy.
1147        set_scrub_vars();
1148        let out = run_claude(&client(&path), vec![]).await.expect("success");
1149        clear_scrub_vars();
1150        assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1151        assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1152    }
1153
1154    #[cfg(feature = "async")]
1155    #[tokio::test]
1156    async fn async_applies_working_dir() {
1157        let (_dir, path) = fake_script(r#"pwd"#);
1158        let workdir = tempfile::tempdir().expect("workdir");
1159        let claude = Claude::builder()
1160            .binary(&path)
1161            .working_dir(workdir.path())
1162            .build()
1163            .expect("build");
1164        let out = run_claude(&claude, vec![]).await.expect("success");
1165        let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
1166        let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
1167        assert_eq!(got, want);
1168    }
1169
1170    #[cfg(feature = "async")]
1171    #[tokio::test]
1172    async fn async_stdin_prompt_round_trips() {
1173        let (_dir, path) = fake_script(r#"cat"#);
1174        let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
1175            .await
1176            .expect("success");
1177        assert!(out.stdout.contains("hello via stdin"));
1178    }
1179
1180    #[cfg(feature = "async")]
1181    #[tokio::test]
1182    async fn async_allow_exit_codes_permits_listed_code() {
1183        let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1184        let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
1185            .await
1186            .expect("allowed code is Ok");
1187        assert!(!out.success);
1188        assert_eq!(out.exit_code, 2);
1189        assert!(out.stdout.contains("out"));
1190    }
1191
1192    #[cfg(feature = "async")]
1193    #[tokio::test]
1194    async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
1195        let (_dir, path) = fake_script(r#"exit 2"#);
1196        let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
1197            .await
1198            .unwrap_err();
1199        assert!(
1200            matches!(err, Error::CommandFailed { exit_code: 2, .. }),
1201            "got: {err:?}"
1202        );
1203    }
1204
1205    #[cfg(feature = "async")]
1206    #[tokio::test]
1207    async fn async_timeout_fires_on_slow_child() {
1208        let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1209        let claude = Claude::builder()
1210            .binary(&path)
1211            .timeout(Duration::from_millis(300))
1212            .build()
1213            .expect("build");
1214        let err = run_claude(&claude, vec![]).await.unwrap_err();
1215        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1216    }
1217
1218    #[cfg(feature = "async")]
1219    #[tokio::test]
1220    async fn async_timeout_path_returns_output_when_fast() {
1221        let (_dir, path) = fake_script(r#"echo quick"#);
1222        let claude = Claude::builder()
1223            .binary(&path)
1224            .timeout(Duration::from_secs(30))
1225            .build()
1226            .expect("build");
1227        let out = run_claude(&claude, vec![]).await.expect("success");
1228        assert!(out.stdout.contains("quick"));
1229    }
1230
1231    #[cfg(feature = "async")]
1232    #[tokio::test]
1233    async fn async_timeout_path_maps_command_failed() {
1234        let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
1235        let claude = Claude::builder()
1236            .binary(&path)
1237            .timeout(Duration::from_secs(30))
1238            .build()
1239            .expect("build");
1240        let err = run_claude(&claude, vec![]).await.unwrap_err();
1241        assert!(
1242            matches!(err, Error::CommandFailed { exit_code: 4, .. }),
1243            "got: {err:?}"
1244        );
1245    }
1246
1247    #[cfg(feature = "async")]
1248    #[tokio::test]
1249    async fn async_stdin_with_timeout_round_trips() {
1250        let (_dir, path) = fake_script(r#"cat"#);
1251        let claude = Claude::builder()
1252            .binary(&path)
1253            .timeout(Duration::from_secs(30))
1254            .build()
1255            .expect("build");
1256        let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
1257            .await
1258            .expect("success");
1259        assert!(out.stdout.contains("piped under timeout"));
1260    }
1261
1262    #[cfg(feature = "async")]
1263    #[tokio::test]
1264    async fn async_stdin_timeout_fires_on_slow_child() {
1265        let (_dir, path) = fake_script(r#"sleep 3"#);
1266        let claude = Claude::builder()
1267            .binary(&path)
1268            .timeout(Duration::from_millis(300))
1269            .build()
1270            .expect("build");
1271        let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
1272            .await
1273            .unwrap_err();
1274        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1275    }
1276
1277    #[cfg(feature = "async")]
1278    #[tokio::test]
1279    async fn async_spawn_failure_maps_io() {
1280        let claude = Claude::builder()
1281            .binary("/nonexistent/definitely/not/here")
1282            .build()
1283            .expect("build");
1284        let err = run_claude(&claude, vec![]).await.unwrap_err();
1285        assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
1286    }
1287
1288    // ---------- sync ----------
1289
1290    #[cfg(feature = "sync")]
1291    #[test]
1292    fn sync_success_maps_output() {
1293        let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
1294        let out = run_claude_sync(&client(&path), vec![]).expect("success");
1295        assert!(out.success);
1296        assert!(out.stdout.contains("hi sync"));
1297    }
1298
1299    #[cfg(feature = "sync")]
1300    #[test]
1301    fn sync_nonzero_exit_maps_command_failed() {
1302        let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1303        let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
1304        match err {
1305            Error::CommandFailed {
1306                exit_code, stderr, ..
1307            } => {
1308                assert_eq!(exit_code, 3);
1309                assert!(stderr.contains("boom"));
1310            }
1311            other => panic!("expected CommandFailed, got {other:?}"),
1312        }
1313    }
1314
1315    #[cfg(feature = "sync")]
1316    #[test]
1317    fn sync_scrubs_claude_env_vars() {
1318        let (_dir, path) =
1319            fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1320        set_scrub_vars();
1321        let out = run_claude_sync(&client(&path), vec![]).expect("success");
1322        clear_scrub_vars();
1323        assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1324        assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1325    }
1326
1327    #[cfg(feature = "sync")]
1328    #[test]
1329    fn sync_stdin_prompt_round_trips() {
1330        let (_dir, path) = fake_script(r#"cat"#);
1331        let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
1332            .expect("success");
1333        assert!(out.stdout.contains("sync stdin"));
1334    }
1335
1336    #[cfg(feature = "sync")]
1337    #[test]
1338    fn sync_allow_exit_codes_permits_listed_code() {
1339        let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1340        let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
1341            .expect("allowed code is Ok");
1342        assert!(!out.success);
1343        assert_eq!(out.exit_code, 2);
1344    }
1345
1346    #[cfg(feature = "sync")]
1347    #[test]
1348    fn sync_timeout_fires_on_slow_child() {
1349        let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1350        let claude = Claude::builder()
1351            .binary(&path)
1352            .timeout(Duration::from_millis(300))
1353            .build()
1354            .expect("build");
1355        let err = run_claude_sync(&claude, vec![]).unwrap_err();
1356        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1357    }
1358
1359    #[cfg(feature = "sync")]
1360    #[test]
1361    fn sync_timeout_path_returns_output_when_fast() {
1362        let (_dir, path) = fake_script(r#"echo quick"#);
1363        let claude = Claude::builder()
1364            .binary(&path)
1365            .timeout(Duration::from_secs(30))
1366            .build()
1367            .expect("build");
1368        let out = run_claude_sync(&claude, vec![]).expect("success");
1369        assert!(out.stdout.contains("quick"));
1370    }
1371
1372    #[cfg(feature = "sync")]
1373    #[test]
1374    fn sync_stdin_with_timeout_round_trips() {
1375        let (_dir, path) = fake_script(r#"cat"#);
1376        let claude = Claude::builder()
1377            .binary(&path)
1378            .timeout(Duration::from_secs(30))
1379            .build()
1380            .expect("build");
1381        let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
1382            .expect("success");
1383        assert!(out.stdout.contains("sync piped"));
1384    }
1385}