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