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//! Every spawn places the child in its own process group on Unix, and
12//! every async spawn sets `kill_on_drop(true)`. Dropping an in-flight
13//! execute future (a lost `tokio::select!` race, a caller-side timeout)
14//! SIGKILLs the whole group (via the crate-internal `GroupKillGuard`),
15//! so subprocesses the
16//! CLI spawned for tool use (shells, MCP servers, test runners) die
17//! with it rather than being reparented and running on. The same
18//! group-kill runs when a configured timeout fires, on both the async
19//! and blocking paths. The blocking paths cannot be dropped mid-flight,
20//! so they have no drop-side equivalent.
21//!
22//! Consequence of the group split: the child no longer shares the
23//! host's terminal process group, so terminal-generated signals
24//! (Ctrl-C) do not reach it directly; terminating a run is the
25//! wrapper's job, via drop, timeout, or an explicit kill.
26//! Terminal-attached hosts that want the terminal to stay the
27//! supervisor can opt out with
28//! [`ClaudeBuilder::process_group(false)`](crate::ClaudeBuilder::process_group),
29//! trading the tree kill away: kills then reach only the direct child.
30
31#[cfg(any(feature = "async", feature = "sync"))]
32use std::time::Duration;
33
34#[cfg(feature = "async")]
35use tokio::io::AsyncReadExt;
36#[cfg(feature = "async")]
37use tokio::process::Command;
38#[cfg(any(feature = "async", feature = "sync"))]
39use tracing::{debug, warn};
40
41use crate::Claude;
42#[cfg(any(feature = "async", feature = "sync"))]
43use crate::error::{Error, Result};
44
45/// Assemble the full argv passed to the CLI binary: the client's
46/// global args followed by the command's own args.
47///
48/// Single assembly path shared by every exec entry point and
49/// [`QueryCommand::to_command_string`](crate::QueryCommand::to_command_string),
50/// so a rendered preview cannot drift from what actually spawns.
51pub(crate) fn full_command_args(claude: &Claude, args: Vec<String>) -> Vec<String> {
52    let mut command_args = claude.global_args.clone();
53    command_args.extend(args);
54    command_args
55}
56
57/// Apply the client's environment policy to one CLI child command.
58///
59/// Kept as the single environment assembly point for buffered, streaming,
60/// sync, timeout, retry, stdin, and duplex spawns. Explicit entries are
61/// applied after clearing and after the nested-session scrub, so callers can
62/// deliberately restore an entry when required.
63#[cfg(any(feature = "async", feature = "sync"))]
64pub(crate) fn apply_child_environment(
65    cmd: &mut std::process::Command,
66    clear_env: bool,
67    env: &std::collections::HashMap<String, String>,
68) {
69    if clear_env {
70        cmd.env_clear();
71    }
72    cmd.env_remove("CLAUDECODE");
73    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
74    cmd.envs(env);
75}
76
77/// The subcommand label for a span, derived from the argv.
78///
79/// The first token is the subcommand for subcommand-style invocations
80/// (`mcp`, `plugin`, `doctor`). For print-mode runs it is the leading
81/// flag (`--print`), which is equally informative. Never a value:
82/// values always follow a flag, and the first token cannot be one.
83///
84/// Deliberately not the whole argv. Prompts arrive as argv positionals
85/// and must never reach a span field.
86#[cfg(any(feature = "async", feature = "sync"))]
87pub(crate) fn span_command(args: &[String]) -> &str {
88    args.first().map(String::as_str).unwrap_or("<none>")
89}
90
91/// Open the span covering one CLI invocation.
92///
93/// `exit_code` and `duration_ms` are declared empty and recorded when
94/// the call finishes, so a subscriber sees them on close. Carries the
95/// binary and working directory, never the prompt and never the env.
96#[cfg(any(feature = "async", feature = "sync"))]
97pub(crate) fn exec_span(claude: &Claude, args: &[String], mode: &'static str) -> tracing::Span {
98    tracing::debug_span!(
99        "claude.exec",
100        command = span_command(args),
101        mode,
102        binary = %claude.binary.display(),
103        cwd = claude.working_dir.as_deref().map(|d| d.display().to_string()),
104        exit_code = tracing::field::Empty,
105        duration_ms = tracing::field::Empty,
106    )
107}
108
109/// Record the outcome of an invocation on its span.
110#[cfg(any(feature = "async", feature = "sync"))]
111pub(crate) fn record_exec_outcome(
112    span: &tracing::Span,
113    exit_code: i32,
114    started: std::time::Instant,
115) {
116    span.record("exit_code", exit_code);
117    span.record("duration_ms", started.elapsed().as_millis() as u64);
118}
119
120/// Raw output from a claude CLI invocation.
121#[derive(Debug, Clone)]
122pub struct CommandOutput {
123    /// Captured standard output.
124    pub stdout: String,
125    /// Captured standard error.
126    pub stderr: String,
127    /// Process exit code.
128    pub exit_code: i32,
129    /// Whether the process exited successfully (exit code 0).
130    pub success: bool,
131}
132
133/// Kills the child's entire process group when dropped, unless disarmed.
134///
135/// Every spawn puts the child in its own process group on Unix
136/// (`process_group(0)`), so the group id equals the child's pid.
137/// `kill_on_drop` and `Child::kill` only reach the direct child; this
138/// guard extends cancellation to the subprocesses the CLI spawns for
139/// tool use (shells, MCP servers, test runners), which would otherwise
140/// be reparented and keep running.
141///
142/// Callers must [`disarm`](Self::disarm) the guard once the child's
143/// exit status has been observed: past that point the pid can be reaped
144/// and recycled, and signalling a recycled group would hit unrelated
145/// processes. While the child is unreaped (running or zombie) its pid
146/// cannot be recycled, so firing is safe.
147///
148/// On non-Unix targets the guard is a no-op.
149/// Arm the group-kill guard and tell the observer the child exists.
150///
151/// Kept together so the two cannot disagree about whether the child leads its
152/// own group: the `pgid` reported is `Some` exactly when the guard is armed,
153/// which is exactly when the pid is safe to `killpg`.
154/// The spawn-policy knobs every spawn path carries together.
155///
156/// Bundled because they always travel as a set and because threading them
157/// individually pushed the timeout paths past clippy's argument threshold:
158/// whether the child leads its own group, how long to wait before escalating a
159/// kill, whether the child should die with its parent, and who to tell that it
160/// exists.
161#[cfg(any(feature = "async", feature = "sync"))]
162#[derive(Clone, Copy)]
163pub(crate) struct SpawnPolicy<'a> {
164    pub(crate) process_group: bool,
165    pub(crate) kill_grace: Option<Duration>,
166    pub(crate) die_with_parent: bool,
167    pub(crate) on_spawn: Option<&'a crate::SpawnObserver>,
168}
169
170#[cfg(any(feature = "async", feature = "sync"))]
171impl SpawnPolicy<'_> {
172    /// The policy a [`Claude`] client describes.
173    pub(crate) fn of(claude: &Claude) -> SpawnPolicy<'_> {
174        SpawnPolicy {
175            process_group: claude.process_group,
176            kill_grace: claude.kill_grace,
177            die_with_parent: claude.die_with_parent,
178            on_spawn: claude.on_spawn.as_ref(),
179        }
180    }
181}
182
183#[cfg(any(feature = "async", feature = "sync"))]
184pub(crate) fn arm_and_notify(
185    process_group: bool,
186    pid: Option<u32>,
187    on_spawn: Option<&crate::SpawnObserver>,
188) -> GroupKillGuard {
189    if let (Some(pid), Some(observer)) = (pid, on_spawn) {
190        observer(crate::SpawnInfo {
191            pid,
192            pgid: process_group.then_some(pid),
193        });
194    }
195    GroupKillGuard::new_if(process_group, pid)
196}
197
198#[cfg(any(feature = "async", feature = "sync"))]
199pub(crate) struct GroupKillGuard {
200    #[cfg(unix)]
201    pgid: Option<i32>,
202}
203
204#[cfg(any(feature = "async", feature = "sync"))]
205impl GroupKillGuard {
206    /// Arm a guard only when the child was placed in its own process
207    /// group; otherwise the child's pid is not a group id and must
208    /// never be signalled (see
209    /// [`ClaudeBuilder::process_group`](crate::ClaudeBuilder::process_group)).
210    pub(crate) fn new_if(enabled: bool, pid: Option<u32>) -> Self {
211        Self::new(if enabled { pid } else { None })
212    }
213
214    /// Arm a guard for the child with the given pid (as returned by
215    /// `Child::id`). A `None` pid (child already reaped) leaves the
216    /// guard disarmed.
217    pub(crate) fn new(pid: Option<u32>) -> Self {
218        #[cfg(unix)]
219        {
220            Self {
221                pgid: pid.and_then(|p| i32::try_from(p).ok()),
222            }
223        }
224        #[cfg(not(unix))]
225        {
226            let _ = pid;
227            Self {}
228        }
229    }
230
231    /// Stop the guard from firing: the child's exit status has been
232    /// observed, so the group id is no longer safe to signal.
233    pub(crate) fn disarm(&mut self) {
234        #[cfg(unix)]
235        {
236            self.pgid = None;
237        }
238    }
239
240    /// True while the guard can still signal the group.
241    pub(crate) fn is_armed(&self) -> bool {
242        #[cfg(unix)]
243        {
244            self.pgid.is_some()
245        }
246        #[cfg(not(unix))]
247        {
248            false
249        }
250    }
251
252    /// SIGTERM the whole group (Unix) so the CLI can flush its
253    /// transcript and session state. Does not disarm: callers follow
254    /// up with [`kill_now`](Self::kill_now) once the grace elapses.
255    pub(crate) fn term_now(&self) {
256        #[cfg(unix)]
257        if let Some(pgid) = self.pgid {
258            // SAFETY: plain FFI call with no pointers or invariants;
259            // failure (e.g. ESRCH once the group is gone) is ignored.
260            let _ = unsafe { libc::killpg(pgid, libc::SIGTERM) };
261        }
262    }
263
264    /// SIGKILL the group immediately and disarm.
265    pub(crate) fn kill_now(&mut self) {
266        #[cfg(unix)]
267        if let Some(pgid) = self.pgid.take() {
268            // SAFETY: plain FFI call with no pointers or invariants;
269            // failure (e.g. ESRCH once the group is gone) is ignored.
270            let _ = unsafe { libc::killpg(pgid, libc::SIGKILL) };
271        }
272    }
273}
274
275#[cfg(any(feature = "async", feature = "sync"))]
276impl Drop for GroupKillGuard {
277    fn drop(&mut self) {
278        self.kill_now();
279    }
280}
281
282/// Whether [`ClaudeBuilder::die_with_parent`](crate::ClaudeBuilder::die_with_parent)
283/// does anything on this platform.
284///
285/// `true` only on Linux, which is the only target with a kernel-level
286/// parent-death signal (`PR_SET_PDEATHSIG`). Elsewhere the option is accepted
287/// and has no effect, so a supervisor that needs the guarantee everywhere must
288/// check this and run its own watchdog rather than assume coverage it does not
289/// have.
290#[must_use]
291pub const fn die_with_parent_supported() -> bool {
292    cfg!(target_os = "linux")
293}
294
295/// Ask the kernel to SIGKILL the child when this process dies.
296///
297/// Linux only. Two things make this correct rather than merely present:
298///
299/// - **The fork/prctl race.** `PR_SET_PDEATHSIG` is set by the child *after*
300///   the fork. If the parent dies in that window the signal never arrives and
301///   the child orphans anyway, which is the exact case this exists to prevent.
302///   So the hook re-reads `getppid()` immediately afterwards and exits if the
303///   parent already changed.
304/// - **Async-signal-safety.** Everything called here (`prctl`, `getppid`,
305///   `_exit`) is on the post-fork allowlist. Anything that allocates or takes a
306///   lock would risk deadlocking the child.
307///
308/// The signal is also cleared across `execve` only for setuid binaries, which
309/// `claude` is not, so it survives into the CLI itself.
310#[cfg(all(unix, any(feature = "async", feature = "sync")))]
311fn pdeathsig_hook() -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static {
312    // Read the parent pid before the fork: inside the child, "the parent we
313    // meant" is this value, not whatever getppid happens to return later.
314    let parent = std::process::id();
315    move || {
316        #[cfg(target_os = "linux")]
317        {
318            // SAFETY: async-signal-safe calls only, as required post-fork.
319            unsafe {
320                if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
321                    return Err(std::io::Error::last_os_error());
322                }
323                // Lost the race: the parent died before the signal was armed.
324                if libc::getppid() as u32 != parent {
325                    libc::_exit(1);
326                }
327            }
328        }
329        #[cfg(not(target_os = "linux"))]
330        {
331            let _ = parent;
332        }
333        Ok(())
334    }
335}
336
337/// Apply the parent-death policy to an async spawn. No-op off Linux; see
338/// [`die_with_parent_supported`].
339#[cfg(feature = "async")]
340pub(crate) fn apply_die_with_parent(cmd: &mut Command, enabled: bool) {
341    #[cfg(unix)]
342    if enabled {
343        // SAFETY: the hook is async-signal-safe; see `pdeathsig_hook`.
344        unsafe {
345            cmd.pre_exec(pdeathsig_hook());
346        }
347    }
348    #[cfg(not(unix))]
349    {
350        let _ = (cmd, enabled);
351    }
352}
353
354/// Blocking mirror of [`apply_die_with_parent`].
355#[cfg(feature = "sync")]
356pub(crate) fn apply_die_with_parent_sync(cmd: &mut std::process::Command, enabled: bool) {
357    #[cfg(unix)]
358    if enabled {
359        use std::os::unix::process::CommandExt;
360        // SAFETY: the hook is async-signal-safe; see `pdeathsig_hook`.
361        unsafe {
362            cmd.pre_exec(pdeathsig_hook());
363        }
364    }
365    #[cfg(not(unix))]
366    {
367        let _ = (cmd, enabled);
368    }
369}
370
371/// Apply the client's process-group policy to an async spawn: place
372/// the child in its own group (Unix) unless the builder opted out via
373/// [`ClaudeBuilder::process_group`](crate::ClaudeBuilder::process_group).
374#[cfg(feature = "async")]
375pub(crate) fn apply_process_group(cmd: &mut Command, enabled: bool) {
376    #[cfg(unix)]
377    if enabled {
378        cmd.process_group(0);
379    }
380    #[cfg(not(unix))]
381    {
382        let _ = (cmd, enabled);
383    }
384}
385
386/// Blocking mirror of [`apply_process_group`].
387#[cfg(feature = "sync")]
388pub(crate) fn apply_process_group_sync(cmd: &mut std::process::Command, enabled: bool) {
389    #[cfg(unix)]
390    if enabled {
391        use std::os::unix::process::CommandExt;
392        cmd.process_group(0);
393    }
394    #[cfg(not(unix))]
395    {
396        let _ = (cmd, enabled);
397    }
398}
399
400/// Escalated group kill for the waitable paths: SIGTERM the group,
401/// wait out `grace` without reaping (the zombie child keeps the group
402/// id reserved, so a recycled pid can never be signalled), then
403/// SIGKILL whatever remains. With no grace configured, or when the
404/// child is not in its own process group, this is an immediate
405/// SIGKILL. Drop-path cancellation cannot wait and always SIGKILLs
406/// immediately via the guard's `Drop`.
407#[cfg(feature = "async")]
408pub(crate) async fn kill_group_with_grace(group: &mut GroupKillGuard, grace: Option<Duration>) {
409    if let Some(g) = grace
410        && !g.is_zero()
411        && group.is_armed()
412    {
413        group.term_now();
414        tokio::time::sleep(g).await;
415    }
416    group.kill_now();
417}
418
419#[cfg(feature = "async")]
420enum StopReason {
421    Timeout { timeout_seconds: u64 },
422    Cancelled,
423}
424
425#[cfg(feature = "async")]
426impl StopReason {
427    fn into_error(self) -> Error {
428        match self {
429            Self::Timeout { timeout_seconds } => Error::Timeout { timeout_seconds },
430            Self::Cancelled => Error::Cancelled,
431        }
432    }
433}
434
435#[cfg(feature = "async")]
436type StopFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = StopReason> + Send + 'a>>;
437
438#[cfg(feature = "async")]
439fn timeout_stop(timeout: Duration) -> StopFuture<'static> {
440    Box::pin(async move {
441        tokio::time::sleep(timeout).await;
442        StopReason::Timeout {
443            timeout_seconds: timeout.as_secs(),
444        }
445    })
446}
447
448#[cfg(feature = "async")]
449fn cancellation_or_timeout<'a, C>(cancel: C, timeout: Option<Duration>) -> StopFuture<'a>
450where
451    C: std::future::Future<Output = ()> + Send + 'a,
452{
453    Box::pin(async move {
454        match timeout {
455            Some(timeout) => tokio::select! {
456                () = cancel => StopReason::Cancelled,
457                () = tokio::time::sleep(timeout) => StopReason::Timeout {
458                    timeout_seconds: timeout.as_secs(),
459                },
460            },
461            None => {
462                cancel.await;
463                StopReason::Cancelled
464            }
465        }
466    })
467}
468
469#[cfg(feature = "async")]
470async fn stop_and_reap(
471    child: &mut tokio::process::Child,
472    group: &mut GroupKillGuard,
473    grace: Option<Duration>,
474    working_dir: Option<&std::path::Path>,
475) -> Result<()> {
476    kill_group_with_grace(group, grace).await;
477    if child
478        .try_wait()
479        .map_err(|e| wait_error(e, working_dir))?
480        .is_none()
481        && let Err(error) = child.start_kill()
482        && error.kind() != std::io::ErrorKind::InvalidInput
483    {
484        return Err(wait_error(error, working_dir));
485    }
486    child.wait().await.map_err(|e| wait_error(e, working_dir))?;
487    Ok(())
488}
489
490#[cfg(feature = "async")]
491fn wait_error(error: std::io::Error, working_dir: Option<&std::path::Path>) -> Error {
492    Error::Io {
493        message: "failed to wait for claude process".to_string(),
494        source: error,
495        working_dir: working_dir.map(|p| p.to_path_buf()),
496    }
497}
498
499/// Blocking mirror of [`kill_group_with_grace`].
500#[cfg(feature = "sync")]
501pub(crate) fn kill_group_with_grace_sync(group: &mut GroupKillGuard, grace: Option<Duration>) {
502    if let Some(g) = grace
503        && !g.is_zero()
504        && group.is_armed()
505    {
506        group.term_now();
507        std::thread::sleep(g);
508    }
509    group.kill_now();
510}
511
512/// Run a claude command with the given arguments.
513///
514/// If the [`Claude`] client has a retry policy set, transient errors will be
515/// retried according to that policy. A per-command retry policy can be passed
516/// to override the client default.
517///
518/// Dropping the returned future mid-flight kills the spawned `claude`
519/// process and, on Unix, its whole process group (SIGKILL): an
520/// abandoned run does not keep executing in the background, and the
521/// subprocesses it spawned for tool use die with it.
522#[cfg(feature = "async")]
523pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
524    run_claude_with_retry(claude, args, None).await
525}
526
527/// Run a Claude command with an explicit cancellation signal.
528///
529/// Retry does not apply. When `cancel` resolves, the wrapper terminates the
530/// owned process group and reaps the direct child before returning
531/// [`Error::Cancelled`]. A configured client timeout uses the same path.
532#[cfg(feature = "async")]
533pub async fn run_claude_cancellable<C>(
534    claude: &Claude,
535    args: Vec<String>,
536    cancel: C,
537) -> Result<CommandOutput>
538where
539    C: std::future::Future<Output = ()> + Send,
540{
541    let command_args = full_command_args(claude, args);
542    let span = exec_span(claude, &command_args, "cancellable");
543    let _enter = span.enter();
544    let started = std::time::Instant::now();
545    let stop = cancellation_or_timeout(cancel, claude.timeout);
546    let output = run_with_stop(
547        &claude.binary,
548        &command_args,
549        &claude.env,
550        claude.clear_env,
551        claude.working_dir.as_deref(),
552        stop,
553        SpawnPolicy::of(claude),
554    )
555    .await?;
556    record_exec_outcome(&span, output.exit_code, started);
557    Ok(output)
558}
559
560/// Run a claude command with an optional per-command retry policy override.
561///
562/// Dropping the returned future kills the child; see [`run_claude`].
563#[cfg(feature = "async")]
564pub async fn run_claude_with_retry(
565    claude: &Claude,
566    args: Vec<String>,
567    retry_override: Option<&crate::retry::RetryPolicy>,
568) -> Result<CommandOutput> {
569    let policy = retry_override.or(claude.retry_policy.as_ref());
570
571    match policy {
572        Some(policy) => {
573            crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
574        }
575        None => run_claude_once(claude, args).await,
576    }
577}
578
579/// Run claude, writing `stdin_content` to the child's stdin rather than
580/// passing the prompt as argv.
581///
582/// stdin mode does not retry -- the stdin pipe is consumed after the first
583/// attempt and cannot be rewound for a subsequent try.
584///
585/// Dropping the returned future kills the child; see [`run_claude`].
586#[cfg(feature = "async")]
587pub async fn run_claude_with_stdin_prompt(
588    claude: &Claude,
589    args: Vec<String>,
590    stdin_content: String,
591) -> Result<CommandOutput> {
592    run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
593}
594
595/// Run Claude with a stdin prompt and an explicit cancellation signal.
596///
597/// Cancellation, timeout, and stdin communication failures settle process
598/// ownership before returning. Retry does not apply.
599#[cfg(feature = "async")]
600pub async fn run_claude_with_stdin_prompt_cancellable<C>(
601    claude: &Claude,
602    args: Vec<String>,
603    stdin_content: String,
604    cancel: C,
605) -> Result<CommandOutput>
606where
607    C: std::future::Future<Output = ()> + Send,
608{
609    let command_args = full_command_args(claude, args);
610    let span = exec_span(claude, &command_args, "stdin-cancellable");
611    let _enter = span.enter();
612    let started = std::time::Instant::now();
613    let stop = cancellation_or_timeout(cancel, claude.timeout);
614    let output = run_stdin_with_stop(
615        &claude.binary,
616        &command_args,
617        &claude.env,
618        claude.clear_env,
619        claude.working_dir.as_deref(),
620        stdin_content,
621        stop,
622        SpawnPolicy::of(claude),
623    )
624    .await?;
625    record_exec_outcome(&span, output.exit_code, started);
626    Ok(output)
627}
628
629#[cfg(feature = "async")]
630async fn run_claude_with_stdin_prompt_internal(
631    claude: &Claude,
632    args: Vec<String>,
633    stdin_content: String,
634) -> Result<CommandOutput> {
635    let command_args = full_command_args(claude, args);
636
637    let span = exec_span(claude, &command_args, "stdin");
638    let _enter = span.enter();
639    let started = std::time::Instant::now();
640    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
641
642    let binary = &claude.binary;
643    let env = &claude.env;
644    let clear_env = claude.clear_env;
645    let working_dir = claude.working_dir.as_deref();
646
647    let result = if let Some(timeout) = claude.timeout {
648        run_with_timeout_stdin(
649            binary,
650            &command_args,
651            env,
652            clear_env,
653            working_dir,
654            timeout,
655            stdin_content,
656            SpawnPolicy::of(claude),
657        )
658        .await
659    } else {
660        run_internal_stdin(
661            binary,
662            &command_args,
663            env,
664            clear_env,
665            working_dir,
666            stdin_content,
667            SpawnPolicy::of(claude),
668        )
669        .await
670    };
671
672    if let Ok(output) = &result {
673        record_exec_outcome(&span, output.exit_code, started);
674    }
675    result
676}
677
678#[cfg(feature = "async")]
679async fn run_internal_stdin(
680    binary: &std::path::Path,
681    args: &[String],
682    env: &std::collections::HashMap<String, String>,
683    clear_env: bool,
684    working_dir: Option<&std::path::Path>,
685    stdin_content: String,
686    policy: SpawnPolicy<'_>,
687) -> Result<CommandOutput> {
688    let SpawnPolicy {
689        process_group,
690        kill_grace,
691        die_with_parent,
692        on_spawn,
693    } = policy;
694    use tokio::io::AsyncWriteExt;
695
696    let mut cmd = Command::new(binary);
697    cmd.args(args);
698    cmd.stdin(std::process::Stdio::piped());
699    cmd.stdout(std::process::Stdio::piped());
700    cmd.stderr(std::process::Stdio::piped());
701    // Dropping the in-flight future must kill the child, not leave the
702    // CLI running unattended (see the module docs).
703    cmd.kill_on_drop(true);
704    // Own process group (Unix) so cancellation can signal the whole
705    // tree, not just the direct child (see GroupKillGuard). Opt out
706    // via ClaudeBuilder::process_group.
707    apply_process_group(&mut cmd, process_group);
708    apply_die_with_parent(&mut cmd, die_with_parent);
709    apply_child_environment(cmd.as_std_mut(), clear_env, env);
710
711    if let Some(dir) = working_dir {
712        cmd.current_dir(dir);
713    }
714
715    let mut child = spawn_retrying_txtbsy(&mut cmd)
716        .await
717        .map_err(|e| Error::Io {
718            message: format!("failed to spawn claude: {e}"),
719            source: e,
720            working_dir: working_dir.map(|p| p.to_path_buf()),
721        })?;
722    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
723
724    // Write the prompt to stdin, then drop the handle so the child sees EOF.
725    if let Some(mut stdin) = child.stdin.take()
726        && let Err(error) = stdin.write_all(stdin_content.as_bytes()).await
727    {
728        let error = Error::Io {
729            message: format!("failed to write to claude stdin: {error}"),
730            source: error,
731            working_dir: working_dir.map(|p| p.to_path_buf()),
732        };
733        stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
734        return Err(error);
735    }
736
737    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
738    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
739
740    let (status, stdout_str, stderr_str) = tokio::join!(
741        child.wait(),
742        drain(&mut stdout_handle),
743        drain(&mut stderr_handle),
744    );
745
746    let status = status.map_err(|e| Error::Io {
747        message: "failed to wait for claude process".to_string(),
748        source: e,
749        working_dir: working_dir.map(|p| p.to_path_buf()),
750    })?;
751    group.disarm();
752
753    let exit_code = status.code().unwrap_or(-1);
754
755    if !status.success() {
756        return Err(Error::from_command_failure(
757            format!("{} {}", binary.display(), args.join(" ")),
758            exit_code,
759            stdout_str,
760            stderr_str,
761            working_dir.map(|p| p.to_path_buf()),
762        ));
763    }
764
765    Ok(CommandOutput {
766        stdout: stdout_str,
767        stderr: stderr_str,
768        exit_code,
769        success: true,
770    })
771}
772
773#[cfg(feature = "async")]
774#[allow(clippy::too_many_arguments)]
775async fn run_with_timeout_stdin(
776    binary: &std::path::Path,
777    args: &[String],
778    env: &std::collections::HashMap<String, String>,
779    clear_env: bool,
780    working_dir: Option<&std::path::Path>,
781    timeout: Duration,
782    stdin_content: String,
783    policy: SpawnPolicy<'_>,
784) -> Result<CommandOutput> {
785    run_stdin_with_stop(
786        binary,
787        args,
788        env,
789        clear_env,
790        working_dir,
791        stdin_content,
792        timeout_stop(timeout),
793        policy,
794    )
795    .await
796}
797
798#[cfg(feature = "async")]
799#[allow(clippy::too_many_arguments)]
800async fn run_stdin_with_stop(
801    binary: &std::path::Path,
802    args: &[String],
803    env: &std::collections::HashMap<String, String>,
804    clear_env: bool,
805    working_dir: Option<&std::path::Path>,
806    stdin_content: String,
807    stop: StopFuture<'_>,
808    policy: SpawnPolicy<'_>,
809) -> Result<CommandOutput> {
810    let SpawnPolicy {
811        process_group,
812        kill_grace,
813        die_with_parent,
814        on_spawn,
815    } = policy;
816    use tokio::io::AsyncWriteExt;
817
818    let mut cmd = Command::new(binary);
819    cmd.args(args);
820    cmd.stdin(std::process::Stdio::piped());
821    cmd.stdout(std::process::Stdio::piped());
822    cmd.stderr(std::process::Stdio::piped());
823    // Dropping the in-flight future must kill the child, not leave the
824    // CLI running unattended (see the module docs).
825    cmd.kill_on_drop(true);
826    // Own process group (Unix) so cancellation can signal the whole
827    // tree, not just the direct child (see GroupKillGuard). Opt out
828    // via ClaudeBuilder::process_group.
829    apply_process_group(&mut cmd, process_group);
830    apply_die_with_parent(&mut cmd, die_with_parent);
831    apply_child_environment(cmd.as_std_mut(), clear_env, env);
832
833    if let Some(dir) = working_dir {
834        cmd.current_dir(dir);
835    }
836
837    let mut child = spawn_retrying_txtbsy(&mut cmd)
838        .await
839        .map_err(|e| Error::Io {
840            message: format!("failed to spawn claude: {e}"),
841            source: e,
842            working_dir: working_dir.map(|p| p.to_path_buf()),
843        })?;
844    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
845
846    let child_stdin = child.stdin.take();
847    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
848    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
849    let write = async move {
850        let Some(mut stdin) = child_stdin else {
851            return Ok(());
852        };
853        stdin
854            .write_all(stdin_content.as_bytes())
855            .await
856            .map_err(|e| Error::Io {
857                message: format!("failed to write to claude stdin: {e}"),
858                source: e,
859                working_dir: working_dir.map(|p| p.to_path_buf()),
860            })
861    };
862    let read_stdout = async {
863        drain_result(&mut stdout_handle)
864            .await
865            .map_err(|e| Error::Io {
866                message: format!("failed to read claude stdout: {e}"),
867                source: e,
868                working_dir: working_dir.map(|p| p.to_path_buf()),
869            })
870    };
871    let read_stderr = async {
872        drain_result(&mut stderr_handle)
873            .await
874            .map_err(|e| Error::Io {
875                message: format!("failed to read claude stderr: {e}"),
876                source: e,
877                working_dir: working_dir.map(|p| p.to_path_buf()),
878            })
879    };
880    let wait = async { child.wait().await.map_err(|e| wait_error(e, working_dir)) };
881    let run = async {
882        let ((), status, stdout, stderr) = tokio::try_join!(write, wait, read_stdout, read_stderr)?;
883        Ok::<_, Error>((status, stdout, stderr))
884    };
885
886    match tokio::select! {
887        outcome = run => Ok(outcome),
888        reason = stop => Err(reason),
889    } {
890        Ok(Ok((status, stdout, stderr))) => {
891            group.disarm();
892            let exit_code = status.code().unwrap_or(-1);
893
894            if !status.success() {
895                return Err(Error::from_command_failure(
896                    format!("{} {}", binary.display(), args.join(" ")),
897                    exit_code,
898                    stdout,
899                    stderr,
900                    working_dir.map(|p| p.to_path_buf()),
901                ));
902            }
903
904            Ok(CommandOutput {
905                stdout,
906                stderr,
907                exit_code,
908                success: true,
909            })
910        }
911        Ok(Err(error)) => {
912            stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
913            Err(error)
914        }
915        Err(reason) => {
916            // Take down the whole group first (subprocesses may hold our pipe
917            // fds), honoring the optional SIGTERM grace, then kill+reap the
918            // direct child.
919            stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
920            Err(reason.into_error())
921        }
922    }
923}
924
925#[cfg(feature = "async")]
926async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
927    let command_args = full_command_args(claude, args);
928
929    let span = exec_span(claude, &command_args, "oneshot");
930    let _enter = span.enter();
931    let started = std::time::Instant::now();
932    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
933
934    let output = if let Some(timeout) = claude.timeout {
935        run_with_timeout(
936            &claude.binary,
937            &command_args,
938            &claude.env,
939            claude.clear_env,
940            claude.working_dir.as_deref(),
941            timeout,
942            SpawnPolicy::of(claude),
943        )
944        .await?
945    } else {
946        run_internal(
947            &claude.binary,
948            &command_args,
949            &claude.env,
950            claude.clear_env,
951            claude.working_dir.as_deref(),
952            SpawnPolicy::of(claude),
953        )
954        .await?
955    };
956
957    record_exec_outcome(&span, output.exit_code, started);
958    Ok(output)
959}
960
961/// Run a claude command and allow specific non-zero exit codes.
962///
963/// Dropping the returned future kills the child; see [`run_claude`].
964#[cfg(feature = "async")]
965pub async fn run_claude_allow_exit_codes(
966    claude: &Claude,
967    args: Vec<String>,
968    allowed_codes: &[i32],
969) -> Result<CommandOutput> {
970    let output = run_claude(claude, args).await;
971
972    match output {
973        Err(Error::CommandFailed {
974            exit_code,
975            stdout,
976            stderr,
977            ..
978        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
979            stdout,
980            stderr,
981            exit_code,
982            success: false,
983        }),
984        other => other,
985    }
986}
987
988#[cfg(feature = "async")]
989async fn run_internal(
990    binary: &std::path::Path,
991    args: &[String],
992    env: &std::collections::HashMap<String, String>,
993    clear_env: bool,
994    working_dir: Option<&std::path::Path>,
995    policy: SpawnPolicy<'_>,
996) -> Result<CommandOutput> {
997    let SpawnPolicy {
998        process_group,
999        kill_grace: _, // no kill site on this path
1000        die_with_parent,
1001        on_spawn,
1002    } = policy;
1003    let mut cmd = Command::new(binary);
1004    cmd.args(args);
1005
1006    // Prevent child from inheriting/blocking on parent's stdin.
1007    cmd.stdin(std::process::Stdio::null());
1008    cmd.stdout(std::process::Stdio::piped());
1009    cmd.stderr(std::process::Stdio::piped());
1010
1011    // Dropping the in-flight future must kill the child, not leave the
1012    // CLI running unattended (see the module docs).
1013    cmd.kill_on_drop(true);
1014    // Own process group (Unix) so cancellation can signal the whole
1015    // tree, not just the direct child (see GroupKillGuard). Opt out
1016    // via ClaudeBuilder::process_group.
1017    apply_process_group(&mut cmd, process_group);
1018    apply_die_with_parent(&mut cmd, die_with_parent);
1019
1020    apply_child_environment(cmd.as_std_mut(), clear_env, env);
1021
1022    if let Some(dir) = working_dir {
1023        cmd.current_dir(dir);
1024    }
1025
1026    // Spawn explicitly (rather than `Command::output`) so the pid is
1027    // available to the group-kill guard while the run is in flight.
1028    let mut child = spawn_retrying_txtbsy(&mut cmd)
1029        .await
1030        .map_err(|e| Error::Io {
1031            message: format!("failed to spawn claude: {e}"),
1032            source: e,
1033            working_dir: working_dir.map(|p| p.to_path_buf()),
1034        })?;
1035    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
1036
1037    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
1038    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
1039
1040    let (status, stdout, stderr) = tokio::join!(
1041        child.wait(),
1042        drain(&mut stdout_handle),
1043        drain(&mut stderr_handle),
1044    );
1045
1046    let status = status.map_err(|e| Error::Io {
1047        message: "failed to wait for claude process".to_string(),
1048        source: e,
1049        working_dir: working_dir.map(|p| p.to_path_buf()),
1050    })?;
1051    group.disarm();
1052
1053    let exit_code = status.code().unwrap_or(-1);
1054
1055    if !status.success() {
1056        return Err(Error::from_command_failure(
1057            format!("{} {}", binary.display(), args.join(" ")),
1058            exit_code,
1059            stdout,
1060            stderr,
1061            working_dir.map(|p| p.to_path_buf()),
1062        ));
1063    }
1064
1065    Ok(CommandOutput {
1066        stdout,
1067        stderr,
1068        exit_code,
1069        success: true,
1070    })
1071}
1072
1073/// Run a command with a timeout, killing the child's whole process
1074/// group (Unix) and reaping the child on expiration.
1075///
1076/// Spawns the child explicitly (rather than wrapping `Command::output()` in a
1077/// `tokio::time::timeout`) so that we retain the handle and can SIGKILL the
1078/// child and wait for it when the timeout fires. Stdout and stderr are drained
1079/// concurrently with `child.wait()` via `tokio::join!` so neither pipe buffer
1080/// can fill up and deadlock the child.
1081///
1082/// On timeout, partial stdout/stderr captured before the kill is logged at
1083/// warn level; the returned `Error::Timeout` itself does not carry the
1084/// partial output.
1085#[cfg(feature = "async")]
1086async fn run_with_timeout(
1087    binary: &std::path::Path,
1088    args: &[String],
1089    env: &std::collections::HashMap<String, String>,
1090    clear_env: bool,
1091    working_dir: Option<&std::path::Path>,
1092    timeout: Duration,
1093    policy: SpawnPolicy<'_>,
1094) -> Result<CommandOutput> {
1095    run_with_stop(
1096        binary,
1097        args,
1098        env,
1099        clear_env,
1100        working_dir,
1101        timeout_stop(timeout),
1102        policy,
1103    )
1104    .await
1105}
1106
1107#[cfg(feature = "async")]
1108async fn run_with_stop(
1109    binary: &std::path::Path,
1110    args: &[String],
1111    env: &std::collections::HashMap<String, String>,
1112    clear_env: bool,
1113    working_dir: Option<&std::path::Path>,
1114    stop: StopFuture<'_>,
1115    policy: SpawnPolicy<'_>,
1116) -> Result<CommandOutput> {
1117    let SpawnPolicy {
1118        process_group,
1119        kill_grace,
1120        die_with_parent,
1121        on_spawn,
1122    } = policy;
1123    let mut cmd = Command::new(binary);
1124    cmd.args(args);
1125    cmd.stdin(std::process::Stdio::null());
1126    cmd.stdout(std::process::Stdio::piped());
1127    cmd.stderr(std::process::Stdio::piped());
1128    // Dropping the in-flight future must kill the child, not leave the
1129    // CLI running unattended (see the module docs).
1130    cmd.kill_on_drop(true);
1131    // Own process group (Unix) so cancellation can signal the whole
1132    // tree, not just the direct child (see GroupKillGuard). Opt out
1133    // via ClaudeBuilder::process_group.
1134    apply_process_group(&mut cmd, process_group);
1135    apply_die_with_parent(&mut cmd, die_with_parent);
1136    apply_child_environment(cmd.as_std_mut(), clear_env, env);
1137
1138    if let Some(dir) = working_dir {
1139        cmd.current_dir(dir);
1140    }
1141
1142    let mut child = spawn_retrying_txtbsy(&mut cmd)
1143        .await
1144        .map_err(|e| Error::Io {
1145            message: format!("failed to spawn claude: {e}"),
1146            source: e,
1147            working_dir: working_dir.map(|p| p.to_path_buf()),
1148        })?;
1149    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
1150
1151    let mut stdout = child.stdout.take().expect("stdout was piped");
1152    let mut stderr = child.stderr.take().expect("stderr was piped");
1153
1154    // Drain stdout and stderr concurrently with the process wait so
1155    // neither pipe buffer can fill up and deadlock the child.
1156    // tokio::join! polls all three on the same task; no tokio::spawn
1157    // (and therefore no `rt` feature) required.
1158    let wait_and_drain = async {
1159        let (status, stdout_str, stderr_str) =
1160            tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
1161        (status, stdout_str, stderr_str)
1162    };
1163
1164    match tokio::select! {
1165        outcome = wait_and_drain => Ok(outcome),
1166        reason = stop => Err(reason),
1167    } {
1168        Ok((Ok(status), stdout, stderr)) => {
1169            group.disarm();
1170            let exit_code = status.code().unwrap_or(-1);
1171
1172            if !status.success() {
1173                return Err(Error::from_command_failure(
1174                    format!("{} {}", binary.display(), args.join(" ")),
1175                    exit_code,
1176                    stdout,
1177                    stderr,
1178                    working_dir.map(|p| p.to_path_buf()),
1179                ));
1180            }
1181
1182            Ok(CommandOutput {
1183                stdout,
1184                stderr,
1185                exit_code,
1186                success: true,
1187            })
1188        }
1189        Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
1190            message: "failed to wait for claude process".to_string(),
1191            source: e,
1192            working_dir: working_dir.map(|p| p.to_path_buf()),
1193        }),
1194        Err(reason) => {
1195            // Take down the whole group, honoring the optional SIGTERM grace,
1196            // then kill+reap the direct child. The group kill takes down
1197            // subprocesses that could otherwise hold our pipe fds open
1198            // forever; the capped drain below stays as a backstop.
1199            stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
1200            let drain_budget = Duration::from_millis(200);
1201            let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
1202                .await
1203                .unwrap_or_default();
1204            let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
1205                .await
1206                .unwrap_or_default();
1207            if !stdout_str.is_empty() || !stderr_str.is_empty() {
1208                warn!(
1209                    stdout = %stdout_str,
1210                    stderr = %stderr_str,
1211                    "partial output from stopped process",
1212                );
1213            }
1214            Err(reason.into_error())
1215        }
1216    }
1217}
1218
1219#[cfg(feature = "async")]
1220async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
1221    let mut buf = Vec::new();
1222    let _ = reader.read_to_end(&mut buf).await;
1223    String::from_utf8_lossy(&buf).into_owned()
1224}
1225
1226#[cfg(feature = "async")]
1227async fn drain_result<R: AsyncReadExt + Unpin>(reader: &mut R) -> std::io::Result<String> {
1228    let mut buf = Vec::new();
1229    reader.read_to_end(&mut buf).await?;
1230    Ok(String::from_utf8_lossy(&buf).into_owned())
1231}
1232
1233/// Total wall-clock time to keep retrying a spawn that reports `ETXTBSY`.
1234///
1235/// Measured as elapsed time rather than a sum of backoffs so a saturated
1236/// host (a CI job running build + clippy + tests at once) still gets the
1237/// full window: the busy descriptor can stay open longer than the old
1238/// 500ms budget under that load, which surfaced as a spurious spawn
1239/// failure. This is only ever spent when a real `ETXTBSY` occurs, which
1240/// does not happen against an already-installed binary in production.
1241#[cfg(any(feature = "async", feature = "sync"))]
1242const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
1243
1244/// Per-attempt backoff ceiling while retrying `ETXTBSY`.
1245///
1246/// Backoff grows exponentially but is capped so retries stay frequent for
1247/// the whole budget: the busy window can clear at any instant, and a large
1248/// tail sleep (the old loop reached 1-2s) would keep spawning stalled long
1249/// after the descriptor closed.
1250#[cfg(any(feature = "async", feature = "sync"))]
1251const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
1252
1253/// Spawn `cmd`, retrying briefly on `ETXTBSY` (`ExecutableFileBusy`).
1254///
1255/// `execve` fails with `ETXTBSY` when another process holds the target file
1256/// open for writing. In a multithreaded program this happens transiently even
1257/// for a file this process has finished writing: if another thread `fork`s
1258/// while a writable descriptor to the binary is still open, the child inherits
1259/// that descriptor and holds it until its own `exec` completes. Any `execve`
1260/// of the file in that window sees a writer and fails. The condition always
1261/// clears on its own, so retry within a bounded wall-clock budget rather than
1262/// surfacing a spurious spawn failure.
1263#[cfg(feature = "async")]
1264async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
1265    let start = std::time::Instant::now();
1266    let mut backoff = Duration::from_millis(1);
1267    loop {
1268        match cmd.spawn() {
1269            Err(e)
1270                if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1271                    && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1272            {
1273                tokio::time::sleep(backoff).await;
1274                backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1275            }
1276            other => return other,
1277        }
1278    }
1279}
1280
1281// ---------- sync twins ----------
1282
1283/// Blocking mirror of [`run_claude`]. Available with the `sync` feature.
1284#[cfg(feature = "sync")]
1285pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1286    run_claude_with_retry_sync(claude, args, None)
1287}
1288
1289/// Blocking mirror of [`run_claude_with_retry`].
1290#[cfg(feature = "sync")]
1291pub fn run_claude_with_retry_sync(
1292    claude: &Claude,
1293    args: Vec<String>,
1294    retry_override: Option<&crate::retry::RetryPolicy>,
1295) -> Result<CommandOutput> {
1296    let policy = retry_override.or(claude.retry_policy.as_ref());
1297
1298    match policy {
1299        Some(policy) => {
1300            crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
1301        }
1302        None => run_claude_once_sync(claude, args),
1303    }
1304}
1305
1306/// Blocking mirror of [`run_claude_with_stdin_prompt`].
1307///
1308/// stdin mode does not retry -- the stdin pipe is consumed after the first
1309/// attempt and cannot be rewound.
1310#[cfg(feature = "sync")]
1311pub fn run_claude_with_stdin_prompt_sync(
1312    claude: &Claude,
1313    args: Vec<String>,
1314    stdin_content: String,
1315) -> Result<CommandOutput> {
1316    let command_args = full_command_args(claude, args);
1317
1318    let span = exec_span(claude, &command_args, "stdin-sync");
1319    let _enter = span.enter();
1320    let started = std::time::Instant::now();
1321    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
1322
1323    let result = if let Some(timeout) = claude.timeout {
1324        run_with_timeout_stdin_sync(
1325            &claude.binary,
1326            &command_args,
1327            &claude.env,
1328            claude.clear_env,
1329            claude.working_dir.as_deref(),
1330            timeout,
1331            stdin_content,
1332            SpawnPolicy::of(claude),
1333        )
1334    } else {
1335        run_internal_stdin_sync(
1336            &claude.binary,
1337            &command_args,
1338            &claude.env,
1339            claude.clear_env,
1340            claude.working_dir.as_deref(),
1341            stdin_content,
1342            SpawnPolicy::of(claude),
1343        )
1344    };
1345
1346    if let Ok(output) = &result {
1347        record_exec_outcome(&span, output.exit_code, started);
1348    }
1349    result
1350}
1351
1352#[cfg(feature = "sync")]
1353fn run_internal_stdin_sync(
1354    binary: &std::path::Path,
1355    args: &[String],
1356    env: &std::collections::HashMap<String, String>,
1357    clear_env: bool,
1358    working_dir: Option<&std::path::Path>,
1359    stdin_content: String,
1360    policy: SpawnPolicy<'_>,
1361) -> Result<CommandOutput> {
1362    let SpawnPolicy {
1363        process_group,
1364        kill_grace: _, // no kill site on this path
1365        die_with_parent,
1366        on_spawn,
1367    } = policy;
1368    use std::io::Write;
1369    use std::process::{Command as StdCommand, Stdio};
1370
1371    let mut cmd = StdCommand::new(binary);
1372    cmd.args(args);
1373    cmd.stdin(Stdio::piped());
1374    cmd.stdout(Stdio::piped());
1375    cmd.stderr(Stdio::piped());
1376    // Own process group (Unix) so a kill can signal the whole tree,
1377    // not just the direct child (see GroupKillGuard). Opt out via
1378    // ClaudeBuilder::process_group.
1379    apply_process_group_sync(&mut cmd, process_group);
1380    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1381    apply_child_environment(&mut cmd, clear_env, env);
1382
1383    if let Some(dir) = working_dir {
1384        cmd.current_dir(dir);
1385    }
1386
1387    let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1388        message: format!("failed to spawn claude: {e}"),
1389        source: e,
1390        working_dir: working_dir.map(|p| p.to_path_buf()),
1391    })?;
1392    let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1393
1394    // Write the prompt to stdin, then drop the handle so the child sees EOF.
1395    if let Some(mut stdin) = child.stdin.take() {
1396        stdin
1397            .write_all(stdin_content.as_bytes())
1398            .map_err(|e| Error::Io {
1399                message: format!("failed to write to claude stdin: {e}"),
1400                source: e,
1401                working_dir: working_dir.map(|p| p.to_path_buf()),
1402            })?;
1403        stdin.flush().map_err(|e| Error::Io {
1404            message: format!("failed to flush claude stdin: {e}"),
1405            source: e,
1406            working_dir: working_dir.map(|p| p.to_path_buf()),
1407        })?;
1408        // Drop stdin so the child sees EOF.
1409    }
1410
1411    let output = child.wait_with_output().map_err(|e| Error::Io {
1412        message: "failed to wait for claude process".to_string(),
1413        source: e,
1414        working_dir: working_dir.map(|p| p.to_path_buf()),
1415    })?;
1416    group.disarm();
1417
1418    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1419    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1420    let exit_code = output.status.code().unwrap_or(-1);
1421
1422    if !output.status.success() {
1423        return Err(Error::from_command_failure(
1424            format!("{} {}", binary.display(), args.join(" ")),
1425            exit_code,
1426            stdout,
1427            stderr,
1428            working_dir.map(|p| p.to_path_buf()),
1429        ));
1430    }
1431
1432    Ok(CommandOutput {
1433        stdout,
1434        stderr,
1435        exit_code,
1436        success: true,
1437    })
1438}
1439
1440#[cfg(feature = "sync")]
1441#[allow(clippy::too_many_arguments)]
1442fn run_with_timeout_stdin_sync(
1443    binary: &std::path::Path,
1444    args: &[String],
1445    env: &std::collections::HashMap<String, String>,
1446    clear_env: bool,
1447    working_dir: Option<&std::path::Path>,
1448    timeout: Duration,
1449    stdin_content: String,
1450    policy: SpawnPolicy<'_>,
1451) -> Result<CommandOutput> {
1452    let SpawnPolicy {
1453        process_group,
1454        kill_grace,
1455        die_with_parent,
1456        on_spawn,
1457    } = policy;
1458    use std::io::Write;
1459    use std::process::{Command as StdCommand, Stdio};
1460    use std::thread;
1461    use wait_timeout::ChildExt;
1462
1463    let mut cmd = StdCommand::new(binary);
1464    cmd.args(args);
1465    cmd.stdin(Stdio::piped());
1466    cmd.stdout(Stdio::piped());
1467    cmd.stderr(Stdio::piped());
1468    // Own process group (Unix) so a kill can signal the whole tree,
1469    // not just the direct child (see GroupKillGuard). Opt out via
1470    // ClaudeBuilder::process_group.
1471    apply_process_group_sync(&mut cmd, process_group);
1472    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1473    apply_child_environment(&mut cmd, clear_env, env);
1474
1475    if let Some(dir) = working_dir {
1476        cmd.current_dir(dir);
1477    }
1478
1479    let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1480        message: format!("failed to spawn claude: {e}"),
1481        source: e,
1482        working_dir: working_dir.map(|p| p.to_path_buf()),
1483    })?;
1484    let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1485
1486    // Write the prompt to stdin, then drop the handle so the child sees EOF.
1487    if let Some(mut stdin) = child.stdin.take() {
1488        stdin
1489            .write_all(stdin_content.as_bytes())
1490            .map_err(|e| Error::Io {
1491                message: format!("failed to write to claude stdin: {e}"),
1492                source: e,
1493                working_dir: working_dir.map(|p| p.to_path_buf()),
1494            })?;
1495        stdin.flush().map_err(|e| Error::Io {
1496            message: format!("failed to flush claude stdin: {e}"),
1497            source: e,
1498            working_dir: working_dir.map(|p| p.to_path_buf()),
1499        })?;
1500        // Drop stdin so the child sees EOF.
1501    }
1502
1503    let stdout = child.stdout.take().expect("stdout was piped");
1504    let stderr = child.stderr.take().expect("stderr was piped");
1505
1506    let stdout_thread = thread::spawn(move || drain_sync(stdout));
1507    let stderr_thread = thread::spawn(move || drain_sync(stderr));
1508
1509    match child.wait_timeout(timeout).map_err(|e| Error::Io {
1510        message: "failed to wait for claude process".to_string(),
1511        source: e,
1512        working_dir: working_dir.map(|p| p.to_path_buf()),
1513    })? {
1514        Some(status) => {
1515            group.disarm();
1516            let stdout = stdout_thread.join().unwrap_or_default();
1517            let stderr = stderr_thread.join().unwrap_or_default();
1518            let exit_code = status.code().unwrap_or(-1);
1519
1520            if !status.success() {
1521                return Err(Error::from_command_failure(
1522                    format!("{} {}", binary.display(), args.join(" ")),
1523                    exit_code,
1524                    stdout,
1525                    stderr,
1526                    working_dir.map(|p| p.to_path_buf()),
1527                ));
1528            }
1529
1530            Ok(CommandOutput {
1531                stdout,
1532                stderr,
1533                exit_code,
1534                success: true,
1535            })
1536        }
1537        None => {
1538            // Timeout: take down the whole group first (subprocesses
1539            // may hold our pipe fds), honoring the optional SIGTERM
1540            // grace, then kill+reap the direct child.
1541            kill_group_with_grace_sync(&mut group, kill_grace);
1542            let _ = child.kill();
1543            let _ = child.wait();
1544            let (stdout_str, stderr_str) =
1545                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1546            if !stdout_str.is_empty() || !stderr_str.is_empty() {
1547                warn!(
1548                    stdout = %stdout_str,
1549                    stderr = %stderr_str,
1550                    "partial output from timed-out process",
1551                );
1552            }
1553            Err(Error::Timeout {
1554                timeout_seconds: timeout.as_secs(),
1555            })
1556        }
1557    }
1558}
1559
1560#[cfg(feature = "sync")]
1561fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1562    let command_args = full_command_args(claude, args);
1563
1564    let span = exec_span(claude, &command_args, "oneshot-sync");
1565    let _enter = span.enter();
1566    let started = std::time::Instant::now();
1567    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
1568
1569    let result = if let Some(timeout) = claude.timeout {
1570        run_with_timeout_sync(
1571            &claude.binary,
1572            &command_args,
1573            &claude.env,
1574            claude.clear_env,
1575            claude.working_dir.as_deref(),
1576            timeout,
1577            SpawnPolicy::of(claude),
1578        )
1579    } else {
1580        run_internal_sync(
1581            &claude.binary,
1582            &command_args,
1583            &claude.env,
1584            claude.clear_env,
1585            claude.working_dir.as_deref(),
1586            SpawnPolicy::of(claude),
1587        )
1588    };
1589
1590    if let Ok(output) = &result {
1591        record_exec_outcome(&span, output.exit_code, started);
1592    }
1593    result
1594}
1595
1596/// Blocking mirror of [`run_claude_allow_exit_codes`].
1597#[cfg(feature = "sync")]
1598pub fn run_claude_allow_exit_codes_sync(
1599    claude: &Claude,
1600    args: Vec<String>,
1601    allowed_codes: &[i32],
1602) -> Result<CommandOutput> {
1603    match run_claude_sync(claude, args) {
1604        Err(Error::CommandFailed {
1605            exit_code,
1606            stdout,
1607            stderr,
1608            ..
1609        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
1610            stdout,
1611            stderr,
1612            exit_code,
1613            success: false,
1614        }),
1615        other => other,
1616    }
1617}
1618
1619#[cfg(feature = "sync")]
1620fn run_internal_sync(
1621    binary: &std::path::Path,
1622    args: &[String],
1623    env: &std::collections::HashMap<String, String>,
1624    clear_env: bool,
1625    working_dir: Option<&std::path::Path>,
1626    policy: SpawnPolicy<'_>,
1627) -> Result<CommandOutput> {
1628    let SpawnPolicy {
1629        process_group,
1630        kill_grace: _, // no kill site on this path
1631        die_with_parent,
1632        on_spawn,
1633    } = policy;
1634    use std::process::{Command as StdCommand, Stdio};
1635
1636    let mut cmd = StdCommand::new(binary);
1637    cmd.args(args);
1638    cmd.stdin(Stdio::null());
1639    // Own process group (Unix); see the module docs. This path has no
1640    // kill site (a blocking call cannot be cancelled mid-flight), so
1641    // there is no guard to arm -- but the child still exists and a
1642    // supervisor still wants its pid, so the spawn is observed below.
1643    apply_process_group_sync(&mut cmd, process_group);
1644    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1645    apply_child_environment(&mut cmd, clear_env, env);
1646
1647    if let Some(dir) = working_dir {
1648        cmd.current_dir(dir);
1649    }
1650
1651    let output =
1652        output_retrying_txtbsy_sync_observed(&mut cmd, process_group, on_spawn).map_err(|e| {
1653            Error::Io {
1654                message: format!("failed to spawn claude: {e}"),
1655                source: e,
1656                working_dir: working_dir.map(|p| p.to_path_buf()),
1657            }
1658        })?;
1659
1660    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1661    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1662    let exit_code = output.status.code().unwrap_or(-1);
1663
1664    if !output.status.success() {
1665        return Err(Error::from_command_failure(
1666            format!("{} {}", binary.display(), args.join(" ")),
1667            exit_code,
1668            stdout,
1669            stderr,
1670            working_dir.map(|p| p.to_path_buf()),
1671        ));
1672    }
1673
1674    Ok(CommandOutput {
1675        stdout,
1676        stderr,
1677        exit_code,
1678        success: true,
1679    })
1680}
1681
1682/// Blocking run with a timeout. Mirrors [`run_with_timeout`]: spawns
1683/// the child, drains stdout/stderr on dedicated threads so neither
1684/// pipe buffer can fill up while we wait, then uses
1685/// [`wait_timeout::ChildExt::wait_timeout`] to enforce the deadline.
1686/// On timeout, the child's whole process group (Unix) is SIGKILLed and
1687/// the child reaped; partial output is logged at warn but the returned
1688/// [`Error::Timeout`] does not carry it.
1689#[cfg(feature = "sync")]
1690fn run_with_timeout_sync(
1691    binary: &std::path::Path,
1692    args: &[String],
1693    env: &std::collections::HashMap<String, String>,
1694    clear_env: bool,
1695    working_dir: Option<&std::path::Path>,
1696    timeout: Duration,
1697    policy: SpawnPolicy<'_>,
1698) -> Result<CommandOutput> {
1699    let SpawnPolicy {
1700        process_group,
1701        kill_grace,
1702        die_with_parent,
1703        on_spawn,
1704    } = policy;
1705    use std::process::{Command as StdCommand, Stdio};
1706    use std::thread;
1707    use wait_timeout::ChildExt;
1708
1709    let mut cmd = StdCommand::new(binary);
1710    cmd.args(args);
1711    cmd.stdin(Stdio::null());
1712    cmd.stdout(Stdio::piped());
1713    cmd.stderr(Stdio::piped());
1714    // Own process group (Unix) so a kill can signal the whole tree,
1715    // not just the direct child (see GroupKillGuard). Opt out via
1716    // ClaudeBuilder::process_group.
1717    apply_process_group_sync(&mut cmd, process_group);
1718    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1719    apply_child_environment(&mut cmd, clear_env, env);
1720
1721    if let Some(dir) = working_dir {
1722        cmd.current_dir(dir);
1723    }
1724
1725    let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1726        message: format!("failed to spawn claude: {e}"),
1727        source: e,
1728        working_dir: working_dir.map(|p| p.to_path_buf()),
1729    })?;
1730    let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1731
1732    // Detach stdout/stderr onto their own threads so neither can block
1733    // the child by filling its pipe buffer. Each thread owns its half
1734    // and drops it on completion, which closes the parent's fd and
1735    // lets read_to_end() return EOF once the child exits.
1736    let stdout = child.stdout.take().expect("stdout was piped");
1737    let stderr = child.stderr.take().expect("stderr was piped");
1738
1739    let stdout_thread = thread::spawn(move || drain_sync(stdout));
1740    let stderr_thread = thread::spawn(move || drain_sync(stderr));
1741
1742    match child.wait_timeout(timeout).map_err(|e| Error::Io {
1743        message: "failed to wait for claude process".to_string(),
1744        source: e,
1745        working_dir: working_dir.map(|p| p.to_path_buf()),
1746    })? {
1747        Some(status) => {
1748            group.disarm();
1749            let stdout = stdout_thread.join().unwrap_or_default();
1750            let stderr = stderr_thread.join().unwrap_or_default();
1751            let exit_code = status.code().unwrap_or(-1);
1752
1753            if !status.success() {
1754                return Err(Error::from_command_failure(
1755                    format!("{} {}", binary.display(), args.join(" ")),
1756                    exit_code,
1757                    stdout,
1758                    stderr,
1759                    working_dir.map(|p| p.to_path_buf()),
1760                ));
1761            }
1762
1763            Ok(CommandOutput {
1764                stdout,
1765                stderr,
1766                exit_code,
1767                success: true,
1768            })
1769        }
1770        None => {
1771            // Timeout: take down the whole group, honoring the
1772            // optional SIGTERM grace, then kill+reap the direct
1773            // child. The group kill takes down subprocesses that
1774            // could otherwise hold our pipe fds open and block the
1775            // drain threads; the capped join below stays as a
1776            // backstop.
1777            kill_group_with_grace_sync(&mut group, kill_grace);
1778            let _ = child.kill();
1779            let _ = child.wait();
1780
1781            let (stdout_str, stderr_str) =
1782                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1783
1784            if !stdout_str.is_empty() || !stderr_str.is_empty() {
1785                warn!(
1786                    stdout = %stdout_str,
1787                    stderr = %stderr_str,
1788                    "partial output from timed-out process",
1789                );
1790            }
1791
1792            Err(Error::Timeout {
1793                timeout_seconds: timeout.as_secs(),
1794            })
1795        }
1796    }
1797}
1798
1799#[cfg(feature = "sync")]
1800fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
1801    let mut buf = Vec::new();
1802    let _ = reader.read_to_end(&mut buf);
1803    String::from_utf8_lossy(&buf).into_owned()
1804}
1805
1806/// Blocking mirror of [`spawn_retrying_txtbsy`]. See that function for why
1807/// `ETXTBSY` is retried rather than surfaced.
1808#[cfg(feature = "sync")]
1809fn spawn_retrying_txtbsy_sync(
1810    cmd: &mut std::process::Command,
1811) -> std::io::Result<std::process::Child> {
1812    let start = std::time::Instant::now();
1813    let mut backoff = Duration::from_millis(1);
1814    loop {
1815        match cmd.spawn() {
1816            Err(e)
1817                if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1818                    && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1819            {
1820                std::thread::sleep(backoff);
1821                backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1822            }
1823            other => return other,
1824        }
1825    }
1826}
1827
1828/// Run `cmd` to completion, retrying on `ETXTBSY` like
1829/// [`spawn_retrying_txtbsy_sync`].
1830///
1831/// The blocking no-timeout capture path calls `Command::output` (spawn,
1832/// wait, and collect in one step) rather than holding a `Child`, so it
1833/// needs the same retry wrapped around `output` itself. The `ETXTBSY`
1834/// still occurs at the `execve` inside `output`.
1835#[cfg(feature = "sync")]
1836/// Run to completion, reporting the child to `on_spawn` first.
1837///
1838/// `Command::output` is `spawn` followed by `wait_with_output`, so splitting
1839/// the two is behaviour-identical and makes the pid observable on a path that
1840/// otherwise never exposes it.
1841#[cfg(feature = "sync")]
1842fn output_retrying_txtbsy_sync_observed(
1843    cmd: &mut std::process::Command,
1844    process_group: bool,
1845    on_spawn: Option<&crate::SpawnObserver>,
1846) -> std::io::Result<std::process::Output> {
1847    // `Command::output` pipes stdout and stderr implicitly; `spawn` does not,
1848    // so setting them here keeps this split behaviour-identical rather than
1849    // silently returning empty captures.
1850    cmd.stdout(std::process::Stdio::piped());
1851    cmd.stderr(std::process::Stdio::piped());
1852
1853    let start = std::time::Instant::now();
1854    let mut backoff = Duration::from_millis(1);
1855    loop {
1856        let spawned = cmd.spawn().inspect(|child| {
1857            if let Some(observer) = on_spawn {
1858                let pid = child.id();
1859                observer(crate::SpawnInfo {
1860                    pid,
1861                    pgid: process_group.then_some(pid),
1862                });
1863            }
1864        });
1865        match spawned.and_then(std::process::Child::wait_with_output) {
1866            Err(e)
1867                if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1868                    && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1869            {
1870                std::thread::sleep(backoff);
1871                backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1872            }
1873            other => return other,
1874        }
1875    }
1876}
1877
1878/// Wait for both drain threads to finish, returning "" for any that
1879/// miss the deadline. Threads aren't cancellable in std; if the child's
1880/// subprocesses are still holding a pipe fd open after kill(), the
1881/// drain thread leaks. That's a pathological case; the common timeout
1882/// path with a responsive child joins in microseconds.
1883#[cfg(feature = "sync")]
1884fn join_with_deadline(
1885    stdout_thread: std::thread::JoinHandle<String>,
1886    stderr_thread: std::thread::JoinHandle<String>,
1887    budget: Duration,
1888) -> (String, String) {
1889    use std::sync::mpsc;
1890    use std::thread;
1891
1892    let (tx, rx) = mpsc::channel::<(&'static str, String)>();
1893
1894    let tx_out = tx.clone();
1895    let tx_err = tx;
1896
1897    thread::spawn(move || {
1898        let s = stdout_thread.join().unwrap_or_default();
1899        let _ = tx_out.send(("stdout", s));
1900    });
1901    thread::spawn(move || {
1902        let s = stderr_thread.join().unwrap_or_default();
1903        let _ = tx_err.send(("stderr", s));
1904    });
1905
1906    let mut stdout = String::new();
1907    let mut stderr = String::new();
1908    let deadline = std::time::Instant::now() + budget;
1909
1910    for _ in 0..2 {
1911        let now = std::time::Instant::now();
1912        if now >= deadline {
1913            break;
1914        }
1915        match rx.recv_timeout(deadline - now) {
1916            Ok(("stdout", s)) => stdout = s,
1917            Ok(("stderr", s)) => stderr = s,
1918            Ok(_) => unreachable!(),
1919            Err(_) => break,
1920        }
1921    }
1922
1923    (stdout, stderr)
1924}
1925
1926// Fake-binary-driven tests for the spawn/execute paths. Unix-only: they
1927// write and run a small bash `claude` stand-in, which cannot execute on
1928// Windows. CI runs `cargo test --lib` on Windows too, so the module is
1929// gated on `unix` to compile out there; ubuntu/macOS (and `llvm-cov`)
1930// exercise it. `tempfile` is a dev-dependency, so it is always available
1931// under `#[cfg(test)]` regardless of the crate feature.
1932#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1933mod tests {
1934    use super::*;
1935    use std::io::Write;
1936    use std::os::unix::fs::PermissionsExt;
1937
1938    use crate::Claude;
1939
1940    /// Write `body` as an executable bash `claude` stand-in in a fresh
1941    /// tempdir. Returns the dir (keep it bound so it outlives the run)
1942    /// and the script path.
1943    fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1944        let dir = tempfile::tempdir().expect("tempdir");
1945        let path = dir.path().join("fake-claude.sh");
1946        // Close the writable handle before returning so the window in which a
1947        // concurrent test's fork could inherit a writable fd to this script
1948        // (and make our later execve fail with ETXTBSY) is as short as
1949        // possible. Spawn itself retries ETXTBSY; this just makes it rarer.
1950        {
1951            let mut f = std::fs::File::create(&path).expect("create script");
1952            write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1953            f.sync_all().expect("sync script");
1954        }
1955        let perms = std::fs::Permissions::from_mode(0o755);
1956        std::fs::set_permissions(&path, perms).expect("chmod");
1957        (dir, path)
1958    }
1959
1960    fn client(path: &std::path::Path) -> Claude {
1961        Claude::builder()
1962            .binary(path)
1963            .build()
1964            .expect("build client")
1965    }
1966
1967    #[test]
1968    fn full_command_args_puts_global_args_first() {
1969        let claude = Claude::builder()
1970            .binary("/usr/local/bin/claude")
1971            .arg("--debug")
1972            .arg("--verbose")
1973            .build()
1974            .expect("build client");
1975        let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
1976        assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
1977    }
1978
1979    #[test]
1980    fn full_command_args_without_global_args_is_passthrough() {
1981        let claude = Claude::builder()
1982            .binary("/usr/local/bin/claude")
1983            .build()
1984            .expect("build client");
1985        let args = full_command_args(&claude, vec!["--print".to_string()]);
1986        assert_eq!(args, ["--print"]);
1987    }
1988
1989    // Serializes the env-scrub tests, which mutate process-global env.
1990    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1991
1992    fn set_scrub_vars() {
1993        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1994        // SAFETY: the synchronous mutation is serialized by ENV_LOCK and
1995        // not held across any await; no other test reads these vars.
1996        unsafe {
1997            std::env::set_var("CLAUDECODE", "1");
1998            std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1999        }
2000    }
2001
2002    fn clear_scrub_vars() {
2003        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2004        // SAFETY: see set_scrub_vars.
2005        unsafe {
2006            std::env::remove_var("CLAUDECODE");
2007            std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
2008        }
2009    }
2010
2011    // ---------- async ----------
2012
2013    #[cfg(feature = "async")]
2014    #[tokio::test]
2015    async fn async_success_maps_output() {
2016        let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
2017        let out = run_claude(&client(&path), vec!["--version".into()])
2018            .await
2019            .expect("success");
2020        assert!(out.success);
2021        assert_eq!(out.exit_code, 0);
2022        assert!(out.stdout.contains("hi there"));
2023    }
2024
2025    #[cfg(feature = "async")]
2026    #[tokio::test]
2027    async fn async_nonzero_exit_maps_command_failed() {
2028        let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2029        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2030        match err {
2031            Error::CommandFailed {
2032                exit_code, stderr, ..
2033            } => {
2034                assert_eq!(exit_code, 3);
2035                assert!(stderr.contains("boom"));
2036            }
2037            other => panic!("expected CommandFailed, got {other:?}"),
2038        }
2039    }
2040
2041    #[cfg(feature = "async")]
2042    #[tokio::test]
2043    async fn async_rail_stop_maps_max_turns() {
2044        let (_dir, path) = fake_script(
2045            r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
2046        );
2047        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2048        assert!(
2049            matches!(
2050                err,
2051                Error::MaxTurnsExceeded {
2052                    max_turns: Some(2),
2053                    ..
2054                }
2055            ),
2056            "got: {err:?}"
2057        );
2058    }
2059
2060    #[cfg(feature = "async")]
2061    #[tokio::test]
2062    async fn async_auth_shaped_stderr_maps_auth() {
2063        let (_dir, path) =
2064            fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
2065        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
2066        assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
2067    }
2068
2069    #[cfg(feature = "async")]
2070    #[tokio::test]
2071    async fn async_scrubs_claude_env_vars() {
2072        let (_dir, path) =
2073            fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2074        // The child sees the vars scrubbed regardless; setting them in the
2075        // parent is what makes the assertion meaningful rather than
2076        // trivially empty. Correctness does not depend on the lock (the
2077        // scrub removes them either way), so it only wraps the synchronous
2078        // env mutations -- never held across the await, per clippy.
2079        set_scrub_vars();
2080        let out = run_claude(&client(&path), vec![]).await.expect("success");
2081        clear_scrub_vars();
2082        assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2083        assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2084    }
2085
2086    #[cfg(feature = "async")]
2087    #[tokio::test]
2088    async fn async_applies_working_dir() {
2089        let (_dir, path) = fake_script(r#"pwd"#);
2090        let workdir = tempfile::tempdir().expect("workdir");
2091        let claude = Claude::builder()
2092            .binary(&path)
2093            .working_dir(workdir.path())
2094            .build()
2095            .expect("build");
2096        let out = run_claude(&claude, vec![]).await.expect("success");
2097        let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
2098        let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
2099        assert_eq!(got, want);
2100    }
2101
2102    #[cfg(feature = "async")]
2103    #[tokio::test]
2104    async fn async_stdin_prompt_round_trips() {
2105        let (_dir, path) = fake_script(r#"cat"#);
2106        let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
2107            .await
2108            .expect("success");
2109        assert!(out.stdout.contains("hello via stdin"));
2110    }
2111
2112    // The retry loop in `spawn_retrying_txtbsy` must only absorb `ETXTBSY`;
2113    // every other spawn error has to surface promptly rather than be retried
2114    // until the budget elapses. A missing binary yields `NotFound`, which
2115    // must return on the first attempt.
2116    #[cfg(feature = "async")]
2117    #[tokio::test]
2118    async fn async_spawn_retry_passes_through_non_txtbsy_error() {
2119        let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
2120        let err = spawn_retrying_txtbsy(&mut cmd)
2121            .await
2122            .expect_err("spawn of missing binary should fail");
2123        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2124    }
2125
2126    #[cfg(feature = "async")]
2127    #[tokio::test]
2128    async fn async_allow_exit_codes_permits_listed_code() {
2129        let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2130        let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
2131            .await
2132            .expect("allowed code is Ok");
2133        assert!(!out.success);
2134        assert_eq!(out.exit_code, 2);
2135        assert!(out.stdout.contains("out"));
2136    }
2137
2138    #[cfg(feature = "async")]
2139    #[tokio::test]
2140    async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
2141        let (_dir, path) = fake_script(r#"exit 2"#);
2142        let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
2143            .await
2144            .unwrap_err();
2145        assert!(
2146            matches!(err, Error::CommandFailed { exit_code: 2, .. }),
2147            "got: {err:?}"
2148        );
2149    }
2150
2151    #[cfg(feature = "async")]
2152    #[tokio::test]
2153    async fn async_timeout_fires_on_slow_child() {
2154        let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2155        let claude = Claude::builder()
2156            .binary(&path)
2157            .timeout(Duration::from_millis(300))
2158            .build()
2159            .expect("build");
2160        let err = run_claude(&claude, vec![]).await.unwrap_err();
2161        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2162    }
2163
2164    #[cfg(feature = "async")]
2165    #[tokio::test]
2166    async fn async_timeout_path_returns_output_when_fast() {
2167        let (_dir, path) = fake_script(r#"echo quick"#);
2168        let claude = Claude::builder()
2169            .binary(&path)
2170            .timeout(Duration::from_secs(30))
2171            .build()
2172            .expect("build");
2173        let out = run_claude(&claude, vec![]).await.expect("success");
2174        assert!(out.stdout.contains("quick"));
2175    }
2176
2177    #[cfg(feature = "async")]
2178    #[tokio::test]
2179    async fn async_timeout_path_maps_command_failed() {
2180        let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
2181        let claude = Claude::builder()
2182            .binary(&path)
2183            .timeout(Duration::from_secs(30))
2184            .build()
2185            .expect("build");
2186        let err = run_claude(&claude, vec![]).await.unwrap_err();
2187        assert!(
2188            matches!(err, Error::CommandFailed { exit_code: 4, .. }),
2189            "got: {err:?}"
2190        );
2191    }
2192
2193    #[cfg(feature = "async")]
2194    #[tokio::test]
2195    async fn async_stdin_with_timeout_round_trips() {
2196        let (_dir, path) = fake_script(r#"cat"#);
2197        let claude = Claude::builder()
2198            .binary(&path)
2199            .timeout(Duration::from_secs(30))
2200            .build()
2201            .expect("build");
2202        let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
2203            .await
2204            .expect("success");
2205        assert!(out.stdout.contains("piped under timeout"));
2206    }
2207
2208    #[cfg(feature = "async")]
2209    #[tokio::test]
2210    async fn async_stdin_timeout_fires_on_slow_child() {
2211        let (_dir, path) = fake_script(r#"sleep 3"#);
2212        let claude = Claude::builder()
2213            .binary(&path)
2214            .timeout(Duration::from_millis(300))
2215            .build()
2216            .expect("build");
2217        let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
2218            .await
2219            .unwrap_err();
2220        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2221    }
2222
2223    /// Drive `fut` just long enough for the fake script to write its pid
2224    /// file, then drop it mid-flight (on return) and hand back the pid.
2225    #[cfg(feature = "async")]
2226    async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
2227    where
2228        F: std::future::Future,
2229        F::Output: std::fmt::Debug,
2230    {
2231        tokio::pin!(fut);
2232        let deadline = std::time::Instant::now() + Duration::from_secs(10);
2233        loop {
2234            if let Some(pid) = std::fs::read_to_string(pid_path)
2235                .ok()
2236                .and_then(|s| s.trim().parse().ok())
2237            {
2238                // Returning drops the pinned future here, mid-flight.
2239                return pid;
2240            }
2241            assert!(
2242                std::time::Instant::now() < deadline,
2243                "child never wrote its pid file"
2244            );
2245            tokio::select! {
2246                out = &mut fut => panic!("future completed before drop: {out:?}"),
2247                _ = tokio::time::sleep(Duration::from_millis(10)) => {}
2248            }
2249        }
2250    }
2251
2252    /// Poll until `pid` is dead or a zombie awaiting reap. The kill is
2253    /// delivered synchronously (killpg / kill_on_drop's start_kill), but
2254    /// reaping happens asynchronously, so a transient zombie counts as
2255    /// killed. Blocking on purpose: it runs after the kill has been
2256    /// issued, so nothing async needs to make progress.
2257    fn assert_pid_killed(pid: u32) {
2258        let deadline = std::time::Instant::now() + Duration::from_secs(10);
2259        loop {
2260            let out = std::process::Command::new("ps")
2261                .args(["-o", "stat=", "-p", &pid.to_string()])
2262                .output()
2263                .expect("run ps");
2264            let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2265            if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
2266                return;
2267            }
2268            assert!(
2269                std::time::Instant::now() < deadline,
2270                "process {pid} still alive (stat {stat}) after kill"
2271            );
2272            std::thread::sleep(Duration::from_millis(25));
2273        }
2274    }
2275
2276    #[cfg(feature = "async")]
2277    fn assert_pid_not_live_now(pid: u32) {
2278        let out = std::process::Command::new("ps")
2279            .args(["-o", "stat=", "-p", &pid.to_string()])
2280            .output()
2281            .expect("run ps");
2282        let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2283        assert!(
2284            !out.status.success() || stat.is_empty() || stat.starts_with('Z'),
2285            "process {pid} still live after terminal settlement (stat {stat})"
2286        );
2287    }
2288
2289    /// Fake script that records its own pid, spawns a same-group
2290    /// grandchild that records its pid too, then sleeps far longer than
2291    /// any test deadline. The main shell waits for the grandchild pid
2292    /// to land before writing its own, so tests that poll for the pid
2293    /// file can rely on the grandchild pid being readable as well.
2294    /// Non-interactive bash does not create new process groups for
2295    /// background jobs, so a group kill must take down both.
2296    fn group_script(
2297        pid_path: &std::path::Path,
2298        gpid_path: &std::path::Path,
2299    ) -> (tempfile::TempDir, std::path::PathBuf) {
2300        // `bash -c` rather than a subshell because `$$` inside a
2301        // subshell still names the parent, and `$BASHPID` needs bash 4
2302        // (macOS ships 3.2). The path travels as `$0` so it needs no
2303        // extra quoting.
2304        fake_script(&format!(
2305            concat!(
2306                "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2307                "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2308                "echo $$ > \"{p}\"\n",
2309                "exec sleep 300",
2310            ),
2311            g = gpid_path.display(),
2312            p = pid_path.display(),
2313        ))
2314    }
2315
2316    /// Read a pid recorded by `group_script`, if fully written yet.
2317    fn try_read_pid(path: &std::path::Path) -> Option<u32> {
2318        std::fs::read_to_string(path).ok()?.trim().parse().ok()
2319    }
2320
2321    /// Read a pid recorded by `group_script`. Only the async tests use
2322    /// this unconditional variant; the sync timeout test reads through
2323    /// `try_read_pid`, so gate it to keep sync-only builds warning-free.
2324    #[cfg(feature = "async")]
2325    fn read_pid(path: &std::path::Path) -> u32 {
2326        try_read_pid(path).expect("pid file readable")
2327    }
2328
2329    // Dropping an in-flight execute future must kill the spawned child:
2330    // every async spawn site sets kill_on_drop(true), so a caller racing
2331    // execute against cancellation (tokio::select!, timeout) cannot leak
2332    // a headless CLI run. `exec` keeps the recorded pid the direct child,
2333    // so the SIGKILL lands on the process the test watches.
2334    #[cfg(feature = "async")]
2335    #[tokio::test]
2336    async fn async_dropping_in_flight_future_kills_child() {
2337        let workdir = tempfile::tempdir().expect("workdir");
2338        let pid_path = workdir.path().join("pid");
2339        let (_dir, path) = fake_script(&format!(
2340            r#"echo $$ > "{}"; exec sleep 30"#,
2341            pid_path.display()
2342        ));
2343        let claude = client(&path);
2344        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2345        assert_pid_killed(pid);
2346    }
2347
2348    // Same guarantee on the timeout path, which holds a Child from
2349    // spawn_retrying_txtbsy rather than going through Command::output.
2350    // The configured timeout is far longer than the test; the drop is
2351    // what kills the child.
2352    #[cfg(feature = "async")]
2353    #[tokio::test]
2354    async fn async_dropping_in_flight_future_kills_child_with_timeout() {
2355        let workdir = tempfile::tempdir().expect("workdir");
2356        let pid_path = workdir.path().join("pid");
2357        let (_dir, path) = fake_script(&format!(
2358            r#"echo $$ > "{}"; exec sleep 30"#,
2359            pid_path.display()
2360        ));
2361        let claude = Claude::builder()
2362            .binary(&path)
2363            .timeout(Duration::from_secs(120))
2364            .build()
2365            .expect("build");
2366        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2367        assert_pid_killed(pid);
2368    }
2369
2370    // Dropping the future must kill the child's whole process group,
2371    // not just the direct child: the CLI spawns subprocesses for tool
2372    // use, and a cancelled run must leave none of them behind.
2373    #[cfg(feature = "async")]
2374    #[tokio::test]
2375    async fn async_dropping_in_flight_future_kills_process_group() {
2376        let workdir = tempfile::tempdir().expect("workdir");
2377        let pid_path = workdir.path().join("pid");
2378        let gpid_path = workdir.path().join("gpid");
2379        let (_dir, path) = group_script(&pid_path, &gpid_path);
2380        let claude = client(&path);
2381        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2382        assert_pid_killed(pid);
2383        assert_pid_killed(read_pid(&gpid_path));
2384    }
2385
2386    // A fired timeout must also kill the whole group. Before the group
2387    // kill, the timeout path SIGKILLed only the direct child and the
2388    // grandchild survived. Retries on a heavily loaded host, where the
2389    // child can get killed before it records its pids: the kill still
2390    // happened, but there is nothing to observe, so run it again.
2391    #[cfg(feature = "async")]
2392    #[tokio::test]
2393    async fn async_timeout_kills_process_group() {
2394        let mut observed = false;
2395        for _ in 0..5 {
2396            let workdir = tempfile::tempdir().expect("workdir");
2397            let pid_path = workdir.path().join("pid");
2398            let gpid_path = workdir.path().join("gpid");
2399            let (_dir, path) = group_script(&pid_path, &gpid_path);
2400            let claude = Claude::builder()
2401                .binary(&path)
2402                .timeout(Duration::from_millis(1000))
2403                .build()
2404                .expect("build");
2405            let err = run_claude(&claude, vec![]).await.unwrap_err();
2406            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2407            if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2408                assert_pid_killed(pid);
2409                assert_pid_killed(gpid);
2410                observed = true;
2411                break;
2412            }
2413        }
2414        assert!(observed, "child never recorded pids within 5 timeout runs");
2415    }
2416
2417    #[cfg(feature = "async")]
2418    #[tokio::test]
2419    async fn async_explicit_cancellation_settles_process_group() {
2420        let workdir = tempfile::tempdir().expect("workdir");
2421        let pid_path = workdir.path().join("pid");
2422        let gpid_path = workdir.path().join("gpid");
2423        let (_dir, path) = group_script(&pid_path, &gpid_path);
2424        let claude = Claude::builder()
2425            .binary(&path)
2426            .kill_grace(Duration::from_millis(10))
2427            .build()
2428            .expect("build");
2429        let cancel = async {
2430            while !pid_path.exists() || !gpid_path.exists() {
2431                tokio::time::sleep(Duration::from_millis(10)).await;
2432            }
2433        };
2434
2435        let error = run_claude_cancellable(&claude, vec![], cancel)
2436            .await
2437            .expect_err("run must be cancelled");
2438        assert!(matches!(error, Error::Cancelled));
2439        assert_pid_not_live_now(read_pid(&pid_path));
2440        assert_pid_not_live_now(read_pid(&gpid_path));
2441    }
2442
2443    #[cfg(feature = "async")]
2444    #[tokio::test]
2445    async fn async_stdin_failure_settles_process_group() {
2446        let workdir = tempfile::tempdir().expect("workdir");
2447        let pid_path = workdir.path().join("pid");
2448        let gpid_path = workdir.path().join("gpid");
2449        let (_dir, path) = fake_script(&format!(
2450            concat!(
2451                "sleep 300 </dev/null &\n",
2452                "child=$!\n",
2453                "echo $$ > '{p}'\n",
2454                "echo $child > '{g}'\n",
2455                "exec 0<&-\n",
2456                "wait $child",
2457            ),
2458            p = pid_path.display(),
2459            g = gpid_path.display(),
2460        ));
2461        let claude = Claude::builder()
2462            .binary(&path)
2463            .kill_grace(Duration::from_millis(10))
2464            .build()
2465            .expect("build");
2466
2467        let error = run_claude_with_stdin_prompt(&claude, vec![], "x".repeat(4 * 1024 * 1024))
2468            .await
2469            .expect_err("stdin write must fail");
2470        assert!(matches!(error, Error::Io { .. }));
2471        assert_pid_not_live_now(read_pid(&pid_path));
2472        assert_pid_not_live_now(read_pid(&gpid_path));
2473    }
2474
2475    // With the process-group split opted out, dropping the future still
2476    // kills the direct child via kill_on_drop, but the grandchild is
2477    // deliberately left running: that is the pre-group contract #767
2478    // preserves for terminal-attached hosts, where the terminal is the
2479    // supervisor. The test reaps the survivor itself.
2480    #[cfg(feature = "async")]
2481    #[tokio::test]
2482    async fn async_process_group_opt_out_kills_only_direct_child() {
2483        let workdir = tempfile::tempdir().expect("workdir");
2484        let pid_path = workdir.path().join("pid");
2485        let gpid_path = workdir.path().join("gpid");
2486        let (_dir, path) = group_script(&pid_path, &gpid_path);
2487        let claude = Claude::builder()
2488            .binary(&path)
2489            .process_group(false)
2490            .build()
2491            .expect("build");
2492        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2493        assert_pid_killed(pid);
2494
2495        // The grandchild must still be alive: no group kill happened.
2496        let gpid = read_pid(&gpid_path);
2497        let out = std::process::Command::new("ps")
2498            .args(["-o", "stat=", "-p", &gpid.to_string()])
2499            .output()
2500            .expect("run ps");
2501        let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2502        assert!(
2503            out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
2504            "grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
2505        );
2506
2507        // Reap the deliberate survivor so it does not idle for 300s.
2508        let _ = std::process::Command::new("kill")
2509            .args(["-9", &gpid.to_string()])
2510            .status();
2511    }
2512
2513    /// Fake script that traps SIGTERM, records a marker, and exits
2514    /// cleanly. The marker can only exist if TERM arrived before the
2515    /// KILL: SIGKILL cannot be trapped. `sleep` runs as a background
2516    /// job with `wait` so bash stays alive to handle the signal
2517    /// (an `exec sleep` would replace bash and drop the trap).
2518    fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
2519        fake_script(&format!(
2520            concat!(
2521                "trap 'echo term > \"{m}\"; exit 0' TERM\n",
2522                "sleep 300 &\n",
2523                "wait $!",
2524            ),
2525            m = marker.display(),
2526        ))
2527    }
2528
2529    // With a kill grace configured, a fired timeout SIGTERMs the group
2530    // before the SIGKILL, giving the child a chance to flush. Retries
2531    // on a heavily loaded host where the child is killed before it
2532    // installs its trap: the kill still happened, but there is nothing
2533    // to observe, so run it again.
2534    #[cfg(feature = "async")]
2535    #[tokio::test]
2536    async fn async_timeout_with_grace_delivers_sigterm_first() {
2537        let mut observed = false;
2538        for _ in 0..5 {
2539            let workdir = tempfile::tempdir().expect("workdir");
2540            let marker = workdir.path().join("term-marker");
2541            let (_dir, path) = term_trap_script(&marker);
2542            let claude = Claude::builder()
2543                .binary(&path)
2544                .timeout(Duration::from_millis(500))
2545                .kill_grace(Duration::from_secs(1))
2546                .build()
2547                .expect("build");
2548            let err = run_claude(&claude, vec![]).await.unwrap_err();
2549            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2550            if marker.exists() {
2551                observed = true;
2552                break;
2553            }
2554        }
2555        assert!(observed, "TERM marker never appeared within 5 timeout runs");
2556    }
2557
2558    // Blocking mirror of async_timeout_with_grace_delivers_sigterm_first.
2559    #[cfg(feature = "sync")]
2560    #[test]
2561    fn sync_timeout_with_grace_delivers_sigterm_first() {
2562        let mut observed = false;
2563        for _ in 0..5 {
2564            let workdir = tempfile::tempdir().expect("workdir");
2565            let marker = workdir.path().join("term-marker");
2566            let (_dir, path) = term_trap_script(&marker);
2567            let claude = Claude::builder()
2568                .binary(&path)
2569                .timeout(Duration::from_millis(500))
2570                .kill_grace(Duration::from_secs(1))
2571                .build()
2572                .expect("build");
2573            let err = run_claude_sync(&claude, vec![]).unwrap_err();
2574            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2575            if marker.exists() {
2576                observed = true;
2577                break;
2578            }
2579        }
2580        assert!(observed, "TERM marker never appeared within 5 timeout runs");
2581    }
2582
2583    // Same guarantee for the stdin-prompt path.
2584    #[cfg(feature = "async")]
2585    #[tokio::test]
2586    async fn async_dropping_in_flight_stdin_future_kills_child() {
2587        let workdir = tempfile::tempdir().expect("workdir");
2588        let pid_path = workdir.path().join("pid");
2589        let (_dir, path) = fake_script(&format!(
2590            r#"echo $$ > "{}"; exec sleep 30"#,
2591            pid_path.display()
2592        ));
2593        let claude = client(&path);
2594        let pid = drop_in_flight_and_capture_pid(
2595            run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
2596            &pid_path,
2597        )
2598        .await;
2599        assert_pid_killed(pid);
2600    }
2601
2602    #[cfg(feature = "async")]
2603    #[tokio::test]
2604    async fn async_spawn_failure_maps_io() {
2605        let claude = Claude::builder()
2606            .binary("/nonexistent/definitely/not/here")
2607            .build()
2608            .expect("build");
2609        let err = run_claude(&claude, vec![]).await.unwrap_err();
2610        assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
2611    }
2612
2613    // ---------- sync ----------
2614
2615    #[cfg(feature = "sync")]
2616    #[test]
2617    fn sync_success_maps_output() {
2618        let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
2619        let out = run_claude_sync(&client(&path), vec![]).expect("success");
2620        assert!(out.success);
2621        assert!(out.stdout.contains("hi sync"));
2622    }
2623
2624    #[cfg(feature = "sync")]
2625    #[test]
2626    fn sync_nonzero_exit_maps_command_failed() {
2627        let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2628        let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
2629        match err {
2630            Error::CommandFailed {
2631                exit_code, stderr, ..
2632            } => {
2633                assert_eq!(exit_code, 3);
2634                assert!(stderr.contains("boom"));
2635            }
2636            other => panic!("expected CommandFailed, got {other:?}"),
2637        }
2638    }
2639
2640    #[cfg(feature = "sync")]
2641    #[test]
2642    fn sync_scrubs_claude_env_vars() {
2643        let (_dir, path) =
2644            fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2645        set_scrub_vars();
2646        let out = run_claude_sync(&client(&path), vec![]).expect("success");
2647        clear_scrub_vars();
2648        assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2649        assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2650    }
2651
2652    #[cfg(feature = "sync")]
2653    #[test]
2654    fn sync_stdin_prompt_round_trips() {
2655        let (_dir, path) = fake_script(r#"cat"#);
2656        let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
2657            .expect("success");
2658        assert!(out.stdout.contains("sync stdin"));
2659    }
2660
2661    // Sync mirror: only `ETXTBSY` is retried; a missing binary must surface
2662    // `NotFound` on the first attempt.
2663    #[cfg(feature = "sync")]
2664    #[test]
2665    fn sync_spawn_retry_passes_through_non_txtbsy_error() {
2666        let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2667        let err =
2668            spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
2669        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2670    }
2671
2672    #[cfg(feature = "sync")]
2673    #[test]
2674    fn sync_output_retry_passes_through_non_txtbsy_error() {
2675        let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2676        let err = output_retrying_txtbsy_sync_observed(&mut cmd, false, None)
2677            .expect_err("output of missing binary should fail");
2678        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2679    }
2680
2681    #[cfg(feature = "sync")]
2682    #[test]
2683    fn sync_allow_exit_codes_permits_listed_code() {
2684        let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2685        let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
2686            .expect("allowed code is Ok");
2687        assert!(!out.success);
2688        assert_eq!(out.exit_code, 2);
2689    }
2690
2691    #[cfg(feature = "sync")]
2692    #[test]
2693    fn sync_timeout_fires_on_slow_child() {
2694        let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2695        let claude = Claude::builder()
2696            .binary(&path)
2697            .timeout(Duration::from_millis(300))
2698            .build()
2699            .expect("build");
2700        let err = run_claude_sync(&claude, vec![]).unwrap_err();
2701        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2702    }
2703
2704    // Blocking mirror of async_timeout_kills_process_group: a fired
2705    // timeout on the sync path must kill the whole group too. Same
2706    // retry rationale as the async variant.
2707    #[cfg(feature = "sync")]
2708    #[test]
2709    fn sync_timeout_kills_process_group() {
2710        let mut observed = false;
2711        for _ in 0..5 {
2712            let workdir = tempfile::tempdir().expect("workdir");
2713            let pid_path = workdir.path().join("pid");
2714            let gpid_path = workdir.path().join("gpid");
2715            let (_dir, path) = group_script(&pid_path, &gpid_path);
2716            let claude = Claude::builder()
2717                .binary(&path)
2718                .timeout(Duration::from_millis(1000))
2719                .build()
2720                .expect("build");
2721            let err = run_claude_sync(&claude, vec![]).unwrap_err();
2722            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2723            if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2724                assert_pid_killed(pid);
2725                assert_pid_killed(gpid);
2726                observed = true;
2727                break;
2728            }
2729        }
2730        assert!(observed, "child never recorded pids within 5 timeout runs");
2731    }
2732
2733    #[cfg(feature = "sync")]
2734    #[test]
2735    fn sync_timeout_path_returns_output_when_fast() {
2736        let (_dir, path) = fake_script(r#"echo quick"#);
2737        let claude = Claude::builder()
2738            .binary(&path)
2739            .timeout(Duration::from_secs(30))
2740            .build()
2741            .expect("build");
2742        let out = run_claude_sync(&claude, vec![]).expect("success");
2743        assert!(out.stdout.contains("quick"));
2744    }
2745
2746    #[cfg(feature = "sync")]
2747    #[test]
2748    fn sync_stdin_with_timeout_round_trips() {
2749        let (_dir, path) = fake_script(r#"cat"#);
2750        let claude = Claude::builder()
2751            .binary(&path)
2752            .timeout(Duration::from_secs(30))
2753            .build()
2754            .expect("build");
2755        let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
2756            .expect("success");
2757        assert!(out.stdout.contains("sync piped"));
2758    }
2759}