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