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