Skip to main content

claude_wrapper/
exec.rs

1use std::time::Duration;
2
3#[cfg(feature = "async")]
4use tokio::io::AsyncReadExt;
5#[cfg(feature = "async")]
6use tokio::process::Command;
7use tracing::{debug, warn};
8
9use crate::Claude;
10use crate::error::{Error, Result};
11
12/// Raw output from a claude CLI invocation.
13#[derive(Debug, Clone)]
14pub struct CommandOutput {
15    pub stdout: String,
16    pub stderr: String,
17    pub exit_code: i32,
18    pub success: bool,
19}
20
21/// Run a claude command with the given arguments.
22///
23/// If the [`Claude`] client has a retry policy set, transient errors will be
24/// retried according to that policy. A per-command retry policy can be passed
25/// to override the client default.
26#[cfg(feature = "async")]
27pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
28    run_claude_with_retry(claude, args, None).await
29}
30
31/// Run a claude command with an optional per-command retry policy override.
32#[cfg(feature = "async")]
33pub async fn run_claude_with_retry(
34    claude: &Claude,
35    args: Vec<String>,
36    retry_override: Option<&crate::retry::RetryPolicy>,
37) -> Result<CommandOutput> {
38    let policy = retry_override.or(claude.retry_policy.as_ref());
39
40    match policy {
41        Some(policy) => {
42            crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
43        }
44        None => run_claude_once(claude, args).await,
45    }
46}
47
48/// Run claude, writing `stdin_content` to the child's stdin rather than
49/// passing the prompt as argv.
50///
51/// stdin mode does not retry -- the stdin pipe is consumed after the first
52/// attempt and cannot be rewound for a subsequent try.
53#[cfg(feature = "async")]
54pub async fn run_claude_with_stdin_prompt(
55    claude: &Claude,
56    args: Vec<String>,
57    stdin_content: String,
58) -> Result<CommandOutput> {
59    run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
60}
61
62#[cfg(feature = "async")]
63async fn run_claude_with_stdin_prompt_internal(
64    claude: &Claude,
65    args: Vec<String>,
66    stdin_content: String,
67) -> Result<CommandOutput> {
68    let mut command_args = Vec::new();
69    command_args.extend(claude.global_args.clone());
70    command_args.extend(args);
71
72    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
73
74    let binary = &claude.binary;
75    let env = &claude.env;
76    let working_dir = claude.working_dir.as_deref();
77
78    if let Some(timeout) = claude.timeout {
79        run_with_timeout_stdin(
80            binary,
81            &command_args,
82            env,
83            working_dir,
84            timeout,
85            stdin_content,
86        )
87        .await
88    } else {
89        run_internal_stdin(binary, &command_args, env, working_dir, stdin_content).await
90    }
91}
92
93#[cfg(feature = "async")]
94async fn run_internal_stdin(
95    binary: &std::path::Path,
96    args: &[String],
97    env: &std::collections::HashMap<String, String>,
98    working_dir: Option<&std::path::Path>,
99    stdin_content: String,
100) -> Result<CommandOutput> {
101    use tokio::io::AsyncWriteExt;
102
103    let mut cmd = Command::new(binary);
104    cmd.args(args);
105    cmd.stdin(std::process::Stdio::piped());
106    cmd.stdout(std::process::Stdio::piped());
107    cmd.stderr(std::process::Stdio::piped());
108    cmd.env_remove("CLAUDECODE");
109    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
110
111    if let Some(dir) = working_dir {
112        cmd.current_dir(dir);
113    }
114
115    for (key, value) in env {
116        cmd.env(key, value);
117    }
118
119    let mut child = cmd.spawn().map_err(|e| Error::Io {
120        message: format!("failed to spawn claude: {e}"),
121        source: e,
122        working_dir: working_dir.map(|p| p.to_path_buf()),
123    })?;
124
125    // Write the prompt to stdin, then drop the handle so the child sees EOF.
126    if let Some(mut stdin) = child.stdin.take() {
127        stdin
128            .write_all(stdin_content.as_bytes())
129            .await
130            .map_err(|e| Error::Io {
131                message: format!("failed to write to claude stdin: {e}"),
132                source: e,
133                working_dir: working_dir.map(|p| p.to_path_buf()),
134            })?;
135        // Drop stdin so the child sees EOF.
136    }
137
138    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
139    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
140
141    let (status, stdout_str, stderr_str) = tokio::join!(
142        child.wait(),
143        drain(&mut stdout_handle),
144        drain(&mut stderr_handle),
145    );
146
147    let status = status.map_err(|e| Error::Io {
148        message: "failed to wait for claude process".to_string(),
149        source: e,
150        working_dir: working_dir.map(|p| p.to_path_buf()),
151    })?;
152
153    let exit_code = status.code().unwrap_or(-1);
154
155    if !status.success() {
156        return Err(Error::from_command_failure(
157            format!("{} {}", binary.display(), args.join(" ")),
158            exit_code,
159            stdout_str,
160            stderr_str,
161            working_dir.map(|p| p.to_path_buf()),
162        ));
163    }
164
165    Ok(CommandOutput {
166        stdout: stdout_str,
167        stderr: stderr_str,
168        exit_code,
169        success: true,
170    })
171}
172
173#[cfg(feature = "async")]
174async fn run_with_timeout_stdin(
175    binary: &std::path::Path,
176    args: &[String],
177    env: &std::collections::HashMap<String, String>,
178    working_dir: Option<&std::path::Path>,
179    timeout: Duration,
180    stdin_content: String,
181) -> Result<CommandOutput> {
182    use tokio::io::AsyncWriteExt;
183
184    let mut cmd = Command::new(binary);
185    cmd.args(args);
186    cmd.stdin(std::process::Stdio::piped());
187    cmd.stdout(std::process::Stdio::piped());
188    cmd.stderr(std::process::Stdio::piped());
189    cmd.env_remove("CLAUDECODE");
190    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
191
192    if let Some(dir) = working_dir {
193        cmd.current_dir(dir);
194    }
195
196    for (key, value) in env {
197        cmd.env(key, value);
198    }
199
200    let mut child = cmd.spawn().map_err(|e| Error::Io {
201        message: format!("failed to spawn claude: {e}"),
202        source: e,
203        working_dir: working_dir.map(|p| p.to_path_buf()),
204    })?;
205
206    // Write the prompt to stdin, then drop the handle so the child sees EOF.
207    if let Some(mut stdin) = child.stdin.take() {
208        stdin
209            .write_all(stdin_content.as_bytes())
210            .await
211            .map_err(|e| Error::Io {
212                message: format!("failed to write to claude stdin: {e}"),
213                source: e,
214                working_dir: working_dir.map(|p| p.to_path_buf()),
215            })?;
216        // Drop stdin so the child sees EOF.
217    }
218
219    let mut stdout_handle = child.stdout.take().expect("stdout was piped");
220    let mut stderr_handle = child.stderr.take().expect("stderr was piped");
221
222    let wait_and_drain = async {
223        let (status, stdout_str, stderr_str) = tokio::join!(
224            child.wait(),
225            drain(&mut stdout_handle),
226            drain(&mut stderr_handle),
227        );
228        (status, stdout_str, stderr_str)
229    };
230
231    match tokio::time::timeout(timeout, wait_and_drain).await {
232        Ok((Ok(status), stdout, stderr)) => {
233            let exit_code = status.code().unwrap_or(-1);
234
235            if !status.success() {
236                return Err(Error::from_command_failure(
237                    format!("{} {}", binary.display(), args.join(" ")),
238                    exit_code,
239                    stdout,
240                    stderr,
241                    working_dir.map(|p| p.to_path_buf()),
242                ));
243            }
244
245            Ok(CommandOutput {
246                stdout,
247                stderr,
248                exit_code,
249                success: true,
250            })
251        }
252        Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
253            message: "failed to wait for claude process".to_string(),
254            source: e,
255            working_dir: working_dir.map(|p| p.to_path_buf()),
256        }),
257        Err(_) => {
258            let _ = child.kill().await;
259            let drain_budget = Duration::from_millis(200);
260            let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout_handle))
261                .await
262                .unwrap_or_default();
263            let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr_handle))
264                .await
265                .unwrap_or_default();
266            if !stdout_str.is_empty() || !stderr_str.is_empty() {
267                warn!(
268                    stdout = %stdout_str,
269                    stderr = %stderr_str,
270                    "partial output from timed-out process",
271                );
272            }
273            Err(Error::Timeout {
274                timeout_seconds: timeout.as_secs(),
275            })
276        }
277    }
278}
279
280#[cfg(feature = "async")]
281async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
282    let mut command_args = Vec::new();
283
284    // Global args first (before subcommand)
285    command_args.extend(claude.global_args.clone());
286
287    // Then command-specific args
288    command_args.extend(args);
289
290    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
291
292    let output = if let Some(timeout) = claude.timeout {
293        run_with_timeout(
294            &claude.binary,
295            &command_args,
296            &claude.env,
297            claude.working_dir.as_deref(),
298            timeout,
299        )
300        .await?
301    } else {
302        run_internal(
303            &claude.binary,
304            &command_args,
305            &claude.env,
306            claude.working_dir.as_deref(),
307        )
308        .await?
309    };
310
311    Ok(output)
312}
313
314/// Run a claude command and allow specific non-zero exit codes.
315#[cfg(feature = "async")]
316pub async fn run_claude_allow_exit_codes(
317    claude: &Claude,
318    args: Vec<String>,
319    allowed_codes: &[i32],
320) -> Result<CommandOutput> {
321    let output = run_claude(claude, args).await;
322
323    match output {
324        Err(Error::CommandFailed {
325            exit_code,
326            stdout,
327            stderr,
328            ..
329        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
330            stdout,
331            stderr,
332            exit_code,
333            success: false,
334        }),
335        other => other,
336    }
337}
338
339#[cfg(feature = "async")]
340async fn run_internal(
341    binary: &std::path::Path,
342    args: &[String],
343    env: &std::collections::HashMap<String, String>,
344    working_dir: Option<&std::path::Path>,
345) -> Result<CommandOutput> {
346    let mut cmd = Command::new(binary);
347    cmd.args(args);
348
349    // Prevent child from inheriting/blocking on parent's stdin.
350    cmd.stdin(std::process::Stdio::null());
351
352    // Remove Claude Code env vars to prevent nested session detection
353    cmd.env_remove("CLAUDECODE");
354    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
355
356    if let Some(dir) = working_dir {
357        cmd.current_dir(dir);
358    }
359
360    for (key, value) in env {
361        cmd.env(key, value);
362    }
363
364    let output = cmd.output().await.map_err(|e| Error::Io {
365        message: format!("failed to spawn claude: {e}"),
366        source: e,
367        working_dir: working_dir.map(|p| p.to_path_buf()),
368    })?;
369
370    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
371    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
372    let exit_code = output.status.code().unwrap_or(-1);
373
374    if !output.status.success() {
375        return Err(Error::from_command_failure(
376            format!("{} {}", binary.display(), args.join(" ")),
377            exit_code,
378            stdout,
379            stderr,
380            working_dir.map(|p| p.to_path_buf()),
381        ));
382    }
383
384    Ok(CommandOutput {
385        stdout,
386        stderr,
387        exit_code,
388        success: true,
389    })
390}
391
392/// Run a command with a timeout, killing and reaping the child on expiration.
393///
394/// Spawns the child explicitly (rather than wrapping `Command::output()` in a
395/// `tokio::time::timeout`) so that we retain the handle and can SIGKILL the
396/// child and wait for it when the timeout fires. Stdout and stderr are drained
397/// concurrently with `child.wait()` via `tokio::join!` so neither pipe buffer
398/// can fill up and deadlock the child.
399///
400/// On timeout, partial stdout/stderr captured before the kill is logged at
401/// warn level; the returned `Error::Timeout` itself does not carry the
402/// partial output.
403#[cfg(feature = "async")]
404async fn run_with_timeout(
405    binary: &std::path::Path,
406    args: &[String],
407    env: &std::collections::HashMap<String, String>,
408    working_dir: Option<&std::path::Path>,
409    timeout: Duration,
410) -> Result<CommandOutput> {
411    let mut cmd = Command::new(binary);
412    cmd.args(args);
413    cmd.stdin(std::process::Stdio::null());
414    cmd.stdout(std::process::Stdio::piped());
415    cmd.stderr(std::process::Stdio::piped());
416    cmd.env_remove("CLAUDECODE");
417    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
418
419    if let Some(dir) = working_dir {
420        cmd.current_dir(dir);
421    }
422
423    for (key, value) in env {
424        cmd.env(key, value);
425    }
426
427    let mut child = cmd.spawn().map_err(|e| Error::Io {
428        message: format!("failed to spawn claude: {e}"),
429        source: e,
430        working_dir: working_dir.map(|p| p.to_path_buf()),
431    })?;
432
433    let mut stdout = child.stdout.take().expect("stdout was piped");
434    let mut stderr = child.stderr.take().expect("stderr was piped");
435
436    // Drain stdout and stderr concurrently with the process wait so
437    // neither pipe buffer can fill up and deadlock the child.
438    // tokio::join! polls all three on the same task; no tokio::spawn
439    // (and therefore no `rt` feature) required.
440    let wait_and_drain = async {
441        let (status, stdout_str, stderr_str) =
442            tokio::join!(child.wait(), drain(&mut stdout), drain(&mut stderr));
443        (status, stdout_str, stderr_str)
444    };
445
446    match tokio::time::timeout(timeout, wait_and_drain).await {
447        Ok((Ok(status), stdout, stderr)) => {
448            let exit_code = status.code().unwrap_or(-1);
449
450            if !status.success() {
451                return Err(Error::from_command_failure(
452                    format!("{} {}", binary.display(), args.join(" ")),
453                    exit_code,
454                    stdout,
455                    stderr,
456                    working_dir.map(|p| p.to_path_buf()),
457                ));
458            }
459
460            Ok(CommandOutput {
461                stdout,
462                stderr,
463                exit_code,
464                success: true,
465            })
466        }
467        Ok((Err(e), _stdout, _stderr)) => Err(Error::Io {
468            message: "failed to wait for claude process".to_string(),
469            source: e,
470            working_dir: working_dir.map(|p| p.to_path_buf()),
471        }),
472        Err(_) => {
473            // Timeout: kill the child (reaps via start_kill + wait).
474            // Note that kill() only targets the direct child; if it has
475            // spawned its own subprocesses that are holding our pipe
476            // fds open, draining would block. Cap the drain with a
477            // short deadline so the timeout error returns promptly.
478            let _ = child.kill().await;
479            let drain_budget = Duration::from_millis(200);
480            let stdout_str = tokio::time::timeout(drain_budget, drain(&mut stdout))
481                .await
482                .unwrap_or_default();
483            let stderr_str = tokio::time::timeout(drain_budget, drain(&mut stderr))
484                .await
485                .unwrap_or_default();
486            if !stdout_str.is_empty() || !stderr_str.is_empty() {
487                warn!(
488                    stdout = %stdout_str,
489                    stderr = %stderr_str,
490                    "partial output from timed-out process",
491                );
492            }
493            Err(Error::Timeout {
494                timeout_seconds: timeout.as_secs(),
495            })
496        }
497    }
498}
499
500#[cfg(feature = "async")]
501async fn drain<R: AsyncReadExt + Unpin>(reader: &mut R) -> String {
502    let mut buf = Vec::new();
503    let _ = reader.read_to_end(&mut buf).await;
504    String::from_utf8_lossy(&buf).into_owned()
505}
506
507// ---------- sync twins ----------
508
509/// Blocking mirror of [`run_claude`]. Available with the `sync` feature.
510#[cfg(feature = "sync")]
511pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
512    run_claude_with_retry_sync(claude, args, None)
513}
514
515/// Blocking mirror of [`run_claude_with_retry`].
516#[cfg(feature = "sync")]
517pub fn run_claude_with_retry_sync(
518    claude: &Claude,
519    args: Vec<String>,
520    retry_override: Option<&crate::retry::RetryPolicy>,
521) -> Result<CommandOutput> {
522    let policy = retry_override.or(claude.retry_policy.as_ref());
523
524    match policy {
525        Some(policy) => {
526            crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
527        }
528        None => run_claude_once_sync(claude, args),
529    }
530}
531
532/// Blocking mirror of [`run_claude_with_stdin_prompt`].
533///
534/// stdin mode does not retry -- the stdin pipe is consumed after the first
535/// attempt and cannot be rewound.
536#[cfg(feature = "sync")]
537pub fn run_claude_with_stdin_prompt_sync(
538    claude: &Claude,
539    args: Vec<String>,
540    stdin_content: String,
541) -> Result<CommandOutput> {
542    let mut command_args = Vec::new();
543    command_args.extend(claude.global_args.clone());
544    command_args.extend(args);
545
546    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
547
548    if let Some(timeout) = claude.timeout {
549        run_with_timeout_stdin_sync(
550            &claude.binary,
551            &command_args,
552            &claude.env,
553            claude.working_dir.as_deref(),
554            timeout,
555            stdin_content,
556        )
557    } else {
558        run_internal_stdin_sync(
559            &claude.binary,
560            &command_args,
561            &claude.env,
562            claude.working_dir.as_deref(),
563            stdin_content,
564        )
565    }
566}
567
568#[cfg(feature = "sync")]
569fn run_internal_stdin_sync(
570    binary: &std::path::Path,
571    args: &[String],
572    env: &std::collections::HashMap<String, String>,
573    working_dir: Option<&std::path::Path>,
574    stdin_content: String,
575) -> Result<CommandOutput> {
576    use std::io::Write;
577    use std::process::{Command as StdCommand, Stdio};
578
579    let mut cmd = StdCommand::new(binary);
580    cmd.args(args);
581    cmd.stdin(Stdio::piped());
582    cmd.stdout(Stdio::piped());
583    cmd.stderr(Stdio::piped());
584    cmd.env_remove("CLAUDECODE");
585    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
586
587    if let Some(dir) = working_dir {
588        cmd.current_dir(dir);
589    }
590
591    for (key, value) in env {
592        cmd.env(key, value);
593    }
594
595    let mut child = cmd.spawn().map_err(|e| Error::Io {
596        message: format!("failed to spawn claude: {e}"),
597        source: e,
598        working_dir: working_dir.map(|p| p.to_path_buf()),
599    })?;
600
601    // Write the prompt to stdin, then drop the handle so the child sees EOF.
602    if let Some(mut stdin) = child.stdin.take() {
603        stdin
604            .write_all(stdin_content.as_bytes())
605            .map_err(|e| Error::Io {
606                message: format!("failed to write to claude stdin: {e}"),
607                source: e,
608                working_dir: working_dir.map(|p| p.to_path_buf()),
609            })?;
610        stdin.flush().map_err(|e| Error::Io {
611            message: format!("failed to flush claude stdin: {e}"),
612            source: e,
613            working_dir: working_dir.map(|p| p.to_path_buf()),
614        })?;
615        // Drop stdin so the child sees EOF.
616    }
617
618    let output = child.wait_with_output().map_err(|e| Error::Io {
619        message: "failed to wait for claude process".to_string(),
620        source: e,
621        working_dir: working_dir.map(|p| p.to_path_buf()),
622    })?;
623
624    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
625    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
626    let exit_code = output.status.code().unwrap_or(-1);
627
628    if !output.status.success() {
629        return Err(Error::from_command_failure(
630            format!("{} {}", binary.display(), args.join(" ")),
631            exit_code,
632            stdout,
633            stderr,
634            working_dir.map(|p| p.to_path_buf()),
635        ));
636    }
637
638    Ok(CommandOutput {
639        stdout,
640        stderr,
641        exit_code,
642        success: true,
643    })
644}
645
646#[cfg(feature = "sync")]
647fn run_with_timeout_stdin_sync(
648    binary: &std::path::Path,
649    args: &[String],
650    env: &std::collections::HashMap<String, String>,
651    working_dir: Option<&std::path::Path>,
652    timeout: Duration,
653    stdin_content: String,
654) -> Result<CommandOutput> {
655    use std::io::Write;
656    use std::process::{Command as StdCommand, Stdio};
657    use std::thread;
658    use wait_timeout::ChildExt;
659
660    let mut cmd = StdCommand::new(binary);
661    cmd.args(args);
662    cmd.stdin(Stdio::piped());
663    cmd.stdout(Stdio::piped());
664    cmd.stderr(Stdio::piped());
665    cmd.env_remove("CLAUDECODE");
666    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
667
668    if let Some(dir) = working_dir {
669        cmd.current_dir(dir);
670    }
671
672    for (key, value) in env {
673        cmd.env(key, value);
674    }
675
676    let mut child = cmd.spawn().map_err(|e| Error::Io {
677        message: format!("failed to spawn claude: {e}"),
678        source: e,
679        working_dir: working_dir.map(|p| p.to_path_buf()),
680    })?;
681
682    // Write the prompt to stdin, then drop the handle so the child sees EOF.
683    if let Some(mut stdin) = child.stdin.take() {
684        stdin
685            .write_all(stdin_content.as_bytes())
686            .map_err(|e| Error::Io {
687                message: format!("failed to write to claude stdin: {e}"),
688                source: e,
689                working_dir: working_dir.map(|p| p.to_path_buf()),
690            })?;
691        stdin.flush().map_err(|e| Error::Io {
692            message: format!("failed to flush claude stdin: {e}"),
693            source: e,
694            working_dir: working_dir.map(|p| p.to_path_buf()),
695        })?;
696        // Drop stdin so the child sees EOF.
697    }
698
699    let stdout = child.stdout.take().expect("stdout was piped");
700    let stderr = child.stderr.take().expect("stderr was piped");
701
702    let stdout_thread = thread::spawn(move || drain_sync(stdout));
703    let stderr_thread = thread::spawn(move || drain_sync(stderr));
704
705    match child.wait_timeout(timeout).map_err(|e| Error::Io {
706        message: "failed to wait for claude process".to_string(),
707        source: e,
708        working_dir: working_dir.map(|p| p.to_path_buf()),
709    })? {
710        Some(status) => {
711            let stdout = stdout_thread.join().unwrap_or_default();
712            let stderr = stderr_thread.join().unwrap_or_default();
713            let exit_code = status.code().unwrap_or(-1);
714
715            if !status.success() {
716                return Err(Error::from_command_failure(
717                    format!("{} {}", binary.display(), args.join(" ")),
718                    exit_code,
719                    stdout,
720                    stderr,
721                    working_dir.map(|p| p.to_path_buf()),
722                ));
723            }
724
725            Ok(CommandOutput {
726                stdout,
727                stderr,
728                exit_code,
729                success: true,
730            })
731        }
732        None => {
733            let _ = child.kill();
734            let _ = child.wait();
735            let (stdout_str, stderr_str) =
736                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
737            if !stdout_str.is_empty() || !stderr_str.is_empty() {
738                warn!(
739                    stdout = %stdout_str,
740                    stderr = %stderr_str,
741                    "partial output from timed-out process",
742                );
743            }
744            Err(Error::Timeout {
745                timeout_seconds: timeout.as_secs(),
746            })
747        }
748    }
749}
750
751#[cfg(feature = "sync")]
752fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
753    let mut command_args = Vec::new();
754    command_args.extend(claude.global_args.clone());
755    command_args.extend(args);
756
757    debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
758
759    if let Some(timeout) = claude.timeout {
760        run_with_timeout_sync(
761            &claude.binary,
762            &command_args,
763            &claude.env,
764            claude.working_dir.as_deref(),
765            timeout,
766        )
767    } else {
768        run_internal_sync(
769            &claude.binary,
770            &command_args,
771            &claude.env,
772            claude.working_dir.as_deref(),
773        )
774    }
775}
776
777/// Blocking mirror of [`run_claude_allow_exit_codes`].
778#[cfg(feature = "sync")]
779pub fn run_claude_allow_exit_codes_sync(
780    claude: &Claude,
781    args: Vec<String>,
782    allowed_codes: &[i32],
783) -> Result<CommandOutput> {
784    match run_claude_sync(claude, args) {
785        Err(Error::CommandFailed {
786            exit_code,
787            stdout,
788            stderr,
789            ..
790        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
791            stdout,
792            stderr,
793            exit_code,
794            success: false,
795        }),
796        other => other,
797    }
798}
799
800#[cfg(feature = "sync")]
801fn run_internal_sync(
802    binary: &std::path::Path,
803    args: &[String],
804    env: &std::collections::HashMap<String, String>,
805    working_dir: Option<&std::path::Path>,
806) -> Result<CommandOutput> {
807    use std::process::{Command as StdCommand, Stdio};
808
809    let mut cmd = StdCommand::new(binary);
810    cmd.args(args);
811    cmd.stdin(Stdio::null());
812    cmd.env_remove("CLAUDECODE");
813    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
814
815    if let Some(dir) = working_dir {
816        cmd.current_dir(dir);
817    }
818
819    for (key, value) in env {
820        cmd.env(key, value);
821    }
822
823    let output = cmd.output().map_err(|e| Error::Io {
824        message: format!("failed to spawn claude: {e}"),
825        source: e,
826        working_dir: working_dir.map(|p| p.to_path_buf()),
827    })?;
828
829    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
830    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
831    let exit_code = output.status.code().unwrap_or(-1);
832
833    if !output.status.success() {
834        return Err(Error::from_command_failure(
835            format!("{} {}", binary.display(), args.join(" ")),
836            exit_code,
837            stdout,
838            stderr,
839            working_dir.map(|p| p.to_path_buf()),
840        ));
841    }
842
843    Ok(CommandOutput {
844        stdout,
845        stderr,
846        exit_code,
847        success: true,
848    })
849}
850
851/// Blocking run with a timeout. Mirrors [`run_with_timeout`]: spawns
852/// the child, drains stdout/stderr on dedicated threads so neither
853/// pipe buffer can fill up while we wait, then uses
854/// [`wait_timeout::ChildExt::wait_timeout`] to enforce the deadline.
855/// On timeout, the child is SIGKILLed and reaped; partial output is
856/// logged at warn but the returned [`Error::Timeout`] does not carry it.
857#[cfg(feature = "sync")]
858fn run_with_timeout_sync(
859    binary: &std::path::Path,
860    args: &[String],
861    env: &std::collections::HashMap<String, String>,
862    working_dir: Option<&std::path::Path>,
863    timeout: Duration,
864) -> Result<CommandOutput> {
865    use std::process::{Command as StdCommand, Stdio};
866    use std::thread;
867    use wait_timeout::ChildExt;
868
869    let mut cmd = StdCommand::new(binary);
870    cmd.args(args);
871    cmd.stdin(Stdio::null());
872    cmd.stdout(Stdio::piped());
873    cmd.stderr(Stdio::piped());
874    cmd.env_remove("CLAUDECODE");
875    cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
876
877    if let Some(dir) = working_dir {
878        cmd.current_dir(dir);
879    }
880
881    for (key, value) in env {
882        cmd.env(key, value);
883    }
884
885    let mut child = cmd.spawn().map_err(|e| Error::Io {
886        message: format!("failed to spawn claude: {e}"),
887        source: e,
888        working_dir: working_dir.map(|p| p.to_path_buf()),
889    })?;
890
891    // Detach stdout/stderr onto their own threads so neither can block
892    // the child by filling its pipe buffer. Each thread owns its half
893    // and drops it on completion, which closes the parent's fd and
894    // lets read_to_end() return EOF once the child exits.
895    let stdout = child.stdout.take().expect("stdout was piped");
896    let stderr = child.stderr.take().expect("stderr was piped");
897
898    let stdout_thread = thread::spawn(move || drain_sync(stdout));
899    let stderr_thread = thread::spawn(move || drain_sync(stderr));
900
901    match child.wait_timeout(timeout).map_err(|e| Error::Io {
902        message: "failed to wait for claude process".to_string(),
903        source: e,
904        working_dir: working_dir.map(|p| p.to_path_buf()),
905    })? {
906        Some(status) => {
907            let stdout = stdout_thread.join().unwrap_or_default();
908            let stderr = stderr_thread.join().unwrap_or_default();
909            let exit_code = status.code().unwrap_or(-1);
910
911            if !status.success() {
912                return Err(Error::from_command_failure(
913                    format!("{} {}", binary.display(), args.join(" ")),
914                    exit_code,
915                    stdout,
916                    stderr,
917                    working_dir.map(|p| p.to_path_buf()),
918                ));
919            }
920
921            Ok(CommandOutput {
922                stdout,
923                stderr,
924                exit_code,
925                success: true,
926            })
927        }
928        None => {
929            // Timeout: SIGKILL and reap. If the child has spawned
930            // subprocesses that inherited our pipe fds, the drain
931            // threads can block indefinitely; cap the join with a
932            // short budget so the timeout error returns promptly.
933            let _ = child.kill();
934            let _ = child.wait();
935
936            let (stdout_str, stderr_str) =
937                join_with_deadline(stdout_thread, stderr_thread, Duration::from_millis(200));
938
939            if !stdout_str.is_empty() || !stderr_str.is_empty() {
940                warn!(
941                    stdout = %stdout_str,
942                    stderr = %stderr_str,
943                    "partial output from timed-out process",
944                );
945            }
946
947            Err(Error::Timeout {
948                timeout_seconds: timeout.as_secs(),
949            })
950        }
951    }
952}
953
954#[cfg(feature = "sync")]
955fn drain_sync<R: std::io::Read>(mut reader: R) -> String {
956    let mut buf = Vec::new();
957    let _ = reader.read_to_end(&mut buf);
958    String::from_utf8_lossy(&buf).into_owned()
959}
960
961/// Wait for both drain threads to finish, returning "" for any that
962/// miss the deadline. Threads aren't cancellable in std; if the child's
963/// subprocesses are still holding a pipe fd open after kill(), the
964/// drain thread leaks. That's a pathological case; the common timeout
965/// path with a responsive child joins in microseconds.
966#[cfg(feature = "sync")]
967fn join_with_deadline(
968    stdout_thread: std::thread::JoinHandle<String>,
969    stderr_thread: std::thread::JoinHandle<String>,
970    budget: Duration,
971) -> (String, String) {
972    use std::sync::mpsc;
973    use std::thread;
974
975    let (tx, rx) = mpsc::channel::<(&'static str, String)>();
976
977    let tx_out = tx.clone();
978    let tx_err = tx;
979
980    thread::spawn(move || {
981        let s = stdout_thread.join().unwrap_or_default();
982        let _ = tx_out.send(("stdout", s));
983    });
984    thread::spawn(move || {
985        let s = stderr_thread.join().unwrap_or_default();
986        let _ = tx_err.send(("stderr", s));
987    });
988
989    let mut stdout = String::new();
990    let mut stderr = String::new();
991    let deadline = std::time::Instant::now() + budget;
992
993    for _ in 0..2 {
994        let now = std::time::Instant::now();
995        if now >= deadline {
996            break;
997        }
998        match rx.recv_timeout(deadline - now) {
999            Ok(("stdout", s)) => stdout = s,
1000            Ok(("stderr", s)) => stderr = s,
1001            Ok(_) => unreachable!(),
1002            Err(_) => break,
1003        }
1004    }
1005
1006    (stdout, stderr)
1007}