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