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