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/// Blocking mirror of [`kill_group_with_grace`].
420#[cfg(feature = "sync")]
421pub(crate) fn kill_group_with_grace_sync(group: &mut GroupKillGuard, grace: Option<Duration>) {
422    if let Some(g) = grace
423        && !g.is_zero()
424        && group.is_armed()
425    {
426        group.term_now();
427        std::thread::sleep(g);
428    }
429    group.kill_now();
430}
431
432/// Run a claude command with the given arguments.
433///
434/// If the [`Claude`] client has a retry policy set, transient errors will be
435/// retried according to that policy. A per-command retry policy can be passed
436/// to override the client default.
437///
438/// Dropping the returned future mid-flight kills the spawned `claude`
439/// process and, on Unix, its whole process group (SIGKILL): an
440/// abandoned run does not keep executing in the background, and the
441/// subprocesses it spawned for tool use die with it.
442#[cfg(feature = "async")]
443pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
444    run_claude_with_retry(claude, args, None).await
445}
446
447/// Run a claude command with an optional per-command retry policy override.
448///
449/// Dropping the returned future kills the child; see [`run_claude`].
450#[cfg(feature = "async")]
451pub async fn run_claude_with_retry(
452    claude: &Claude,
453    args: Vec<String>,
454    retry_override: Option<&crate::retry::RetryPolicy>,
455) -> Result<CommandOutput> {
456    let policy = retry_override.or(claude.retry_policy.as_ref());
457
458    match policy {
459        Some(policy) => {
460            crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
461        }
462        None => run_claude_once(claude, args).await,
463    }
464}
465
466/// Run claude, writing `stdin_content` to the child's stdin rather than
467/// passing the prompt as argv.
468///
469/// stdin mode does not retry -- the stdin pipe is consumed after the first
470/// attempt and cannot be rewound for a subsequent try.
471///
472/// Dropping the returned future kills the child; see [`run_claude`].
473#[cfg(feature = "async")]
474pub async fn run_claude_with_stdin_prompt(
475    claude: &Claude,
476    args: Vec<String>,
477    stdin_content: String,
478) -> Result<CommandOutput> {
479    run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
480}
481
482#[cfg(feature = "async")]
483async fn run_claude_with_stdin_prompt_internal(
484    claude: &Claude,
485    args: Vec<String>,
486    stdin_content: String,
487) -> Result<CommandOutput> {
488    let command_args = full_command_args(claude, args);
489
490    let span = exec_span(claude, &command_args, "stdin");
491    let _enter = span.enter();
492    let started = std::time::Instant::now();
493    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
494
495    let binary = &claude.binary;
496    let env = &claude.env;
497    let clear_env = claude.clear_env;
498    let working_dir = claude.working_dir.as_deref();
499
500    let result = if let Some(timeout) = claude.timeout {
501        run_with_timeout_stdin(
502            binary,
503            &command_args,
504            env,
505            clear_env,
506            working_dir,
507            timeout,
508            stdin_content,
509            SpawnPolicy::of(claude),
510        )
511        .await
512    } else {
513        run_internal_stdin(
514            binary,
515            &command_args,
516            env,
517            clear_env,
518            working_dir,
519            stdin_content,
520            SpawnPolicy::of(claude),
521        )
522        .await
523    };
524
525    if let Ok(output) = &result {
526        record_exec_outcome(&span, output.exit_code, started);
527    }
528    result
529}
530
531#[cfg(feature = "async")]
532async fn run_internal_stdin(
533    binary: &std::path::Path,
534    args: &[String],
535    env: &std::collections::HashMap<String, String>,
536    clear_env: bool,
537    working_dir: Option<&std::path::Path>,
538    stdin_content: String,
539    policy: SpawnPolicy<'_>,
540) -> Result<CommandOutput> {
541    let SpawnPolicy {
542        process_group,
543        kill_grace: _, // no kill site on this path
544        die_with_parent,
545        on_spawn,
546    } = policy;
547    use tokio::io::AsyncWriteExt;
548
549    let mut cmd = Command::new(binary);
550    cmd.args(args);
551    cmd.stdin(std::process::Stdio::piped());
552    cmd.stdout(std::process::Stdio::piped());
553    cmd.stderr(std::process::Stdio::piped());
554    // Dropping the in-flight future must kill the child, not leave the
555    // CLI running unattended (see the module docs).
556    cmd.kill_on_drop(true);
557    // Own process group (Unix) so cancellation can signal the whole
558    // tree, not just the direct child (see GroupKillGuard). Opt out
559    // via ClaudeBuilder::process_group.
560    apply_process_group(&mut cmd, process_group);
561    apply_die_with_parent(&mut cmd, die_with_parent);
562    apply_child_environment(cmd.as_std_mut(), clear_env, env);
563
564    if let Some(dir) = working_dir {
565        cmd.current_dir(dir);
566    }
567
568    let mut child = spawn_retrying_txtbsy(&mut cmd)
569        .await
570        .map_err(|e| Error::Io {
571            message: format!("failed to spawn claude: {e}"),
572            source: e,
573            working_dir: working_dir.map(|p| p.to_path_buf()),
574        })?;
575    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
576
577    // Write the prompt to stdin, then drop the handle so the child sees EOF.
578    if let Some(mut stdin) = child.stdin.take() {
579        stdin
580            .write_all(stdin_content.as_bytes())
581            .await
582            .map_err(|e| Error::Io {
583                message: format!("failed to write to claude stdin: {e}"),
584                source: e,
585                working_dir: working_dir.map(|p| p.to_path_buf()),
586            })?;
587        // Drop stdin so the child sees EOF.
588    }
589
590    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
591    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
592
593    let (status, stdout_str, stderr_str) = tokio::join!(
594        child.wait(),
595        drain(&mut stdout_handle),
596        drain(&mut stderr_handle),
597    );
598
599    let status = status.map_err(|e| Error::Io {
600        message: "failed to wait for claude process".to_string(),
601        source: e,
602        working_dir: working_dir.map(|p| p.to_path_buf()),
603    })?;
604    group.disarm();
605
606    let exit_code = status.code().unwrap_or(-1);
607
608    if !status.success() {
609        return Err(Error::from_command_failure(
610            format!("{} {}", binary.display(), args.join(" ")),
611            exit_code,
612            stdout_str,
613            stderr_str,
614            working_dir.map(|p| p.to_path_buf()),
615        ));
616    }
617
618    Ok(CommandOutput {
619        stdout: stdout_str,
620        stderr: stderr_str,
621        exit_code,
622        success: true,
623    })
624}
625
626#[cfg(feature = "async")]
627#[allow(clippy::too_many_arguments)]
628async fn run_with_timeout_stdin(
629    binary: &std::path::Path,
630    args: &[String],
631    env: &std::collections::HashMap<String, String>,
632    clear_env: bool,
633    working_dir: Option<&std::path::Path>,
634    timeout: Duration,
635    stdin_content: String,
636    policy: SpawnPolicy<'_>,
637) -> Result<CommandOutput> {
638    let SpawnPolicy {
639        process_group,
640        kill_grace,
641        die_with_parent,
642        on_spawn,
643    } = policy;
644    use tokio::io::AsyncWriteExt;
645
646    let mut cmd = Command::new(binary);
647    cmd.args(args);
648    cmd.stdin(std::process::Stdio::piped());
649    cmd.stdout(std::process::Stdio::piped());
650    cmd.stderr(std::process::Stdio::piped());
651    // Dropping the in-flight future must kill the child, not leave the
652    // CLI running unattended (see the module docs).
653    cmd.kill_on_drop(true);
654    // Own process group (Unix) so cancellation can signal the whole
655    // tree, not just the direct child (see GroupKillGuard). Opt out
656    // via ClaudeBuilder::process_group.
657    apply_process_group(&mut cmd, process_group);
658    apply_die_with_parent(&mut cmd, die_with_parent);
659    apply_child_environment(cmd.as_std_mut(), clear_env, env);
660
661    if let Some(dir) = working_dir {
662        cmd.current_dir(dir);
663    }
664
665    let mut child = spawn_retrying_txtbsy(&mut cmd)
666        .await
667        .map_err(|e| Error::Io {
668            message: format!("failed to spawn claude: {e}"),
669            source: e,
670            working_dir: working_dir.map(|p| p.to_path_buf()),
671        })?;
672    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
673
674    // Write the prompt to stdin, then drop the handle so the child sees EOF.
675    if let Some(mut stdin) = child.stdin.take() {
676        stdin
677            .write_all(stdin_content.as_bytes())
678            .await
679            .map_err(|e| Error::Io {
680                message: format!("failed to write to claude stdin: {e}"),
681                source: e,
682                working_dir: working_dir.map(|p| p.to_path_buf()),
683            })?;
684        // Drop stdin so the child sees EOF.
685    }
686
687    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
688    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
689
690    let wait_and_drain = async {
691        let (status, stdout_str, stderr_str) = tokio::join!(
692            child.wait(),
693            drain(&mut stdout_handle),
694            drain(&mut stderr_handle),
695        );
696        (status, stdout_str, stderr_str)
697    };
698
699    match tokio::time::timeout(timeout, wait_and_drain).await {
700        Ok((Ok(status), stdout, stderr)) => {
701            group.disarm();
702            let exit_code = status.code().unwrap_or(-1);
703
704            if !status.success() {
705                return Err(Error::from_command_failure(
706                    format!("{} {}", binary.display(), args.join(" ")),
707                    exit_code,
708                    stdout,
709                    stderr,
710                    working_dir.map(|p| p.to_path_buf()),
711                ));
712            }
713
714            Ok(CommandOutput {
715                stdout,
716                stderr,
717                exit_code,
718                success: true,
719            })
720        }
721        Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
722            message: "failed to wait for claude process".to_string(),
723            source: e,
724            working_dir: working_dir.map(|p| p.to_path_buf()),
725        }),
726        Err(_) => {
727            // Timeout: take down the whole group first (subprocesses
728            // may hold our pipe fds), honoring the optional SIGTERM
729            // grace, then kill+reap the direct child.
730            kill_group_with_grace(&mut group, kill_grace).await;
731            let _ = child.kill().await;
732            let drain_budget = Duration::from_millis(200);
733            let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
734                .await
735                .unwrap_or_default();
736            let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
737                .await
738                .unwrap_or_default();
739            if !stdout_str.is_empty() || !stderr_str.is_empty() {
740                warn!(
741                    stdout = %stdout_str,
742                    stderr = %stderr_str,
743                    "partial output from timed-out process",
744                );
745            }
746            Err(Error::Timeout {
747                timeout_seconds: timeout.as_secs(),
748            })
749        }
750    }
751}
752
753#[cfg(feature = "async")]
754async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
755    let command_args = full_command_args(claude, args);
756
757    let span = exec_span(claude, &command_args, "oneshot");
758    let _enter = span.enter();
759    let started = std::time::Instant::now();
760    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
761
762    let output = if let Some(timeout) = claude.timeout {
763        run_with_timeout(
764            &claude.binary,
765            &command_args,
766            &claude.env,
767            claude.clear_env,
768            claude.working_dir.as_deref(),
769            timeout,
770            SpawnPolicy::of(claude),
771        )
772        .await?
773    } else {
774        run_internal(
775            &claude.binary,
776            &command_args,
777            &claude.env,
778            claude.clear_env,
779            claude.working_dir.as_deref(),
780            SpawnPolicy::of(claude),
781        )
782        .await?
783    };
784
785    record_exec_outcome(&span, output.exit_code, started);
786    Ok(output)
787}
788
789/// Run a claude command and allow specific non-zero exit codes.
790///
791/// Dropping the returned future kills the child; see [`run_claude`].
792#[cfg(feature = "async")]
793pub async fn run_claude_allow_exit_codes(
794    claude: &Claude,
795    args: Vec<String>,
796    allowed_codes: &[i32],
797) -> Result<CommandOutput> {
798    let output = run_claude(claude, args).await;
799
800    match output {
801        Err(Error::CommandFailed {
802            exit_code,
803            stdout,
804            stderr,
805            ..
806        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
807            stdout,
808            stderr,
809            exit_code,
810            success: false,
811        }),
812        other => other,
813    }
814}
815
816#[cfg(feature = "async")]
817async fn run_internal(
818    binary: &std::path::Path,
819    args: &[String],
820    env: &std::collections::HashMap<String, String>,
821    clear_env: bool,
822    working_dir: Option<&std::path::Path>,
823    policy: SpawnPolicy<'_>,
824) -> Result<CommandOutput> {
825    let SpawnPolicy {
826        process_group,
827        kill_grace: _, // no kill site on this path
828        die_with_parent,
829        on_spawn,
830    } = policy;
831    let mut cmd = Command::new(binary);
832    cmd.args(args);
833
834    // Prevent child from inheriting/blocking on parent's stdin.
835    cmd.stdin(std::process::Stdio::null());
836    cmd.stdout(std::process::Stdio::piped());
837    cmd.stderr(std::process::Stdio::piped());
838
839    // Dropping the in-flight future must kill the child, not leave the
840    // CLI running unattended (see the module docs).
841    cmd.kill_on_drop(true);
842    // Own process group (Unix) so cancellation can signal the whole
843    // tree, not just the direct child (see GroupKillGuard). Opt out
844    // via ClaudeBuilder::process_group.
845    apply_process_group(&mut cmd, process_group);
846    apply_die_with_parent(&mut cmd, die_with_parent);
847
848    apply_child_environment(cmd.as_std_mut(), clear_env, env);
849
850    if let Some(dir) = working_dir {
851        cmd.current_dir(dir);
852    }
853
854    // Spawn explicitly (rather than `Command::output`) so the pid is
855    // available to the group-kill guard while the run is in flight.
856    let mut child = spawn_retrying_txtbsy(&mut cmd)
857        .await
858        .map_err(|e| Error::Io {
859            message: format!("failed to spawn claude: {e}"),
860            source: e,
861            working_dir: working_dir.map(|p| p.to_path_buf()),
862        })?;
863    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
864
865    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
866    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
867
868    let (status, stdout, stderr) = tokio::join!(
869        child.wait(),
870        drain(&mut stdout_handle),
871        drain(&mut stderr_handle),
872    );
873
874    let status = status.map_err(|e| Error::Io {
875        message: "failed to wait for claude process".to_string(),
876        source: e,
877        working_dir: working_dir.map(|p| p.to_path_buf()),
878    })?;
879    group.disarm();
880
881    let exit_code = status.code().unwrap_or(-1);
882
883    if !status.success() {
884        return Err(Error::from_command_failure(
885            format!("{} {}", binary.display(), args.join(" ")),
886            exit_code,
887            stdout,
888            stderr,
889            working_dir.map(|p| p.to_path_buf()),
890        ));
891    }
892
893    Ok(CommandOutput {
894        stdout,
895        stderr,
896        exit_code,
897        success: true,
898    })
899}
900
901/// Run a command with a timeout, killing the child's whole process
902/// group (Unix) and reaping the child on expiration.
903///
904/// Spawns the child explicitly (rather than wrapping `Command::output()` in a
905/// `tokio::time::timeout`) so that we retain the handle and can SIGKILL the
906/// child and wait for it when the timeout fires. Stdout and stderr are drained
907/// concurrently with `child.wait()` via `tokio::join!` so neither pipe buffer
908/// can fill up and deadlock the child.
909///
910/// On timeout, partial stdout/stderr captured before the kill is logged at
911/// warn level; the returned `Error::Timeout` itself does not carry the
912/// partial output.
913#[cfg(feature = "async")]
914async fn run_with_timeout(
915    binary: &std::path::Path,
916    args: &[String],
917    env: &std::collections::HashMap<String, String>,
918    clear_env: bool,
919    working_dir: Option<&std::path::Path>,
920    timeout: Duration,
921    policy: SpawnPolicy<'_>,
922) -> Result<CommandOutput> {
923    let SpawnPolicy {
924        process_group,
925        kill_grace,
926        die_with_parent,
927        on_spawn,
928    } = policy;
929    let mut cmd = Command::new(binary);
930    cmd.args(args);
931    cmd.stdin(std::process::Stdio::null());
932    cmd.stdout(std::process::Stdio::piped());
933    cmd.stderr(std::process::Stdio::piped());
934    // Dropping the in-flight future must kill the child, not leave the
935    // CLI running unattended (see the module docs).
936    cmd.kill_on_drop(true);
937    // Own process group (Unix) so cancellation can signal the whole
938    // tree, not just the direct child (see GroupKillGuard). Opt out
939    // via ClaudeBuilder::process_group.
940    apply_process_group(&mut cmd, process_group);
941    apply_die_with_parent(&mut cmd, die_with_parent);
942    apply_child_environment(cmd.as_std_mut(), clear_env, env);
943
944    if let Some(dir) = working_dir {
945        cmd.current_dir(dir);
946    }
947
948    let mut child = spawn_retrying_txtbsy(&mut cmd)
949        .await
950        .map_err(|e| Error::Io {
951            message: format!("failed to spawn claude: {e}"),
952            source: e,
953            working_dir: working_dir.map(|p| p.to_path_buf()),
954        })?;
955    let mut group = arm_and_notify(process_group, child.id(), on_spawn);
956
957    let mut stdout = child.stdout.take().expect("stdout was piped");
958    let mut stderr = child.stderr.take().expect("stderr was piped");
959
960    // Drain stdout and stderr concurrently with the process wait so
961    // neither pipe buffer can fill up and deadlock the child.
962    // tokio::join! polls all three on the same task; no tokio::spawn
963    // (and therefore no `rt` feature) required.
964    let wait_and_drain = async {
965        let (status, stdout_str, stderr_str) =
966            tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
967        (status, stdout_str, stderr_str)
968    };
969
970    match tokio::time::timeout(timeout, wait_and_drain).await {
971        Ok((Ok(status), stdout, stderr)) => {
972            group.disarm();
973            let exit_code = status.code().unwrap_or(-1);
974
975            if !status.success() {
976                return Err(Error::from_command_failure(
977                    format!("{} {}", binary.display(), args.join(" ")),
978                    exit_code,
979                    stdout,
980                    stderr,
981                    working_dir.map(|p| p.to_path_buf()),
982                ));
983            }
984
985            Ok(CommandOutput {
986                stdout,
987                stderr,
988                exit_code,
989                success: true,
990            })
991        }
992        Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
993            message: "failed to wait for claude process".to_string(),
994            source: e,
995            working_dir: working_dir.map(|p| p.to_path_buf()),
996        }),
997        Err(_) => {
998            // Timeout: take down the whole group, honoring the
999            // optional SIGTERM grace, then kill+reap the direct
1000            // child. The group kill takes down subprocesses that
1001            // could otherwise hold our pipe fds open forever; the
1002            // capped drain below stays as a backstop.
1003            kill_group_with_grace(&mut group, kill_grace).await;
1004            let _ = child.kill().await;
1005            let drain_budget = Duration::from_millis(200);
1006            let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
1007                .await
1008                .unwrap_or_default();
1009            let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
1010                .await
1011                .unwrap_or_default();
1012            if !stdout_str.is_empty() || !stderr_str.is_empty() {
1013                warn!(
1014                    stdout = %stdout_str,
1015                    stderr = %stderr_str,
1016                    "partial output from timed-out process",
1017                );
1018            }
1019            Err(Error::Timeout {
1020                timeout_seconds: timeout.as_secs(),
1021            })
1022        }
1023    }
1024}
1025
1026#[cfg(feature = "async")]
1027async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
1028    let mut buf = Vec::new();
1029    let _ = reader.read_to_end(&mut buf).await;
1030    String::from_utf8_lossy(&buf).into_owned()
1031}
1032
1033/// Total wall-clock time to keep retrying a spawn that reports `ETXTBSY`.
1034///
1035/// Measured as elapsed time rather than a sum of backoffs so a saturated
1036/// host (a CI job running build + clippy + tests at once) still gets the
1037/// full window: the busy descriptor can stay open longer than the old
1038/// 500ms budget under that load, which surfaced as a spurious spawn
1039/// failure. This is only ever spent when a real `ETXTBSY` occurs, which
1040/// does not happen against an already-installed binary in production.
1041#[cfg(any(feature = "async", feature = "sync"))]
1042const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
1043
1044/// Per-attempt backoff ceiling while retrying `ETXTBSY`.
1045///
1046/// Backoff grows exponentially but is capped so retries stay frequent for
1047/// the whole budget: the busy window can clear at any instant, and a large
1048/// tail sleep (the old loop reached 1-2s) would keep spawning stalled long
1049/// after the descriptor closed.
1050#[cfg(any(feature = "async", feature = "sync"))]
1051const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
1052
1053/// Spawn `cmd`, retrying briefly on `ETXTBSY` (`ExecutableFileBusy`).
1054///
1055/// `execve` fails with `ETXTBSY` when another process holds the target file
1056/// open for writing. In a multithreaded program this happens transiently even
1057/// for a file this process has finished writing: if another thread `fork`s
1058/// while a writable descriptor to the binary is still open, the child inherits
1059/// that descriptor and holds it until its own `exec` completes. Any `execve`
1060/// of the file in that window sees a writer and fails. The condition always
1061/// clears on its own, so retry within a bounded wall-clock budget rather than
1062/// surfacing a spurious spawn failure.
1063#[cfg(feature = "async")]
1064async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
1065    let start = std::time::Instant::now();
1066    let mut backoff = Duration::from_millis(1);
1067    loop {
1068        match cmd.spawn() {
1069            Err(e)
1070                if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1071                    && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1072            {
1073                tokio::time::sleep(backoff).await;
1074                backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1075            }
1076            other => return other,
1077        }
1078    }
1079}
1080
1081// ---------- sync twins ----------
1082
1083/// Blocking mirror of [`run_claude`]. Available with the `sync` feature.
1084#[cfg(feature = "sync")]
1085pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1086    run_claude_with_retry_sync(claude, args, None)
1087}
1088
1089/// Blocking mirror of [`run_claude_with_retry`].
1090#[cfg(feature = "sync")]
1091pub fn run_claude_with_retry_sync(
1092    claude: &Claude,
1093    args: Vec<String>,
1094    retry_override: Option<&crate::retry::RetryPolicy>,
1095) -> Result<CommandOutput> {
1096    let policy = retry_override.or(claude.retry_policy.as_ref());
1097
1098    match policy {
1099        Some(policy) => {
1100            crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
1101        }
1102        None => run_claude_once_sync(claude, args),
1103    }
1104}
1105
1106/// Blocking mirror of [`run_claude_with_stdin_prompt`].
1107///
1108/// stdin mode does not retry -- the stdin pipe is consumed after the first
1109/// attempt and cannot be rewound.
1110#[cfg(feature = "sync")]
1111pub fn run_claude_with_stdin_prompt_sync(
1112    claude: &Claude,
1113    args: Vec<String>,
1114    stdin_content: String,
1115) -> Result<CommandOutput> {
1116    let command_args = full_command_args(claude, args);
1117
1118    let span = exec_span(claude, &command_args, "stdin-sync");
1119    let _enter = span.enter();
1120    let started = std::time::Instant::now();
1121    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
1122
1123    let result = if let Some(timeout) = claude.timeout {
1124        run_with_timeout_stdin_sync(
1125            &claude.binary,
1126            &command_args,
1127            &claude.env,
1128            claude.clear_env,
1129            claude.working_dir.as_deref(),
1130            timeout,
1131            stdin_content,
1132            SpawnPolicy::of(claude),
1133        )
1134    } else {
1135        run_internal_stdin_sync(
1136            &claude.binary,
1137            &command_args,
1138            &claude.env,
1139            claude.clear_env,
1140            claude.working_dir.as_deref(),
1141            stdin_content,
1142            SpawnPolicy::of(claude),
1143        )
1144    };
1145
1146    if let Ok(output) = &result {
1147        record_exec_outcome(&span, output.exit_code, started);
1148    }
1149    result
1150}
1151
1152#[cfg(feature = "sync")]
1153fn run_internal_stdin_sync(
1154    binary: &std::path::Path,
1155    args: &[String],
1156    env: &std::collections::HashMap<String, String>,
1157    clear_env: bool,
1158    working_dir: Option<&std::path::Path>,
1159    stdin_content: String,
1160    policy: SpawnPolicy<'_>,
1161) -> Result<CommandOutput> {
1162    let SpawnPolicy {
1163        process_group,
1164        kill_grace: _, // no kill site on this path
1165        die_with_parent,
1166        on_spawn,
1167    } = policy;
1168    use std::io::Write;
1169    use std::process::{Command as StdCommand, Stdio};
1170
1171    let mut cmd = StdCommand::new(binary);
1172    cmd.args(args);
1173    cmd.stdin(Stdio::piped());
1174    cmd.stdout(Stdio::piped());
1175    cmd.stderr(Stdio::piped());
1176    // Own process group (Unix) so a kill can signal the whole tree,
1177    // not just the direct child (see GroupKillGuard). Opt out via
1178    // ClaudeBuilder::process_group.
1179    apply_process_group_sync(&mut cmd, process_group);
1180    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1181    apply_child_environment(&mut cmd, clear_env, env);
1182
1183    if let Some(dir) = working_dir {
1184        cmd.current_dir(dir);
1185    }
1186
1187    let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1188        message: format!("failed to spawn claude: {e}"),
1189        source: e,
1190        working_dir: working_dir.map(|p| p.to_path_buf()),
1191    })?;
1192    let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1193
1194    // Write the prompt to stdin, then drop the handle so the child sees EOF.
1195    if let Some(mut stdin) = child.stdin.take() {
1196        stdin
1197            .write_all(stdin_content.as_bytes())
1198            .map_err(|e| Error::Io {
1199                message: format!("failed to write to claude stdin: {e}"),
1200                source: e,
1201                working_dir: working_dir.map(|p| p.to_path_buf()),
1202            })?;
1203        stdin.flush().map_err(|e| Error::Io {
1204            message: format!("failed to flush claude stdin: {e}"),
1205            source: e,
1206            working_dir: working_dir.map(|p| p.to_path_buf()),
1207        })?;
1208        // Drop stdin so the child sees EOF.
1209    }
1210
1211    let output = child.wait_with_output().map_err(|e| Error::Io {
1212        message: "failed to wait for claude process".to_string(),
1213        source: e,
1214        working_dir: working_dir.map(|p| p.to_path_buf()),
1215    })?;
1216    group.disarm();
1217
1218    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1219    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1220    let exit_code = output.status.code().unwrap_or(-1);
1221
1222    if !output.status.success() {
1223        return Err(Error::from_command_failure(
1224            format!("{} {}", binary.display(), args.join(" ")),
1225            exit_code,
1226            stdout,
1227            stderr,
1228            working_dir.map(|p| p.to_path_buf()),
1229        ));
1230    }
1231
1232    Ok(CommandOutput {
1233        stdout,
1234        stderr,
1235        exit_code,
1236        success: true,
1237    })
1238}
1239
1240#[cfg(feature = "sync")]
1241#[allow(clippy::too_many_arguments)]
1242fn run_with_timeout_stdin_sync(
1243    binary: &std::path::Path,
1244    args: &[String],
1245    env: &std::collections::HashMap<String, String>,
1246    clear_env: bool,
1247    working_dir: Option<&std::path::Path>,
1248    timeout: Duration,
1249    stdin_content: String,
1250    policy: SpawnPolicy<'_>,
1251) -> Result<CommandOutput> {
1252    let SpawnPolicy {
1253        process_group,
1254        kill_grace,
1255        die_with_parent,
1256        on_spawn,
1257    } = policy;
1258    use std::io::Write;
1259    use std::process::{Command as StdCommand, Stdio};
1260    use std::thread;
1261    use wait_timeout::ChildExt;
1262
1263    let mut cmd = StdCommand::new(binary);
1264    cmd.args(args);
1265    cmd.stdin(Stdio::piped());
1266    cmd.stdout(Stdio::piped());
1267    cmd.stderr(Stdio::piped());
1268    // Own process group (Unix) so a kill can signal the whole tree,
1269    // not just the direct child (see GroupKillGuard). Opt out via
1270    // ClaudeBuilder::process_group.
1271    apply_process_group_sync(&mut cmd, process_group);
1272    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1273    apply_child_environment(&mut cmd, clear_env, env);
1274
1275    if let Some(dir) = working_dir {
1276        cmd.current_dir(dir);
1277    }
1278
1279    let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1280        message: format!("failed to spawn claude: {e}"),
1281        source: e,
1282        working_dir: working_dir.map(|p| p.to_path_buf()),
1283    })?;
1284    let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1285
1286    // Write the prompt to stdin, then drop the handle so the child sees EOF.
1287    if let Some(mut stdin) = child.stdin.take() {
1288        stdin
1289            .write_all(stdin_content.as_bytes())
1290            .map_err(|e| Error::Io {
1291                message: format!("failed to write to claude stdin: {e}"),
1292                source: e,
1293                working_dir: working_dir.map(|p| p.to_path_buf()),
1294            })?;
1295        stdin.flush().map_err(|e| Error::Io {
1296            message: format!("failed to flush claude stdin: {e}"),
1297            source: e,
1298            working_dir: working_dir.map(|p| p.to_path_buf()),
1299        })?;
1300        // Drop stdin so the child sees EOF.
1301    }
1302
1303    let stdout = child.stdout.take().expect("stdout was piped");
1304    let stderr = child.stderr.take().expect("stderr was piped");
1305
1306    let stdout_thread = thread::spawn(move || drain_sync(stdout));
1307    let stderr_thread = thread::spawn(move || drain_sync(stderr));
1308
1309    match child.wait_timeout(timeout).map_err(|e| Error::Io {
1310        message: "failed to wait for claude process".to_string(),
1311        source: e,
1312        working_dir: working_dir.map(|p| p.to_path_buf()),
1313    })? {
1314        Some(status) => {
1315            group.disarm();
1316            let stdout = stdout_thread.join().unwrap_or_default();
1317            let stderr = stderr_thread.join().unwrap_or_default();
1318            let exit_code = status.code().unwrap_or(-1);
1319
1320            if !status.success() {
1321                return Err(Error::from_command_failure(
1322                    format!("{} {}", binary.display(), args.join(" ")),
1323                    exit_code,
1324                    stdout,
1325                    stderr,
1326                    working_dir.map(|p| p.to_path_buf()),
1327                ));
1328            }
1329
1330            Ok(CommandOutput {
1331                stdout,
1332                stderr,
1333                exit_code,
1334                success: true,
1335            })
1336        }
1337        None => {
1338            // Timeout: take down the whole group first (subprocesses
1339            // may hold our pipe fds), honoring the optional SIGTERM
1340            // grace, then kill+reap the direct child.
1341            kill_group_with_grace_sync(&mut group, kill_grace);
1342            let _ = child.kill();
1343            let _ = child.wait();
1344            let (stdout_str, stderr_str) =
1345                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1346            if !stdout_str.is_empty() || !stderr_str.is_empty() {
1347                warn!(
1348                    stdout = %stdout_str,
1349                    stderr = %stderr_str,
1350                    "partial output from timed-out process",
1351                );
1352            }
1353            Err(Error::Timeout {
1354                timeout_seconds: timeout.as_secs(),
1355            })
1356        }
1357    }
1358}
1359
1360#[cfg(feature = "sync")]
1361fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
1362    let command_args = full_command_args(claude, args);
1363
1364    let span = exec_span(claude, &command_args, "oneshot-sync");
1365    let _enter = span.enter();
1366    let started = std::time::Instant::now();
1367    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
1368
1369    let result = if let Some(timeout) = claude.timeout {
1370        run_with_timeout_sync(
1371            &claude.binary,
1372            &command_args,
1373            &claude.env,
1374            claude.clear_env,
1375            claude.working_dir.as_deref(),
1376            timeout,
1377            SpawnPolicy::of(claude),
1378        )
1379    } else {
1380        run_internal_sync(
1381            &claude.binary,
1382            &command_args,
1383            &claude.env,
1384            claude.clear_env,
1385            claude.working_dir.as_deref(),
1386            SpawnPolicy::of(claude),
1387        )
1388    };
1389
1390    if let Ok(output) = &result {
1391        record_exec_outcome(&span, output.exit_code, started);
1392    }
1393    result
1394}
1395
1396/// Blocking mirror of [`run_claude_allow_exit_codes`].
1397#[cfg(feature = "sync")]
1398pub fn run_claude_allow_exit_codes_sync(
1399    claude: &Claude,
1400    args: Vec<String>,
1401    allowed_codes: &[i32],
1402) -> Result<CommandOutput> {
1403    match run_claude_sync(claude, args) {
1404        Err(Error::CommandFailed {
1405            exit_code,
1406            stdout,
1407            stderr,
1408            ..
1409        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
1410            stdout,
1411            stderr,
1412            exit_code,
1413            success: false,
1414        }),
1415        other => other,
1416    }
1417}
1418
1419#[cfg(feature = "sync")]
1420fn run_internal_sync(
1421    binary: &std::path::Path,
1422    args: &[String],
1423    env: &std::collections::HashMap<String, String>,
1424    clear_env: bool,
1425    working_dir: Option<&std::path::Path>,
1426    policy: SpawnPolicy<'_>,
1427) -> Result<CommandOutput> {
1428    let SpawnPolicy {
1429        process_group,
1430        kill_grace: _, // no kill site on this path
1431        die_with_parent,
1432        on_spawn,
1433    } = policy;
1434    use std::process::{Command as StdCommand, Stdio};
1435
1436    let mut cmd = StdCommand::new(binary);
1437    cmd.args(args);
1438    cmd.stdin(Stdio::null());
1439    // Own process group (Unix); see the module docs. This path has no
1440    // kill site (a blocking call cannot be cancelled mid-flight), so
1441    // there is no guard to arm -- but the child still exists and a
1442    // supervisor still wants its pid, so the spawn is observed below.
1443    apply_process_group_sync(&mut cmd, process_group);
1444    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1445    apply_child_environment(&mut cmd, clear_env, env);
1446
1447    if let Some(dir) = working_dir {
1448        cmd.current_dir(dir);
1449    }
1450
1451    let output =
1452        output_retrying_txtbsy_sync_observed(&mut cmd, process_group, on_spawn).map_err(|e| {
1453            Error::Io {
1454                message: format!("failed to spawn claude: {e}"),
1455                source: e,
1456                working_dir: working_dir.map(|p| p.to_path_buf()),
1457            }
1458        })?;
1459
1460    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1461    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1462    let exit_code = output.status.code().unwrap_or(-1);
1463
1464    if !output.status.success() {
1465        return Err(Error::from_command_failure(
1466            format!("{} {}", binary.display(), args.join(" ")),
1467            exit_code,
1468            stdout,
1469            stderr,
1470            working_dir.map(|p| p.to_path_buf()),
1471        ));
1472    }
1473
1474    Ok(CommandOutput {
1475        stdout,
1476        stderr,
1477        exit_code,
1478        success: true,
1479    })
1480}
1481
1482/// Blocking run with a timeout. Mirrors [`run_with_timeout`]: spawns
1483/// the child, drains stdout/stderr on dedicated threads so neither
1484/// pipe buffer can fill up while we wait, then uses
1485/// [`wait_timeout::ChildExt::wait_timeout`] to enforce the deadline.
1486/// On timeout, the child's whole process group (Unix) is SIGKILLed and
1487/// the child reaped; partial output is logged at warn but the returned
1488/// [`Error::Timeout`] does not carry it.
1489#[cfg(feature = "sync")]
1490fn run_with_timeout_sync(
1491    binary: &std::path::Path,
1492    args: &[String],
1493    env: &std::collections::HashMap<String, String>,
1494    clear_env: bool,
1495    working_dir: Option<&std::path::Path>,
1496    timeout: Duration,
1497    policy: SpawnPolicy<'_>,
1498) -> Result<CommandOutput> {
1499    let SpawnPolicy {
1500        process_group,
1501        kill_grace,
1502        die_with_parent,
1503        on_spawn,
1504    } = policy;
1505    use std::process::{Command as StdCommand, Stdio};
1506    use std::thread;
1507    use wait_timeout::ChildExt;
1508
1509    let mut cmd = StdCommand::new(binary);
1510    cmd.args(args);
1511    cmd.stdin(Stdio::null());
1512    cmd.stdout(Stdio::piped());
1513    cmd.stderr(Stdio::piped());
1514    // Own process group (Unix) so a kill can signal the whole tree,
1515    // not just the direct child (see GroupKillGuard). Opt out via
1516    // ClaudeBuilder::process_group.
1517    apply_process_group_sync(&mut cmd, process_group);
1518    apply_die_with_parent_sync(&mut cmd, die_with_parent);
1519    apply_child_environment(&mut cmd, clear_env, env);
1520
1521    if let Some(dir) = working_dir {
1522        cmd.current_dir(dir);
1523    }
1524
1525    let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
1526        message: format!("failed to spawn claude: {e}"),
1527        source: e,
1528        working_dir: working_dir.map(|p| p.to_path_buf()),
1529    })?;
1530    let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
1531
1532    // Detach stdout/stderr onto their own threads so neither can block
1533    // the child by filling its pipe buffer. Each thread owns its half
1534    // and drops it on completion, which closes the parent's fd and
1535    // lets read_to_end() return EOF once the child exits.
1536    let stdout = child.stdout.take().expect("stdout was piped");
1537    let stderr = child.stderr.take().expect("stderr was piped");
1538
1539    let stdout_thread = thread::spawn(move || drain_sync(stdout));
1540    let stderr_thread = thread::spawn(move || drain_sync(stderr));
1541
1542    match child.wait_timeout(timeout).map_err(|e| Error::Io {
1543        message: "failed to wait for claude process".to_string(),
1544        source: e,
1545        working_dir: working_dir.map(|p| p.to_path_buf()),
1546    })? {
1547        Some(status) => {
1548            group.disarm();
1549            let stdout = stdout_thread.join().unwrap_or_default();
1550            let stderr = stderr_thread.join().unwrap_or_default();
1551            let exit_code = status.code().unwrap_or(-1);
1552
1553            if !status.success() {
1554                return Err(Error::from_command_failure(
1555                    format!("{} {}", binary.display(), args.join(" ")),
1556                    exit_code,
1557                    stdout,
1558                    stderr,
1559                    working_dir.map(|p| p.to_path_buf()),
1560                ));
1561            }
1562
1563            Ok(CommandOutput {
1564                stdout,
1565                stderr,
1566                exit_code,
1567                success: true,
1568            })
1569        }
1570        None => {
1571            // Timeout: take down the whole group, honoring the
1572            // optional SIGTERM grace, then kill+reap the direct
1573            // child. The group kill takes down subprocesses that
1574            // could otherwise hold our pipe fds open and block the
1575            // drain threads; the capped join below stays as a
1576            // backstop.
1577            kill_group_with_grace_sync(&mut group, kill_grace);
1578            let _ = child.kill();
1579            let _ = child.wait();
1580
1581            let (stdout_str, stderr_str) =
1582                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
1583
1584            if !stdout_str.is_empty() || !stderr_str.is_empty() {
1585                warn!(
1586                    stdout = %stdout_str,
1587                    stderr = %stderr_str,
1588                    "partial output from timed-out process",
1589                );
1590            }
1591
1592            Err(Error::Timeout {
1593                timeout_seconds: timeout.as_secs(),
1594            })
1595        }
1596    }
1597}
1598
1599#[cfg(feature = "sync")]
1600fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
1601    let mut buf = Vec::new();
1602    let _ = reader.read_to_end(&mut buf);
1603    String::from_utf8_lossy(&buf).into_owned()
1604}
1605
1606/// Blocking mirror of [`spawn_retrying_txtbsy`]. See that function for why
1607/// `ETXTBSY` is retried rather than surfaced.
1608#[cfg(feature = "sync")]
1609fn spawn_retrying_txtbsy_sync(
1610    cmd: &mut std::process::Command,
1611) -> std::io::Result<std::process::Child> {
1612    let start = std::time::Instant::now();
1613    let mut backoff = Duration::from_millis(1);
1614    loop {
1615        match cmd.spawn() {
1616            Err(e)
1617                if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1618                    && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1619            {
1620                std::thread::sleep(backoff);
1621                backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1622            }
1623            other => return other,
1624        }
1625    }
1626}
1627
1628/// Run `cmd` to completion, retrying on `ETXTBSY` like
1629/// [`spawn_retrying_txtbsy_sync`].
1630///
1631/// The blocking no-timeout capture path calls `Command::output` (spawn,
1632/// wait, and collect in one step) rather than holding a `Child`, so it
1633/// needs the same retry wrapped around `output` itself. The `ETXTBSY`
1634/// still occurs at the `execve` inside `output`.
1635#[cfg(feature = "sync")]
1636/// Run to completion, reporting the child to `on_spawn` first.
1637///
1638/// `Command::output` is `spawn` followed by `wait_with_output`, so splitting
1639/// the two is behaviour-identical and makes the pid observable on a path that
1640/// otherwise never exposes it.
1641#[cfg(feature = "sync")]
1642fn output_retrying_txtbsy_sync_observed(
1643    cmd: &mut std::process::Command,
1644    process_group: bool,
1645    on_spawn: Option<&crate::SpawnObserver>,
1646) -> std::io::Result<std::process::Output> {
1647    // `Command::output` pipes stdout and stderr implicitly; `spawn` does not,
1648    // so setting them here keeps this split behaviour-identical rather than
1649    // silently returning empty captures.
1650    cmd.stdout(std::process::Stdio::piped());
1651    cmd.stderr(std::process::Stdio::piped());
1652
1653    let start = std::time::Instant::now();
1654    let mut backoff = Duration::from_millis(1);
1655    loop {
1656        let spawned = cmd.spawn().inspect(|child| {
1657            if let Some(observer) = on_spawn {
1658                let pid = child.id();
1659                observer(crate::SpawnInfo {
1660                    pid,
1661                    pgid: process_group.then_some(pid),
1662                });
1663            }
1664        });
1665        match spawned.and_then(std::process::Child::wait_with_output) {
1666            Err(e)
1667                if e.kind() == std::io::ErrorKind::ExecutableFileBusy
1668                    && start.elapsed() < TXTBSY_RETRY_BUDGET =>
1669            {
1670                std::thread::sleep(backoff);
1671                backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
1672            }
1673            other => return other,
1674        }
1675    }
1676}
1677
1678/// Wait for both drain threads to finish, returning "" for any that
1679/// miss the deadline. Threads aren't cancellable in std; if the child's
1680/// subprocesses are still holding a pipe fd open after kill(), the
1681/// drain thread leaks. That's a pathological case; the common timeout
1682/// path with a responsive child joins in microseconds.
1683#[cfg(feature = "sync")]
1684fn join_with_deadline(
1685    stdout_thread: std::thread::JoinHandle<String>,
1686    stderr_thread: std::thread::JoinHandle<String>,
1687    budget: Duration,
1688) -> (String, String) {
1689    use std::sync::mpsc;
1690    use std::thread;
1691
1692    let (tx, rx) = mpsc::channel::<(&'static str, String)>();
1693
1694    let tx_out = tx.clone();
1695    let tx_err = tx;
1696
1697    thread::spawn(move || {
1698        let s = stdout_thread.join().unwrap_or_default();
1699        let _ = tx_out.send(("stdout", s));
1700    });
1701    thread::spawn(move || {
1702        let s = stderr_thread.join().unwrap_or_default();
1703        let _ = tx_err.send(("stderr", s));
1704    });
1705
1706    let mut stdout = String::new();
1707    let mut stderr = String::new();
1708    let deadline = std::time::Instant::now() + budget;
1709
1710    for _ in 0..2 {
1711        let now = std::time::Instant::now();
1712        if now >= deadline {
1713            break;
1714        }
1715        match rx.recv_timeout(deadline - now) {
1716            Ok(("stdout", s)) => stdout = s,
1717            Ok(("stderr", s)) => stderr = s,
1718            Ok(_) => unreachable!(),
1719            Err(_) => break,
1720        }
1721    }
1722
1723    (stdout, stderr)
1724}
1725
1726// Fake-binary-driven tests for the spawn/execute paths. Unix-only: they
1727// write and run a small bash `claude` stand-in, which cannot execute on
1728// Windows. CI runs `cargo test --lib` on Windows too, so the module is
1729// gated on `unix` to compile out there; ubuntu/macOS (and `llvm-cov`)
1730// exercise it. `tempfile` is a dev-dependency, so it is always available
1731// under `#[cfg(test)]` regardless of the crate feature.
1732#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
1733mod tests {
1734    use super::*;
1735    use std::io::Write;
1736    use std::os::unix::fs::PermissionsExt;
1737
1738    use crate::Claude;
1739
1740    /// Write `body` as an executable bash `claude` stand-in in a fresh
1741    /// tempdir. Returns the dir (keep it bound so it outlives the run)
1742    /// and the script path.
1743    fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
1744        let dir = tempfile::tempdir().expect("tempdir");
1745        let path = dir.path().join("fake-claude.sh");
1746        // Close the writable handle before returning so the window in which a
1747        // concurrent test's fork could inherit a writable fd to this script
1748        // (and make our later execve fail with ETXTBSY) is as short as
1749        // possible. Spawn itself retries ETXTBSY; this just makes it rarer.
1750        {
1751            let mut f = std::fs::File::create(&path).expect("create script");
1752            write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
1753            f.sync_all().expect("sync script");
1754        }
1755        let perms = std::fs::Permissions::from_mode(0o755);
1756        std::fs::set_permissions(&path, perms).expect("chmod");
1757        (dir, path)
1758    }
1759
1760    fn client(path: &std::path::Path) -> Claude {
1761        Claude::builder()
1762            .binary(path)
1763            .build()
1764            .expect("build client")
1765    }
1766
1767    #[test]
1768    fn full_command_args_puts_global_args_first() {
1769        let claude = Claude::builder()
1770            .binary("/usr/local/bin/claude")
1771            .arg("--debug")
1772            .arg("--verbose")
1773            .build()
1774            .expect("build client");
1775        let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
1776        assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
1777    }
1778
1779    #[test]
1780    fn full_command_args_without_global_args_is_passthrough() {
1781        let claude = Claude::builder()
1782            .binary("/usr/local/bin/claude")
1783            .build()
1784            .expect("build client");
1785        let args = full_command_args(&claude, vec!["--print".to_string()]);
1786        assert_eq!(args, ["--print"]);
1787    }
1788
1789    // Serializes the env-scrub tests, which mutate process-global env.
1790    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1791
1792    fn set_scrub_vars() {
1793        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1794        // SAFETY: the synchronous mutation is serialized by ENV_LOCK and
1795        // not held across any await; no other test reads these vars.
1796        unsafe {
1797            std::env::set_var("CLAUDECODE", "1");
1798            std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
1799        }
1800    }
1801
1802    fn clear_scrub_vars() {
1803        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1804        // SAFETY: see set_scrub_vars.
1805        unsafe {
1806            std::env::remove_var("CLAUDECODE");
1807            std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
1808        }
1809    }
1810
1811    // ---------- async ----------
1812
1813    #[cfg(feature = "async")]
1814    #[tokio::test]
1815    async fn async_success_maps_output() {
1816        let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
1817        let out = run_claude(&client(&path), vec!["--version".into()])
1818            .await
1819            .expect("success");
1820        assert!(out.success);
1821        assert_eq!(out.exit_code, 0);
1822        assert!(out.stdout.contains("hi there"));
1823    }
1824
1825    #[cfg(feature = "async")]
1826    #[tokio::test]
1827    async fn async_nonzero_exit_maps_command_failed() {
1828        let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
1829        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1830        match err {
1831            Error::CommandFailed {
1832                exit_code, stderr, ..
1833            } => {
1834                assert_eq!(exit_code, 3);
1835                assert!(stderr.contains("boom"));
1836            }
1837            other => panic!("expected CommandFailed, got {other:?}"),
1838        }
1839    }
1840
1841    #[cfg(feature = "async")]
1842    #[tokio::test]
1843    async fn async_rail_stop_maps_max_turns() {
1844        let (_dir, path) = fake_script(
1845            r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
1846        );
1847        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1848        assert!(
1849            matches!(
1850                err,
1851                Error::MaxTurnsExceeded {
1852                    max_turns: Some(2),
1853                    ..
1854                }
1855            ),
1856            "got: {err:?}"
1857        );
1858    }
1859
1860    #[cfg(feature = "async")]
1861    #[tokio::test]
1862    async fn async_auth_shaped_stderr_maps_auth() {
1863        let (_dir, path) =
1864            fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
1865        let err = run_claude(&client(&path), vec![]).await.unwrap_err();
1866        assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
1867    }
1868
1869    #[cfg(feature = "async")]
1870    #[tokio::test]
1871    async fn async_scrubs_claude_env_vars() {
1872        let (_dir, path) =
1873            fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
1874        // The child sees the vars scrubbed regardless; setting them in the
1875        // parent is what makes the assertion meaningful rather than
1876        // trivially empty. Correctness does not depend on the lock (the
1877        // scrub removes them either way), so it only wraps the synchronous
1878        // env mutations -- never held across the await, per clippy.
1879        set_scrub_vars();
1880        let out = run_claude(&client(&path), vec![]).await.expect("success");
1881        clear_scrub_vars();
1882        assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
1883        assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
1884    }
1885
1886    #[cfg(feature = "async")]
1887    #[tokio::test]
1888    async fn async_applies_working_dir() {
1889        let (_dir, path) = fake_script(r#"pwd"#);
1890        let workdir = tempfile::tempdir().expect("workdir");
1891        let claude = Claude::builder()
1892            .binary(&path)
1893            .working_dir(workdir.path())
1894            .build()
1895            .expect("build");
1896        let out = run_claude(&claude, vec![]).await.expect("success");
1897        let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
1898        let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
1899        assert_eq!(got, want);
1900    }
1901
1902    #[cfg(feature = "async")]
1903    #[tokio::test]
1904    async fn async_stdin_prompt_round_trips() {
1905        let (_dir, path) = fake_script(r#"cat"#);
1906        let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
1907            .await
1908            .expect("success");
1909        assert!(out.stdout.contains("hello via stdin"));
1910    }
1911
1912    // The retry loop in `spawn_retrying_txtbsy` must only absorb `ETXTBSY`;
1913    // every other spawn error has to surface promptly rather than be retried
1914    // until the budget elapses. A missing binary yields `NotFound`, which
1915    // must return on the first attempt.
1916    #[cfg(feature = "async")]
1917    #[tokio::test]
1918    async fn async_spawn_retry_passes_through_non_txtbsy_error() {
1919        let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
1920        let err = spawn_retrying_txtbsy(&mut cmd)
1921            .await
1922            .expect_err("spawn of missing binary should fail");
1923        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
1924    }
1925
1926    #[cfg(feature = "async")]
1927    #[tokio::test]
1928    async fn async_allow_exit_codes_permits_listed_code() {
1929        let (_dir, path) = fake_script(r#"echo out; exit 2"#);
1930        let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
1931            .await
1932            .expect("allowed code is Ok");
1933        assert!(!out.success);
1934        assert_eq!(out.exit_code, 2);
1935        assert!(out.stdout.contains("out"));
1936    }
1937
1938    #[cfg(feature = "async")]
1939    #[tokio::test]
1940    async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
1941        let (_dir, path) = fake_script(r#"exit 2"#);
1942        let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
1943            .await
1944            .unwrap_err();
1945        assert!(
1946            matches!(err, Error::CommandFailed { exit_code: 2, .. }),
1947            "got: {err:?}"
1948        );
1949    }
1950
1951    #[cfg(feature = "async")]
1952    #[tokio::test]
1953    async fn async_timeout_fires_on_slow_child() {
1954        let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
1955        let claude = Claude::builder()
1956            .binary(&path)
1957            .timeout(Duration::from_millis(300))
1958            .build()
1959            .expect("build");
1960        let err = run_claude(&claude, vec![]).await.unwrap_err();
1961        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
1962    }
1963
1964    #[cfg(feature = "async")]
1965    #[tokio::test]
1966    async fn async_timeout_path_returns_output_when_fast() {
1967        let (_dir, path) = fake_script(r#"echo quick"#);
1968        let claude = Claude::builder()
1969            .binary(&path)
1970            .timeout(Duration::from_secs(30))
1971            .build()
1972            .expect("build");
1973        let out = run_claude(&claude, vec![]).await.expect("success");
1974        assert!(out.stdout.contains("quick"));
1975    }
1976
1977    #[cfg(feature = "async")]
1978    #[tokio::test]
1979    async fn async_timeout_path_maps_command_failed() {
1980        let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
1981        let claude = Claude::builder()
1982            .binary(&path)
1983            .timeout(Duration::from_secs(30))
1984            .build()
1985            .expect("build");
1986        let err = run_claude(&claude, vec![]).await.unwrap_err();
1987        assert!(
1988            matches!(err, Error::CommandFailed { exit_code: 4, .. }),
1989            "got: {err:?}"
1990        );
1991    }
1992
1993    #[cfg(feature = "async")]
1994    #[tokio::test]
1995    async fn async_stdin_with_timeout_round_trips() {
1996        let (_dir, path) = fake_script(r#"cat"#);
1997        let claude = Claude::builder()
1998            .binary(&path)
1999            .timeout(Duration::from_secs(30))
2000            .build()
2001            .expect("build");
2002        let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
2003            .await
2004            .expect("success");
2005        assert!(out.stdout.contains("piped under timeout"));
2006    }
2007
2008    #[cfg(feature = "async")]
2009    #[tokio::test]
2010    async fn async_stdin_timeout_fires_on_slow_child() {
2011        let (_dir, path) = fake_script(r#"sleep 3"#);
2012        let claude = Claude::builder()
2013            .binary(&path)
2014            .timeout(Duration::from_millis(300))
2015            .build()
2016            .expect("build");
2017        let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
2018            .await
2019            .unwrap_err();
2020        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2021    }
2022
2023    /// Drive `fut` just long enough for the fake script to write its pid
2024    /// file, then drop it mid-flight (on return) and hand back the pid.
2025    #[cfg(feature = "async")]
2026    async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
2027    where
2028        F: std::future::Future,
2029        F::Output: std::fmt::Debug,
2030    {
2031        tokio::pin!(fut);
2032        let deadline = std::time::Instant::now() + Duration::from_secs(10);
2033        loop {
2034            if let Some(pid) = std::fs::read_to_string(pid_path)
2035                .ok()
2036                .and_then(|s| s.trim().parse().ok())
2037            {
2038                // Returning drops the pinned future here, mid-flight.
2039                return pid;
2040            }
2041            assert!(
2042                std::time::Instant::now() < deadline,
2043                "child never wrote its pid file"
2044            );
2045            tokio::select! {
2046                out = &mut fut => panic!("future completed before drop: {out:?}"),
2047                _ = tokio::time::sleep(Duration::from_millis(10)) => {}
2048            }
2049        }
2050    }
2051
2052    /// Poll until `pid` is dead or a zombie awaiting reap. The kill is
2053    /// delivered synchronously (killpg / kill_on_drop's start_kill), but
2054    /// reaping happens asynchronously, so a transient zombie counts as
2055    /// killed. Blocking on purpose: it runs after the kill has been
2056    /// issued, so nothing async needs to make progress.
2057    fn assert_pid_killed(pid: u32) {
2058        let deadline = std::time::Instant::now() + Duration::from_secs(10);
2059        loop {
2060            let out = std::process::Command::new("ps")
2061                .args(["-o", "stat=", "-p", &pid.to_string()])
2062                .output()
2063                .expect("run ps");
2064            let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2065            if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
2066                return;
2067            }
2068            assert!(
2069                std::time::Instant::now() < deadline,
2070                "process {pid} still alive (stat {stat}) after kill"
2071            );
2072            std::thread::sleep(Duration::from_millis(25));
2073        }
2074    }
2075
2076    /// Fake script that records its own pid, spawns a same-group
2077    /// grandchild that records its pid too, then sleeps far longer than
2078    /// any test deadline. The main shell waits for the grandchild pid
2079    /// to land before writing its own, so tests that poll for the pid
2080    /// file can rely on the grandchild pid being readable as well.
2081    /// Non-interactive bash does not create new process groups for
2082    /// background jobs, so a group kill must take down both.
2083    fn group_script(
2084        pid_path: &std::path::Path,
2085        gpid_path: &std::path::Path,
2086    ) -> (tempfile::TempDir, std::path::PathBuf) {
2087        // `bash -c` rather than a subshell because `$$` inside a
2088        // subshell still names the parent, and `$BASHPID` needs bash 4
2089        // (macOS ships 3.2). The path travels as `$0` so it needs no
2090        // extra quoting.
2091        fake_script(&format!(
2092            concat!(
2093                "bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
2094                "until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
2095                "echo $$ > \"{p}\"\n",
2096                "exec sleep 300",
2097            ),
2098            g = gpid_path.display(),
2099            p = pid_path.display(),
2100        ))
2101    }
2102
2103    /// Read a pid recorded by `group_script`, if fully written yet.
2104    fn try_read_pid(path: &std::path::Path) -> Option<u32> {
2105        std::fs::read_to_string(path).ok()?.trim().parse().ok()
2106    }
2107
2108    /// Read a pid recorded by `group_script`. Only the async tests use
2109    /// this unconditional variant; the sync timeout test reads through
2110    /// `try_read_pid`, so gate it to keep sync-only builds warning-free.
2111    #[cfg(feature = "async")]
2112    fn read_pid(path: &std::path::Path) -> u32 {
2113        try_read_pid(path).expect("pid file readable")
2114    }
2115
2116    // Dropping an in-flight execute future must kill the spawned child:
2117    // every async spawn site sets kill_on_drop(true), so a caller racing
2118    // execute against cancellation (tokio::select!, timeout) cannot leak
2119    // a headless CLI run. `exec` keeps the recorded pid the direct child,
2120    // so the SIGKILL lands on the process the test watches.
2121    #[cfg(feature = "async")]
2122    #[tokio::test]
2123    async fn async_dropping_in_flight_future_kills_child() {
2124        let workdir = tempfile::tempdir().expect("workdir");
2125        let pid_path = workdir.path().join("pid");
2126        let (_dir, path) = fake_script(&format!(
2127            r#"echo $$ > "{}"; exec sleep 30"#,
2128            pid_path.display()
2129        ));
2130        let claude = client(&path);
2131        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2132        assert_pid_killed(pid);
2133    }
2134
2135    // Same guarantee on the timeout path, which holds a Child from
2136    // spawn_retrying_txtbsy rather than going through Command::output.
2137    // The configured timeout is far longer than the test; the drop is
2138    // what kills the child.
2139    #[cfg(feature = "async")]
2140    #[tokio::test]
2141    async fn async_dropping_in_flight_future_kills_child_with_timeout() {
2142        let workdir = tempfile::tempdir().expect("workdir");
2143        let pid_path = workdir.path().join("pid");
2144        let (_dir, path) = fake_script(&format!(
2145            r#"echo $$ > "{}"; exec sleep 30"#,
2146            pid_path.display()
2147        ));
2148        let claude = Claude::builder()
2149            .binary(&path)
2150            .timeout(Duration::from_secs(120))
2151            .build()
2152            .expect("build");
2153        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2154        assert_pid_killed(pid);
2155    }
2156
2157    // Dropping the future must kill the child's whole process group,
2158    // not just the direct child: the CLI spawns subprocesses for tool
2159    // use, and a cancelled run must leave none of them behind.
2160    #[cfg(feature = "async")]
2161    #[tokio::test]
2162    async fn async_dropping_in_flight_future_kills_process_group() {
2163        let workdir = tempfile::tempdir().expect("workdir");
2164        let pid_path = workdir.path().join("pid");
2165        let gpid_path = workdir.path().join("gpid");
2166        let (_dir, path) = group_script(&pid_path, &gpid_path);
2167        let claude = client(&path);
2168        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2169        assert_pid_killed(pid);
2170        assert_pid_killed(read_pid(&gpid_path));
2171    }
2172
2173    // A fired timeout must also kill the whole group. Before the group
2174    // kill, the timeout path SIGKILLed only the direct child and the
2175    // grandchild survived. Retries on a heavily loaded host, where the
2176    // child can get killed before it records its pids: the kill still
2177    // happened, but there is nothing to observe, so run it again.
2178    #[cfg(feature = "async")]
2179    #[tokio::test]
2180    async fn async_timeout_kills_process_group() {
2181        let mut observed = false;
2182        for _ in 0..5 {
2183            let workdir = tempfile::tempdir().expect("workdir");
2184            let pid_path = workdir.path().join("pid");
2185            let gpid_path = workdir.path().join("gpid");
2186            let (_dir, path) = group_script(&pid_path, &gpid_path);
2187            let claude = Claude::builder()
2188                .binary(&path)
2189                .timeout(Duration::from_millis(1000))
2190                .build()
2191                .expect("build");
2192            let err = run_claude(&claude, vec![]).await.unwrap_err();
2193            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2194            if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2195                assert_pid_killed(pid);
2196                assert_pid_killed(gpid);
2197                observed = true;
2198                break;
2199            }
2200        }
2201        assert!(observed, "child never recorded pids within 5 timeout runs");
2202    }
2203
2204    // With the process-group split opted out, dropping the future still
2205    // kills the direct child via kill_on_drop, but the grandchild is
2206    // deliberately left running: that is the pre-group contract #767
2207    // preserves for terminal-attached hosts, where the terminal is the
2208    // supervisor. The test reaps the survivor itself.
2209    #[cfg(feature = "async")]
2210    #[tokio::test]
2211    async fn async_process_group_opt_out_kills_only_direct_child() {
2212        let workdir = tempfile::tempdir().expect("workdir");
2213        let pid_path = workdir.path().join("pid");
2214        let gpid_path = workdir.path().join("gpid");
2215        let (_dir, path) = group_script(&pid_path, &gpid_path);
2216        let claude = Claude::builder()
2217            .binary(&path)
2218            .process_group(false)
2219            .build()
2220            .expect("build");
2221        let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
2222        assert_pid_killed(pid);
2223
2224        // The grandchild must still be alive: no group kill happened.
2225        let gpid = read_pid(&gpid_path);
2226        let out = std::process::Command::new("ps")
2227            .args(["-o", "stat=", "-p", &gpid.to_string()])
2228            .output()
2229            .expect("run ps");
2230        let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
2231        assert!(
2232            out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
2233            "grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
2234        );
2235
2236        // Reap the deliberate survivor so it does not idle for 300s.
2237        let _ = std::process::Command::new("kill")
2238            .args(["-9", &gpid.to_string()])
2239            .status();
2240    }
2241
2242    /// Fake script that traps SIGTERM, records a marker, and exits
2243    /// cleanly. The marker can only exist if TERM arrived before the
2244    /// KILL: SIGKILL cannot be trapped. `sleep` runs as a background
2245    /// job with `wait` so bash stays alive to handle the signal
2246    /// (an `exec sleep` would replace bash and drop the trap).
2247    fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
2248        fake_script(&format!(
2249            concat!(
2250                "trap 'echo term > \"{m}\"; exit 0' TERM\n",
2251                "sleep 300 &\n",
2252                "wait $!",
2253            ),
2254            m = marker.display(),
2255        ))
2256    }
2257
2258    // With a kill grace configured, a fired timeout SIGTERMs the group
2259    // before the SIGKILL, giving the child a chance to flush. Retries
2260    // on a heavily loaded host where the child is killed before it
2261    // installs its trap: the kill still happened, but there is nothing
2262    // to observe, so run it again.
2263    #[cfg(feature = "async")]
2264    #[tokio::test]
2265    async fn async_timeout_with_grace_delivers_sigterm_first() {
2266        let mut observed = false;
2267        for _ in 0..5 {
2268            let workdir = tempfile::tempdir().expect("workdir");
2269            let marker = workdir.path().join("term-marker");
2270            let (_dir, path) = term_trap_script(&marker);
2271            let claude = Claude::builder()
2272                .binary(&path)
2273                .timeout(Duration::from_millis(500))
2274                .kill_grace(Duration::from_secs(1))
2275                .build()
2276                .expect("build");
2277            let err = run_claude(&claude, vec![]).await.unwrap_err();
2278            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2279            if marker.exists() {
2280                observed = true;
2281                break;
2282            }
2283        }
2284        assert!(observed, "TERM marker never appeared within 5 timeout runs");
2285    }
2286
2287    // Blocking mirror of async_timeout_with_grace_delivers_sigterm_first.
2288    #[cfg(feature = "sync")]
2289    #[test]
2290    fn sync_timeout_with_grace_delivers_sigterm_first() {
2291        let mut observed = false;
2292        for _ in 0..5 {
2293            let workdir = tempfile::tempdir().expect("workdir");
2294            let marker = workdir.path().join("term-marker");
2295            let (_dir, path) = term_trap_script(&marker);
2296            let claude = Claude::builder()
2297                .binary(&path)
2298                .timeout(Duration::from_millis(500))
2299                .kill_grace(Duration::from_secs(1))
2300                .build()
2301                .expect("build");
2302            let err = run_claude_sync(&claude, vec![]).unwrap_err();
2303            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2304            if marker.exists() {
2305                observed = true;
2306                break;
2307            }
2308        }
2309        assert!(observed, "TERM marker never appeared within 5 timeout runs");
2310    }
2311
2312    // Same guarantee for the stdin-prompt path.
2313    #[cfg(feature = "async")]
2314    #[tokio::test]
2315    async fn async_dropping_in_flight_stdin_future_kills_child() {
2316        let workdir = tempfile::tempdir().expect("workdir");
2317        let pid_path = workdir.path().join("pid");
2318        let (_dir, path) = fake_script(&format!(
2319            r#"echo $$ > "{}"; exec sleep 30"#,
2320            pid_path.display()
2321        ));
2322        let claude = client(&path);
2323        let pid = drop_in_flight_and_capture_pid(
2324            run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
2325            &pid_path,
2326        )
2327        .await;
2328        assert_pid_killed(pid);
2329    }
2330
2331    #[cfg(feature = "async")]
2332    #[tokio::test]
2333    async fn async_spawn_failure_maps_io() {
2334        let claude = Claude::builder()
2335            .binary("/nonexistent/definitely/not/here")
2336            .build()
2337            .expect("build");
2338        let err = run_claude(&claude, vec![]).await.unwrap_err();
2339        assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
2340    }
2341
2342    // ---------- sync ----------
2343
2344    #[cfg(feature = "sync")]
2345    #[test]
2346    fn sync_success_maps_output() {
2347        let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
2348        let out = run_claude_sync(&client(&path), vec![]).expect("success");
2349        assert!(out.success);
2350        assert!(out.stdout.contains("hi sync"));
2351    }
2352
2353    #[cfg(feature = "sync")]
2354    #[test]
2355    fn sync_nonzero_exit_maps_command_failed() {
2356        let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
2357        let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
2358        match err {
2359            Error::CommandFailed {
2360                exit_code, stderr, ..
2361            } => {
2362                assert_eq!(exit_code, 3);
2363                assert!(stderr.contains("boom"));
2364            }
2365            other => panic!("expected CommandFailed, got {other:?}"),
2366        }
2367    }
2368
2369    #[cfg(feature = "sync")]
2370    #[test]
2371    fn sync_scrubs_claude_env_vars() {
2372        let (_dir, path) =
2373            fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
2374        set_scrub_vars();
2375        let out = run_claude_sync(&client(&path), vec![]).expect("success");
2376        clear_scrub_vars();
2377        assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
2378        assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
2379    }
2380
2381    #[cfg(feature = "sync")]
2382    #[test]
2383    fn sync_stdin_prompt_round_trips() {
2384        let (_dir, path) = fake_script(r#"cat"#);
2385        let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
2386            .expect("success");
2387        assert!(out.stdout.contains("sync stdin"));
2388    }
2389
2390    // Sync mirror: only `ETXTBSY` is retried; a missing binary must surface
2391    // `NotFound` on the first attempt.
2392    #[cfg(feature = "sync")]
2393    #[test]
2394    fn sync_spawn_retry_passes_through_non_txtbsy_error() {
2395        let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2396        let err =
2397            spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
2398        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2399    }
2400
2401    #[cfg(feature = "sync")]
2402    #[test]
2403    fn sync_output_retry_passes_through_non_txtbsy_error() {
2404        let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
2405        let err = output_retrying_txtbsy_sync_observed(&mut cmd, false, None)
2406            .expect_err("output of missing binary should fail");
2407        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
2408    }
2409
2410    #[cfg(feature = "sync")]
2411    #[test]
2412    fn sync_allow_exit_codes_permits_listed_code() {
2413        let (_dir, path) = fake_script(r#"echo out; exit 2"#);
2414        let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
2415            .expect("allowed code is Ok");
2416        assert!(!out.success);
2417        assert_eq!(out.exit_code, 2);
2418    }
2419
2420    #[cfg(feature = "sync")]
2421    #[test]
2422    fn sync_timeout_fires_on_slow_child() {
2423        let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
2424        let claude = Claude::builder()
2425            .binary(&path)
2426            .timeout(Duration::from_millis(300))
2427            .build()
2428            .expect("build");
2429        let err = run_claude_sync(&claude, vec![]).unwrap_err();
2430        assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2431    }
2432
2433    // Blocking mirror of async_timeout_kills_process_group: a fired
2434    // timeout on the sync path must kill the whole group too. Same
2435    // retry rationale as the async variant.
2436    #[cfg(feature = "sync")]
2437    #[test]
2438    fn sync_timeout_kills_process_group() {
2439        let mut observed = false;
2440        for _ in 0..5 {
2441            let workdir = tempfile::tempdir().expect("workdir");
2442            let pid_path = workdir.path().join("pid");
2443            let gpid_path = workdir.path().join("gpid");
2444            let (_dir, path) = group_script(&pid_path, &gpid_path);
2445            let claude = Claude::builder()
2446                .binary(&path)
2447                .timeout(Duration::from_millis(1000))
2448                .build()
2449                .expect("build");
2450            let err = run_claude_sync(&claude, vec![]).unwrap_err();
2451            assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
2452            if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
2453                assert_pid_killed(pid);
2454                assert_pid_killed(gpid);
2455                observed = true;
2456                break;
2457            }
2458        }
2459        assert!(observed, "child never recorded pids within 5 timeout runs");
2460    }
2461
2462    #[cfg(feature = "sync")]
2463    #[test]
2464    fn sync_timeout_path_returns_output_when_fast() {
2465        let (_dir, path) = fake_script(r#"echo quick"#);
2466        let claude = Claude::builder()
2467            .binary(&path)
2468            .timeout(Duration::from_secs(30))
2469            .build()
2470            .expect("build");
2471        let out = run_claude_sync(&claude, vec![]).expect("success");
2472        assert!(out.stdout.contains("quick"));
2473    }
2474
2475    #[cfg(feature = "sync")]
2476    #[test]
2477    fn sync_stdin_with_timeout_round_trips() {
2478        let (_dir, path) = fake_script(r#"cat"#);
2479        let claude = Claude::builder()
2480            .binary(&path)
2481            .timeout(Duration::from_secs(30))
2482            .build()
2483            .expect("build");
2484        let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
2485            .expect("success");
2486        assert!(out.stdout.contains("sync piped"));
2487    }
2488}