Skip to main content

claude_wrapper/
exec.rs

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