#[cfg(any(feature = "async", feature = "sync"))]
use std::time::Duration;
#[cfg(feature = "async")]
use tokio::io::AsyncReadExt;
#[cfg(feature = "async")]
use tokio::process::Command;
#[cfg(any(feature = "async", feature = "sync"))]
use tracing::{debug, warn};
use crate::Claude;
#[cfg(any(feature = "async", feature = "sync"))]
use crate::error::{Error, Result};
pub(crate) fn full_command_args(claude: &Claude, args: Vec<String>) -> Vec<String> {
let mut command_args = claude.global_args.clone();
command_args.extend(args);
command_args
}
#[cfg(any(feature = "async", feature = "sync"))]
pub(crate) fn apply_child_environment(
cmd: &mut std::process::Command,
clear_env: bool,
env: &std::collections::HashMap<String, String>,
) {
if clear_env {
cmd.env_clear();
}
cmd.env_remove("CLAUDECODE");
cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
cmd.envs(env);
}
#[cfg(any(feature = "async", feature = "sync"))]
pub(crate) fn span_command(args: &[String]) -> &str {
args.first().map(String::as_str).unwrap_or("<none>")
}
#[cfg(any(feature = "async", feature = "sync"))]
pub(crate) fn exec_span(claude: &Claude, args: &[String], mode: &'static str) -> tracing::Span {
tracing::debug_span!(
"claude.exec",
command = span_command(args),
mode,
binary = %claude.binary.display(),
cwd = claude.working_dir.as_deref().map(|d| d.display().to_string()),
exit_code = tracing::field::Empty,
duration_ms = tracing::field::Empty,
)
}
#[cfg(any(feature = "async", feature = "sync"))]
pub(crate) fn record_exec_outcome(
span: &tracing::Span,
exit_code: i32,
started: std::time::Instant,
) {
span.record("exit_code", exit_code);
span.record("duration_ms", started.elapsed().as_millis() as u64);
}
#[derive(Debug, Clone)]
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub success: bool,
}
#[cfg(any(feature = "async", feature = "sync"))]
#[derive(Clone, Copy)]
pub(crate) struct SpawnPolicy<'a> {
pub(crate) process_group: bool,
pub(crate) kill_grace: Option<Duration>,
pub(crate) output_limit: Option<usize>,
pub(crate) die_with_parent: bool,
pub(crate) on_spawn: Option<&'a crate::SpawnObserver>,
}
#[cfg(any(feature = "async", feature = "sync"))]
impl SpawnPolicy<'_> {
pub(crate) fn of(claude: &Claude) -> SpawnPolicy<'_> {
SpawnPolicy {
process_group: claude.process_group,
kill_grace: claude.kill_grace,
output_limit: claude.output_limit,
die_with_parent: claude.die_with_parent,
on_spawn: claude.on_spawn.as_ref(),
}
}
}
#[cfg(any(feature = "async", feature = "sync"))]
pub(crate) fn arm_and_notify(
process_group: bool,
pid: Option<u32>,
on_spawn: Option<&crate::SpawnObserver>,
) -> GroupKillGuard {
if let (Some(pid), Some(observer)) = (pid, on_spawn) {
observer(crate::SpawnInfo {
pid,
pgid: process_group.then_some(pid),
});
}
GroupKillGuard::new_if(process_group, pid)
}
#[cfg(any(feature = "async", feature = "sync"))]
pub(crate) struct GroupKillGuard {
#[cfg(unix)]
pgid: Option<i32>,
}
#[cfg(any(feature = "async", feature = "sync"))]
impl GroupKillGuard {
pub(crate) fn new_if(enabled: bool, pid: Option<u32>) -> Self {
Self::new(if enabled { pid } else { None })
}
pub(crate) fn new(pid: Option<u32>) -> Self {
#[cfg(unix)]
{
Self {
pgid: pid.and_then(|p| i32::try_from(p).ok()),
}
}
#[cfg(not(unix))]
{
let _ = pid;
Self {}
}
}
pub(crate) fn disarm(&mut self) {
#[cfg(unix)]
{
self.pgid = None;
}
}
pub(crate) fn is_armed(&self) -> bool {
#[cfg(unix)]
{
self.pgid.is_some()
}
#[cfg(not(unix))]
{
false
}
}
pub(crate) fn term_now(&self) {
#[cfg(unix)]
if let Some(pgid) = self.pgid {
let _ = unsafe { libc::killpg(pgid, libc::SIGTERM) };
}
}
pub(crate) fn kill_now(&mut self) {
#[cfg(unix)]
if let Some(pgid) = self.pgid.take() {
let _ = unsafe { libc::killpg(pgid, libc::SIGKILL) };
}
}
}
#[cfg(any(feature = "async", feature = "sync"))]
impl Drop for GroupKillGuard {
fn drop(&mut self) {
self.kill_now();
}
}
#[must_use]
pub const fn die_with_parent_supported() -> bool {
cfg!(target_os = "linux")
}
#[cfg(all(unix, any(feature = "async", feature = "sync")))]
fn pdeathsig_hook() -> impl FnMut() -> std::io::Result<()> + Send + Sync + 'static {
let parent = std::process::id();
move || {
#[cfg(target_os = "linux")]
{
unsafe {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
return Err(std::io::Error::last_os_error());
}
if libc::getppid() as u32 != parent {
libc::_exit(1);
}
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = parent;
}
Ok(())
}
}
#[cfg(feature = "async")]
pub(crate) fn apply_die_with_parent(cmd: &mut Command, enabled: bool) {
#[cfg(unix)]
if enabled {
unsafe {
cmd.pre_exec(pdeathsig_hook());
}
}
#[cfg(not(unix))]
{
let _ = (cmd, enabled);
}
}
#[cfg(feature = "sync")]
pub(crate) fn apply_die_with_parent_sync(cmd: &mut std::process::Command, enabled: bool) {
#[cfg(unix)]
if enabled {
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(pdeathsig_hook());
}
}
#[cfg(not(unix))]
{
let _ = (cmd, enabled);
}
}
#[cfg(feature = "async")]
pub(crate) fn apply_process_group(cmd: &mut Command, enabled: bool) {
#[cfg(unix)]
if enabled {
cmd.process_group(0);
}
#[cfg(not(unix))]
{
let _ = (cmd, enabled);
}
}
#[cfg(feature = "sync")]
pub(crate) fn apply_process_group_sync(cmd: &mut std::process::Command, enabled: bool) {
#[cfg(unix)]
if enabled {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(not(unix))]
{
let _ = (cmd, enabled);
}
}
#[cfg(feature = "async")]
pub(crate) async fn kill_group_with_grace(group: &mut GroupKillGuard, grace: Option<Duration>) {
if let Some(g) = grace
&& !g.is_zero()
&& group.is_armed()
{
group.term_now();
tokio::time::sleep(g).await;
}
group.kill_now();
}
#[cfg(feature = "async")]
enum StopReason {
Timeout { timeout_seconds: u64 },
Cancelled,
}
#[cfg(feature = "async")]
impl StopReason {
fn into_error(self) -> Error {
match self {
Self::Timeout { timeout_seconds } => Error::Timeout { timeout_seconds },
Self::Cancelled => Error::Cancelled,
}
}
}
#[cfg(feature = "async")]
fn never_stop() -> StopFuture<'static> {
Box::pin(std::future::pending())
}
#[cfg(feature = "async")]
type StopFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = StopReason> + Send + 'a>>;
#[cfg(feature = "async")]
fn timeout_stop(timeout: Duration) -> StopFuture<'static> {
Box::pin(async move {
tokio::time::sleep(timeout).await;
StopReason::Timeout {
timeout_seconds: timeout.as_secs(),
}
})
}
#[cfg(feature = "async")]
fn cancellation_or_timeout<'a, C>(cancel: C, timeout: Option<Duration>) -> StopFuture<'a>
where
C: std::future::Future<Output = ()> + Send + 'a,
{
Box::pin(async move {
match timeout {
Some(timeout) => tokio::select! {
() = cancel => StopReason::Cancelled,
() = tokio::time::sleep(timeout) => StopReason::Timeout {
timeout_seconds: timeout.as_secs(),
},
},
None => {
cancel.await;
StopReason::Cancelled
}
}
})
}
#[cfg(feature = "async")]
pub(crate) async fn stop_and_reap(
child: &mut tokio::process::Child,
group: &mut GroupKillGuard,
grace: Option<Duration>,
working_dir: Option<&std::path::Path>,
) -> Result<()> {
kill_group_with_grace(group, grace).await;
if child
.try_wait()
.map_err(|e| wait_error(e, working_dir))?
.is_none()
&& let Err(error) = child.start_kill()
&& error.kind() != std::io::ErrorKind::InvalidInput
{
return Err(wait_error(error, working_dir));
}
child.wait().await.map_err(|e| wait_error(e, working_dir))?;
Ok(())
}
#[cfg(feature = "async")]
fn wait_error(error: std::io::Error, working_dir: Option<&std::path::Path>) -> Error {
Error::Io {
message: "failed to wait for claude process".to_string(),
source: error,
working_dir: working_dir.map(|p| p.to_path_buf()),
}
}
#[cfg(feature = "sync")]
pub(crate) fn kill_group_with_grace_sync(group: &mut GroupKillGuard, grace: Option<Duration>) {
if let Some(g) = grace
&& !g.is_zero()
&& group.is_armed()
{
group.term_now();
std::thread::sleep(g);
}
group.kill_now();
}
#[cfg(feature = "async")]
pub async fn run_claude(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
run_claude_with_retry(claude, args, None).await
}
#[cfg(feature = "async")]
pub async fn run_claude_cancellable<C>(
claude: &Claude,
args: Vec<String>,
cancel: C,
) -> Result<CommandOutput>
where
C: std::future::Future<Output = ()> + Send,
{
let command_args = full_command_args(claude, args);
let span = exec_span(claude, &command_args, "cancellable");
let _enter = span.enter();
let started = std::time::Instant::now();
let stop = cancellation_or_timeout(cancel, claude.timeout);
let output = run_with_stop(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
stop,
SpawnPolicy::of(claude),
)
.await?;
record_exec_outcome(&span, output.exit_code, started);
Ok(output)
}
#[cfg(feature = "async")]
pub async fn run_claude_with_retry(
claude: &Claude,
args: Vec<String>,
retry_override: Option<&crate::retry::RetryPolicy>,
) -> Result<CommandOutput> {
let policy = retry_override.or(claude.retry_policy.as_ref());
match policy {
Some(policy) => {
crate::retry::with_retry(policy, || run_claude_once(claude, args.clone())).await
}
None => run_claude_once(claude, args).await,
}
}
#[cfg(feature = "async")]
pub async fn run_claude_with_stdin_prompt(
claude: &Claude,
args: Vec<String>,
stdin_content: String,
) -> Result<CommandOutput> {
run_claude_with_stdin_prompt_internal(claude, args, stdin_content).await
}
#[cfg(feature = "async")]
pub async fn run_claude_with_stdin_prompt_cancellable<C>(
claude: &Claude,
args: Vec<String>,
stdin_content: String,
cancel: C,
) -> Result<CommandOutput>
where
C: std::future::Future<Output = ()> + Send,
{
let command_args = full_command_args(claude, args);
let span = exec_span(claude, &command_args, "stdin-cancellable");
let _enter = span.enter();
let started = std::time::Instant::now();
let stop = cancellation_or_timeout(cancel, claude.timeout);
let output = run_stdin_with_stop(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
stdin_content,
stop,
SpawnPolicy::of(claude),
)
.await?;
record_exec_outcome(&span, output.exit_code, started);
Ok(output)
}
#[cfg(feature = "async")]
async fn run_claude_with_stdin_prompt_internal(
claude: &Claude,
args: Vec<String>,
stdin_content: String,
) -> Result<CommandOutput> {
let command_args = full_command_args(claude, args);
let span = exec_span(claude, &command_args, "stdin");
let _enter = span.enter();
let started = std::time::Instant::now();
debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt)");
let binary = &claude.binary;
let env = &claude.env;
let clear_env = claude.clear_env;
let working_dir = claude.working_dir.as_deref();
let result = if let Some(timeout) = claude.timeout {
run_with_timeout_stdin(
binary,
&command_args,
env,
clear_env,
working_dir,
timeout,
stdin_content,
SpawnPolicy::of(claude),
)
.await
} else {
run_internal_stdin(
binary,
&command_args,
env,
clear_env,
working_dir,
stdin_content,
SpawnPolicy::of(claude),
)
.await
};
if let Ok(output) = &result {
record_exec_outcome(&span, output.exit_code, started);
}
result
}
#[cfg(feature = "async")]
async fn run_internal_stdin(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
stdin_content: String,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
run_stdin_with_stop(
binary,
args,
env,
clear_env,
working_dir,
stdin_content,
never_stop(),
policy,
)
.await
}
#[cfg(feature = "async")]
#[allow(clippy::too_many_arguments)]
async fn run_with_timeout_stdin(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
timeout: Duration,
stdin_content: String,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
run_stdin_with_stop(
binary,
args,
env,
clear_env,
working_dir,
stdin_content,
timeout_stop(timeout),
policy,
)
.await
}
#[cfg(feature = "async")]
#[allow(clippy::too_many_arguments)]
async fn run_stdin_with_stop(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
stdin_content: String,
stop: StopFuture<'_>,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
let SpawnPolicy {
process_group,
kill_grace,
output_limit,
die_with_parent,
on_spawn,
} = policy;
use tokio::io::AsyncWriteExt;
let mut cmd = Command::new(binary);
cmd.args(args);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
apply_process_group(&mut cmd, process_group);
apply_die_with_parent(&mut cmd, die_with_parent);
apply_child_environment(cmd.as_std_mut(), clear_env, env);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
let mut child = spawn_retrying_txtbsy(&mut cmd)
.await
.map_err(|e| Error::Io {
message: format!("failed to spawn claude: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})?;
let mut group = arm_and_notify(process_group, child.id(), on_spawn);
let child_stdin = child.stdin.take();
let mut stdout_handle = child.stdout.take().expect("stdout was piped");
let mut stderr_handle = child.stderr.take().expect("stderr was piped");
let write = async move {
let Some(mut stdin) = child_stdin else {
return Ok(());
};
stdin
.write_all(stdin_content.as_bytes())
.await
.map_err(|e| Error::Io {
message: format!("failed to write to claude stdin: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})
};
let read_stdout = capture_stream(
&mut stdout_handle,
output_limit,
crate::OutputStream::Stdout,
working_dir,
);
let read_stderr = capture_stream(
&mut stderr_handle,
output_limit,
crate::OutputStream::Stderr,
working_dir,
);
let wait = async { child.wait().await.map_err(|e| wait_error(e, working_dir)) };
let run = async {
let ((), status, stdout, stderr) = tokio::try_join!(write, wait, read_stdout, read_stderr)?;
Ok::<_, Error>((status, stdout, stderr))
};
match tokio::select! {
outcome = run => Ok(outcome),
reason = stop => Err(reason),
} {
Ok(Ok((status, stdout, stderr))) => {
group.disarm();
let exit_code = status.code().unwrap_or(-1);
if !status.success() {
return Err(Error::from_command_failure(
format!("{} {}", binary.display(), args.join(" ")),
exit_code,
stdout,
stderr,
working_dir.map(|p| p.to_path_buf()),
));
}
Ok(CommandOutput {
stdout,
stderr,
exit_code,
success: true,
})
}
Ok(Err(error)) => {
stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
Err(error)
}
Err(reason) => {
stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
Err(reason.into_error())
}
}
}
#[cfg(feature = "async")]
async fn run_claude_once(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
let command_args = full_command_args(claude, args);
let span = exec_span(claude, &command_args, "oneshot");
let _enter = span.enter();
let started = std::time::Instant::now();
debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command");
let output = if let Some(timeout) = claude.timeout {
run_with_timeout(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
timeout,
SpawnPolicy::of(claude),
)
.await?
} else {
run_internal(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
SpawnPolicy::of(claude),
)
.await?
};
record_exec_outcome(&span, output.exit_code, started);
Ok(output)
}
#[cfg(feature = "async")]
pub async fn run_claude_allow_exit_codes(
claude: &Claude,
args: Vec<String>,
allowed_codes: &[i32],
) -> Result<CommandOutput> {
let output = run_claude(claude, args).await;
match output {
Err(Error::CommandFailed {
exit_code,
stdout,
stderr,
..
}) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
stdout,
stderr,
exit_code,
success: false,
}),
other => other,
}
}
#[cfg(feature = "async")]
async fn run_internal(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
run_with_stop(
binary,
args,
env,
clear_env,
working_dir,
never_stop(),
policy,
)
.await
}
#[cfg(feature = "async")]
async fn run_with_timeout(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
timeout: Duration,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
run_with_stop(
binary,
args,
env,
clear_env,
working_dir,
timeout_stop(timeout),
policy,
)
.await
}
#[cfg(feature = "async")]
async fn run_with_stop(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
stop: StopFuture<'_>,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
let SpawnPolicy {
process_group,
kill_grace,
output_limit,
die_with_parent,
on_spawn,
} = policy;
let mut cmd = Command::new(binary);
cmd.args(args);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
apply_process_group(&mut cmd, process_group);
apply_die_with_parent(&mut cmd, die_with_parent);
apply_child_environment(cmd.as_std_mut(), clear_env, env);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
let mut child = spawn_retrying_txtbsy(&mut cmd)
.await
.map_err(|e| Error::Io {
message: format!("failed to spawn claude: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})?;
let mut group = arm_and_notify(process_group, child.id(), on_spawn);
let mut stdout = child.stdout.take().expect("stdout was piped");
let mut stderr = child.stderr.take().expect("stderr was piped");
let wait_and_drain = async {
tokio::try_join!(
async { child.wait().await.map_err(|e| wait_error(e, working_dir)) },
capture_stream(
&mut stdout,
output_limit,
crate::OutputStream::Stdout,
working_dir
),
capture_stream(
&mut stderr,
output_limit,
crate::OutputStream::Stderr,
working_dir
),
)
};
match tokio::select! {
outcome = wait_and_drain => Ok(outcome),
reason = stop => Err(reason),
} {
Ok(Ok((status, stdout, stderr))) => {
group.disarm();
let exit_code = status.code().unwrap_or(-1);
if !status.success() {
return Err(Error::from_command_failure(
format!("{} {}", binary.display(), args.join(" ")),
exit_code,
stdout,
stderr,
working_dir.map(|p| p.to_path_buf()),
));
}
Ok(CommandOutput {
stdout,
stderr,
exit_code,
success: true,
})
}
Ok(Err(error)) => {
stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
Err(error)
}
Err(reason) => {
stop_and_reap(&mut child, &mut group, kill_grace, working_dir).await?;
let stdout_str = tokio::time::timeout(
PARTIAL_CAPTURE_BUDGET,
capture_partial(&mut stdout, output_limit),
)
.await
.unwrap_or_default();
let stderr_str = tokio::time::timeout(
PARTIAL_CAPTURE_BUDGET,
capture_partial(&mut stderr, output_limit),
)
.await
.unwrap_or_default();
if !stdout_str.is_empty() || !stderr_str.is_empty() {
warn!(
stdout = %stdout_str,
stderr = %stderr_str,
"partial output from stopped process",
);
}
Err(reason.into_error())
}
}
}
#[cfg(any(feature = "async", feature = "sync"))]
const CAPTURE_CHUNK: usize = 8 * 1024;
#[cfg(any(feature = "async", feature = "sync"))]
const BREACH_LOG_BYTES: usize = 2 * 1024;
#[cfg(any(feature = "async", feature = "sync"))]
const PARTIAL_CAPTURE_BUDGET: Duration = Duration::from_millis(200);
#[cfg(any(feature = "async", feature = "sync"))]
enum Captured {
Complete(String),
Exceeded { head: String },
}
#[cfg(feature = "async")]
async fn capture<R: AsyncReadExt + Unpin>(
reader: &mut R,
limit: Option<usize>,
) -> std::io::Result<Captured> {
let Some(limit) = limit else {
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await?;
return Ok(Captured::Complete(
String::from_utf8_lossy(&buf).into_owned(),
));
};
let mut buf = Vec::new();
let mut chunk = [0u8; CAPTURE_CHUNK];
loop {
let read = reader.read(&mut chunk).await?;
if read == 0 {
return Ok(Captured::Complete(
String::from_utf8_lossy(&buf).into_owned(),
));
}
if buf.len() + read > limit {
buf.truncate(BREACH_LOG_BYTES.min(buf.len()));
return Ok(Captured::Exceeded {
head: String::from_utf8_lossy(&buf).into_owned(),
});
}
buf.extend_from_slice(&chunk[..read]);
}
}
#[cfg(feature = "async")]
pub(crate) async fn capture_stream<R: AsyncReadExt + Unpin>(
reader: &mut R,
limit: Option<usize>,
stream: crate::OutputStream,
working_dir: Option<&std::path::Path>,
) -> Result<String> {
match capture(reader, limit).await {
Ok(Captured::Complete(text)) => Ok(text),
Ok(Captured::Exceeded { head }) => {
let limit_bytes = limit.unwrap_or_default();
warn!(
%stream,
limit_bytes,
head = %head,
"captured output exceeded its ceiling; terminating the child",
);
Err(Error::OutputLimitExceeded {
stream,
limit_bytes,
})
}
Err(source) => Err(Error::Io {
message: format!("failed to read claude {stream}: {source}"),
source,
working_dir: working_dir.map(|p| p.to_path_buf()),
}),
}
}
#[cfg(feature = "async")]
async fn capture_partial<R: AsyncReadExt + Unpin>(reader: &mut R, limit: Option<usize>) -> String {
match capture(reader, limit).await {
Ok(Captured::Complete(text) | Captured::Exceeded { head: text }) => text,
Err(_) => String::new(),
}
}
#[cfg(any(feature = "async", feature = "sync"))]
const TXTBSY_RETRY_BUDGET: Duration = Duration::from_secs(3);
#[cfg(any(feature = "async", feature = "sync"))]
const TXTBSY_MAX_BACKOFF: Duration = Duration::from_millis(25);
#[cfg(feature = "async")]
async fn spawn_retrying_txtbsy(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
let start = std::time::Instant::now();
let mut backoff = Duration::from_millis(1);
loop {
match cmd.spawn() {
Err(e)
if e.kind() == std::io::ErrorKind::ExecutableFileBusy
&& start.elapsed() < TXTBSY_RETRY_BUDGET =>
{
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
}
other => return other,
}
}
}
#[cfg(feature = "sync")]
pub fn run_claude_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
run_claude_with_retry_sync(claude, args, None)
}
#[cfg(feature = "sync")]
pub fn run_claude_with_retry_sync(
claude: &Claude,
args: Vec<String>,
retry_override: Option<&crate::retry::RetryPolicy>,
) -> Result<CommandOutput> {
let policy = retry_override.or(claude.retry_policy.as_ref());
match policy {
Some(policy) => {
crate::retry::with_retry_sync(policy, || run_claude_once_sync(claude, args.clone()))
}
None => run_claude_once_sync(claude, args),
}
}
#[cfg(feature = "sync")]
pub fn run_claude_with_stdin_prompt_sync(
claude: &Claude,
args: Vec<String>,
stdin_content: String,
) -> Result<CommandOutput> {
let command_args = full_command_args(claude, args);
let span = exec_span(claude, &command_args, "stdin-sync");
let _enter = span.enter();
let started = std::time::Instant::now();
debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (stdin prompt, sync)");
let result = if let Some(timeout) = claude.timeout {
run_with_deadline_stdin_sync(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
Some(timeout),
stdin_content,
SpawnPolicy::of(claude),
)
} else {
run_internal_stdin_sync(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
stdin_content,
SpawnPolicy::of(claude),
)
};
if let Ok(output) = &result {
record_exec_outcome(&span, output.exit_code, started);
}
result
}
#[cfg(feature = "sync")]
fn run_internal_stdin_sync(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
stdin_content: String,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
run_with_deadline_stdin_sync(
binary,
args,
env,
clear_env,
working_dir,
None,
stdin_content,
policy,
)
}
#[cfg(feature = "sync")]
#[allow(clippy::too_many_arguments)]
fn run_with_deadline_stdin_sync(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
deadline: Option<Duration>,
stdin_content: String,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
let SpawnPolicy {
process_group,
kill_grace,
output_limit,
die_with_parent,
on_spawn,
} = policy;
use std::io::Write;
use std::process::{Command as StdCommand, Stdio};
let mut cmd = StdCommand::new(binary);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
apply_process_group_sync(&mut cmd, process_group);
apply_die_with_parent_sync(&mut cmd, die_with_parent);
apply_child_environment(&mut cmd, clear_env, env);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
message: format!("failed to spawn claude: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})?;
let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
let capture = SyncCapture::start(
child.stdout.take().expect("stdout was piped"),
child.stderr.take().expect("stderr was piped"),
output_limit,
);
if let Some(mut stdin) = child.stdin.take() {
let written = stdin
.write_all(stdin_content.as_bytes())
.and_then(|()| stdin.flush());
if let Err(source) = written {
stop_and_reap_sync(&mut child, &mut group, kill_grace);
log_partial(capture, "partial output from process with failed stdin");
return Err(Error::Io {
message: format!("failed to write to claude stdin: {source}"),
source,
working_dir: working_dir.map(|p| p.to_path_buf()),
});
}
}
finish_sync_run(
&mut child,
&mut group,
capture,
SyncRun {
binary,
args,
working_dir,
deadline,
kill_grace,
output_limit,
},
)
}
#[cfg(feature = "sync")]
fn run_claude_once_sync(claude: &Claude, args: Vec<String>) -> Result<CommandOutput> {
let command_args = full_command_args(claude, args);
let span = exec_span(claude, &command_args, "oneshot-sync");
let _enter = span.enter();
let started = std::time::Instant::now();
debug!(binary = %claude.binary.display(), args = ?command_args, "executing claude command (sync)");
let result = if let Some(timeout) = claude.timeout {
run_with_deadline_sync(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
Some(timeout),
SpawnPolicy::of(claude),
)
} else {
run_internal_sync(
&claude.binary,
&command_args,
&claude.env,
claude.clear_env,
claude.working_dir.as_deref(),
SpawnPolicy::of(claude),
)
};
if let Ok(output) = &result {
record_exec_outcome(&span, output.exit_code, started);
}
result
}
#[cfg(feature = "sync")]
pub fn run_claude_allow_exit_codes_sync(
claude: &Claude,
args: Vec<String>,
allowed_codes: &[i32],
) -> Result<CommandOutput> {
match run_claude_sync(claude, args) {
Err(Error::CommandFailed {
exit_code,
stdout,
stderr,
..
}) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
stdout,
stderr,
exit_code,
success: false,
}),
other => other,
}
}
#[cfg(feature = "sync")]
fn run_internal_sync(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
run_with_deadline_sync(binary, args, env, clear_env, working_dir, None, policy)
}
#[cfg(feature = "sync")]
fn run_with_deadline_sync(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
clear_env: bool,
working_dir: Option<&std::path::Path>,
deadline: Option<Duration>,
policy: SpawnPolicy<'_>,
) -> Result<CommandOutput> {
let SpawnPolicy {
process_group,
kill_grace,
output_limit,
die_with_parent,
on_spawn,
} = policy;
use std::process::{Command as StdCommand, Stdio};
let mut cmd = StdCommand::new(binary);
cmd.args(args);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
apply_process_group_sync(&mut cmd, process_group);
apply_die_with_parent_sync(&mut cmd, die_with_parent);
apply_child_environment(&mut cmd, clear_env, env);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
let mut child = spawn_retrying_txtbsy_sync(&mut cmd).map_err(|e| Error::Io {
message: format!("failed to spawn claude: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})?;
let mut group = arm_and_notify(process_group, Some(child.id()), on_spawn);
let capture = SyncCapture::start(
child.stdout.take().expect("stdout was piped"),
child.stderr.take().expect("stderr was piped"),
output_limit,
);
finish_sync_run(
&mut child,
&mut group,
capture,
SyncRun {
binary,
args,
working_dir,
deadline,
kill_grace,
output_limit,
},
)
}
#[cfg(feature = "sync")]
fn capture_sync<R: std::io::Read>(
mut reader: R,
limit: Option<usize>,
) -> std::io::Result<Captured> {
let Some(limit) = limit else {
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
return Ok(Captured::Complete(
String::from_utf8_lossy(&buf).into_owned(),
));
};
let mut buf = Vec::new();
let mut chunk = [0u8; CAPTURE_CHUNK];
loop {
let read = reader.read(&mut chunk)?;
if read == 0 {
return Ok(Captured::Complete(
String::from_utf8_lossy(&buf).into_owned(),
));
}
if buf.len() + read > limit {
buf.truncate(BREACH_LOG_BYTES.min(buf.len()));
return Ok(Captured::Exceeded {
head: String::from_utf8_lossy(&buf).into_owned(),
});
}
buf.extend_from_slice(&chunk[..read]);
}
}
#[cfg(feature = "sync")]
mod breach {
pub(super) const NONE: u8 = 0;
pub(super) const STDOUT: u8 = 1;
pub(super) const STDERR: u8 = 2;
pub(super) fn stream(code: u8) -> Option<crate::OutputStream> {
match code {
STDOUT => Some(crate::OutputStream::Stdout),
STDERR => Some(crate::OutputStream::Stderr),
_ => None,
}
}
}
#[cfg(feature = "sync")]
const BREACH_POLL_INTERVAL: Duration = Duration::from_millis(250);
#[cfg(feature = "sync")]
struct SyncCapture {
stdout: std::thread::JoinHandle<std::io::Result<Captured>>,
stderr: std::thread::JoinHandle<std::io::Result<Captured>>,
limit: Option<usize>,
breached: std::sync::Arc<std::sync::atomic::AtomicU8>,
}
#[cfg(feature = "sync")]
impl SyncCapture {
fn start(
stdout: std::process::ChildStdout,
stderr: std::process::ChildStderr,
limit: Option<usize>,
) -> Self {
let breached = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(breach::NONE));
Self {
stdout: spawn_capture_thread(stdout, limit, breach::STDOUT, &breached),
stderr: spawn_capture_thread(stderr, limit, breach::STDERR, &breached),
limit,
breached,
}
}
fn watch(&self) -> Option<&std::sync::atomic::AtomicU8> {
self.limit.is_some().then(|| self.breached.as_ref())
}
fn settle(self, working_dir: Option<&std::path::Path>) -> Result<(String, String)> {
let limit = self.limit;
let stdout = join_capture(self.stdout, crate::OutputStream::Stdout, limit, working_dir);
let stderr = join_capture(self.stderr, crate::OutputStream::Stderr, limit, working_dir);
Ok((stdout?, stderr?))
}
fn partial(self, budget: Duration) -> (String, String) {
let (stdout, stderr) = join_with_deadline(self.stdout, self.stderr, budget);
(partial_text(stdout), partial_text(stderr))
}
}
#[cfg(feature = "sync")]
fn spawn_capture_thread<R: std::io::Read + Send + 'static>(
reader: R,
limit: Option<usize>,
code: u8,
breached: &std::sync::Arc<std::sync::atomic::AtomicU8>,
) -> std::thread::JoinHandle<std::io::Result<Captured>> {
let breached = std::sync::Arc::clone(breached);
std::thread::spawn(move || {
let outcome = capture_sync(reader, limit);
if matches!(outcome, Ok(Captured::Exceeded { .. })) {
let _ = breached.compare_exchange(
breach::NONE,
code,
std::sync::atomic::Ordering::AcqRel,
std::sync::atomic::Ordering::Acquire,
);
}
outcome
})
}
#[cfg(feature = "sync")]
fn join_capture(
handle: std::thread::JoinHandle<std::io::Result<Captured>>,
stream: crate::OutputStream,
limit: Option<usize>,
working_dir: Option<&std::path::Path>,
) -> Result<String> {
match handle.join() {
Ok(Ok(Captured::Complete(text))) => Ok(text),
Ok(Ok(Captured::Exceeded { head })) => {
let limit_bytes = limit.unwrap_or_default();
warn!(
%stream,
limit_bytes,
head = %head,
"captured output exceeded its ceiling; terminating the child",
);
Err(Error::OutputLimitExceeded {
stream,
limit_bytes,
})
}
Ok(Err(source)) => Err(Error::Io {
message: format!("failed to read claude {stream}: {source}"),
source,
working_dir: working_dir.map(|p| p.to_path_buf()),
}),
Err(_) => Err(Error::Io {
message: format!("claude {stream} capture thread panicked"),
source: std::io::Error::other(format!("{stream} capture thread panicked")),
working_dir: working_dir.map(|p| p.to_path_buf()),
}),
}
}
#[cfg(feature = "sync")]
fn partial_text(outcome: Option<std::io::Result<Captured>>) -> String {
match outcome {
Some(Ok(Captured::Complete(text) | Captured::Exceeded { head: text })) => text,
Some(Err(_)) | None => String::new(),
}
}
#[cfg(feature = "sync")]
enum SyncWait {
Exited(std::process::ExitStatus),
TimedOut,
Breached(crate::OutputStream),
}
#[cfg(feature = "sync")]
fn wait_for_child_sync(
child: &mut std::process::Child,
deadline: Option<Duration>,
watch: Option<&std::sync::atomic::AtomicU8>,
) -> std::io::Result<SyncWait> {
use std::sync::atomic::Ordering;
use wait_timeout::ChildExt;
let Some(watch) = watch else {
return Ok(match deadline {
Some(deadline) => child
.wait_timeout(deadline)?
.map_or(SyncWait::TimedOut, SyncWait::Exited),
None => SyncWait::Exited(child.wait()?),
});
};
let expiry = deadline.map(|d| std::time::Instant::now() + d);
loop {
if let Some(stream) = breach::stream(watch.load(Ordering::Acquire)) {
return Ok(SyncWait::Breached(stream));
}
let mut tick = BREACH_POLL_INTERVAL;
if let Some(expiry) = expiry {
let remaining = expiry.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Ok(SyncWait::TimedOut);
}
tick = tick.min(remaining);
}
if let Some(status) = child.wait_timeout(tick)? {
return Ok(SyncWait::Exited(status));
}
}
}
#[cfg(feature = "sync")]
struct SyncRun<'a> {
binary: &'a std::path::Path,
args: &'a [String],
working_dir: Option<&'a std::path::Path>,
deadline: Option<Duration>,
kill_grace: Option<Duration>,
output_limit: Option<usize>,
}
#[cfg(feature = "sync")]
fn finish_sync_run(
child: &mut std::process::Child,
group: &mut GroupKillGuard,
capture: SyncCapture,
run: SyncRun<'_>,
) -> Result<CommandOutput> {
let waited = match wait_for_child_sync(child, run.deadline, capture.watch()) {
Ok(waited) => waited,
Err(source) => {
stop_and_reap_sync(child, group, run.kill_grace);
return Err(Error::Io {
message: "failed to wait for claude process".to_string(),
source,
working_dir: run.working_dir.map(|p| p.to_path_buf()),
});
}
};
match waited {
SyncWait::Exited(status) => {
group.disarm();
let (stdout, stderr) = capture.settle(run.working_dir)?;
let exit_code = status.code().unwrap_or(-1);
if !status.success() {
return Err(Error::from_command_failure(
format!("{} {}", run.binary.display(), run.args.join(" ")),
exit_code,
stdout,
stderr,
run.working_dir.map(|p| p.to_path_buf()),
));
}
Ok(CommandOutput {
stdout,
stderr,
exit_code,
success: true,
})
}
SyncWait::TimedOut => {
stop_and_reap_sync(child, group, run.kill_grace);
log_partial(capture, "partial output from timed-out process");
Err(Error::Timeout {
timeout_seconds: run.deadline.unwrap_or_default().as_secs(),
})
}
SyncWait::Breached(stream) => {
stop_and_reap_sync(child, group, run.kill_grace);
log_partial(capture, "partial output from over-limit process");
Err(Error::OutputLimitExceeded {
stream,
limit_bytes: run.output_limit.unwrap_or_default(),
})
}
}
}
#[cfg(feature = "sync")]
fn log_partial(capture: SyncCapture, message: &'static str) {
let (stdout, stderr) = capture.partial(PARTIAL_CAPTURE_BUDGET);
if !stdout.is_empty() || !stderr.is_empty() {
warn!(stdout = %stdout, stderr = %stderr, "{message}");
}
}
#[cfg(feature = "sync")]
fn stop_and_reap_sync(
child: &mut std::process::Child,
group: &mut GroupKillGuard,
grace: Option<Duration>,
) {
kill_group_with_grace_sync(group, grace);
let _ = child.kill();
let _ = child.wait();
}
#[cfg(feature = "sync")]
fn spawn_retrying_txtbsy_sync(
cmd: &mut std::process::Command,
) -> std::io::Result<std::process::Child> {
let start = std::time::Instant::now();
let mut backoff = Duration::from_millis(1);
loop {
match cmd.spawn() {
Err(e)
if e.kind() == std::io::ErrorKind::ExecutableFileBusy
&& start.elapsed() < TXTBSY_RETRY_BUDGET =>
{
std::thread::sleep(backoff);
backoff = (backoff * 2).min(TXTBSY_MAX_BACKOFF);
}
other => return other,
}
}
}
#[cfg(feature = "sync")]
#[cfg(feature = "sync")]
fn join_with_deadline<T: Send + 'static>(
stdout_thread: std::thread::JoinHandle<T>,
stderr_thread: std::thread::JoinHandle<T>,
budget: Duration,
) -> (Option<T>, Option<T>) {
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel::<(&'static str, T)>();
let tx_out = tx.clone();
let tx_err = tx;
thread::spawn(move || {
if let Ok(value) = stdout_thread.join() {
let _ = tx_out.send(("stdout", value));
}
});
thread::spawn(move || {
if let Ok(value) = stderr_thread.join() {
let _ = tx_err.send(("stderr", value));
}
});
let mut stdout = None;
let mut stderr = None;
let deadline = std::time::Instant::now() + budget;
for _ in 0..2 {
let now = std::time::Instant::now();
if now >= deadline {
break;
}
match rx.recv_timeout(deadline - now) {
Ok(("stdout", value)) => stdout = Some(value),
Ok(("stderr", value)) => stderr = Some(value),
Ok(_) => unreachable!(),
Err(_) => break,
}
}
(stdout, stderr)
}
#[cfg(all(test, unix, any(feature = "async", feature = "sync")))]
mod tests {
use super::*;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use crate::Claude;
fn fake_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("fake-claude.sh");
{
let mut f = std::fs::File::create(&path).expect("create script");
write!(f, "#!/usr/bin/env bash\n{body}\n").expect("write script");
f.sync_all().expect("sync script");
}
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&path, perms).expect("chmod");
(dir, path)
}
fn client(path: &std::path::Path) -> Claude {
Claude::builder()
.binary(path)
.build()
.expect("build client")
}
#[test]
fn full_command_args_puts_global_args_first() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.arg("--debug")
.arg("--verbose")
.build()
.expect("build client");
let args = full_command_args(&claude, vec!["--print".to_string(), "hi".to_string()]);
assert_eq!(args, ["--debug", "--verbose", "--print", "hi"]);
}
#[test]
fn full_command_args_without_global_args_is_passthrough() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.build()
.expect("build client");
let args = full_command_args(&claude, vec!["--print".to_string()]);
assert_eq!(args, ["--print"]);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn set_scrub_vars() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("CLAUDECODE", "1");
std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
}
}
fn clear_scrub_vars() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("CLAUDECODE");
std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
}
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_success_maps_output() {
let (_dir, path) = fake_script(r#"echo "hi there"; exit 0"#);
let out = run_claude(&client(&path), vec!["--version".into()])
.await
.expect("success");
assert!(out.success);
assert_eq!(out.exit_code, 0);
assert!(out.stdout.contains("hi there"));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_nonzero_exit_maps_command_failed() {
let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
let err = run_claude(&client(&path), vec![]).await.unwrap_err();
match err {
Error::CommandFailed {
exit_code, stderr, ..
} => {
assert_eq!(exit_code, 3);
assert!(stderr.contains("boom"));
}
other => panic!("expected CommandFailed, got {other:?}"),
}
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_rail_stop_maps_max_turns() {
let (_dir, path) = fake_script(
r#"echo '{"type":"result","subtype":"error_max_turns","is_error":true,"errors":["Reached maximum number of turns (2)"]}'; exit 1"#,
);
let err = run_claude(&client(&path), vec![]).await.unwrap_err();
assert!(
matches!(
err,
Error::MaxTurnsExceeded {
max_turns: Some(2),
..
}
),
"got: {err:?}"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_auth_shaped_stderr_maps_auth() {
let (_dir, path) =
fake_script(r#"echo "Not authenticated. Run `claude login`." >&2; exit 1"#);
let err = run_claude(&client(&path), vec![]).await.unwrap_err();
assert!(matches!(err, Error::Auth { .. }), "got: {err:?}");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_scrubs_claude_env_vars() {
let (_dir, path) =
fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
set_scrub_vars();
let out = run_claude(&client(&path), vec![]).await.expect("success");
clear_scrub_vars();
assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_applies_working_dir() {
let (_dir, path) = fake_script(r#"pwd"#);
let workdir = tempfile::tempdir().expect("workdir");
let claude = Claude::builder()
.binary(&path)
.working_dir(workdir.path())
.build()
.expect("build");
let out = run_claude(&claude, vec![]).await.expect("success");
let got = std::fs::canonicalize(out.stdout.trim()).expect("canonicalize pwd");
let want = std::fs::canonicalize(workdir.path()).expect("canonicalize workdir");
assert_eq!(got, want);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_stdin_prompt_round_trips() {
let (_dir, path) = fake_script(r#"cat"#);
let out = run_claude_with_stdin_prompt(&client(&path), vec![], "hello via stdin".into())
.await
.expect("success");
assert!(out.stdout.contains("hello via stdin"));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_spawn_retry_passes_through_non_txtbsy_error() {
let mut cmd = Command::new("/nonexistent/definitely-not-a-real-binary");
let err = spawn_retrying_txtbsy(&mut cmd)
.await
.expect_err("spawn of missing binary should fail");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_allow_exit_codes_permits_listed_code() {
let (_dir, path) = fake_script(r#"echo out; exit 2"#);
let out = run_claude_allow_exit_codes(&client(&path), vec![], &[2])
.await
.expect("allowed code is Ok");
assert!(!out.success);
assert_eq!(out.exit_code, 2);
assert!(out.stdout.contains("out"));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_allow_exit_codes_still_errors_on_unlisted_code() {
let (_dir, path) = fake_script(r#"exit 2"#);
let err = run_claude_allow_exit_codes(&client(&path), vec![], &[5])
.await
.unwrap_err();
assert!(
matches!(err, Error::CommandFailed { exit_code: 2, .. }),
"got: {err:?}"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_timeout_fires_on_slow_child() {
let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_millis(300))
.build()
.expect("build");
let err = run_claude(&claude, vec![]).await.unwrap_err();
assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_timeout_path_returns_output_when_fast() {
let (_dir, path) = fake_script(r#"echo quick"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_secs(30))
.build()
.expect("build");
let out = run_claude(&claude, vec![]).await.expect("success");
assert!(out.stdout.contains("quick"));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_timeout_path_maps_command_failed() {
let (_dir, path) = fake_script(r#"echo e >&2; exit 4"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_secs(30))
.build()
.expect("build");
let err = run_claude(&claude, vec![]).await.unwrap_err();
assert!(
matches!(err, Error::CommandFailed { exit_code: 4, .. }),
"got: {err:?}"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_stdin_with_timeout_round_trips() {
let (_dir, path) = fake_script(r#"cat"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_secs(30))
.build()
.expect("build");
let out = run_claude_with_stdin_prompt(&claude, vec![], "piped under timeout".into())
.await
.expect("success");
assert!(out.stdout.contains("piped under timeout"));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_stdin_timeout_fires_on_slow_child() {
let (_dir, path) = fake_script(r#"sleep 3"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_millis(300))
.build()
.expect("build");
let err = run_claude_with_stdin_prompt(&claude, vec![], "x".into())
.await
.unwrap_err();
assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
}
#[cfg(feature = "async")]
async fn drop_in_flight_and_capture_pid<F>(fut: F, pid_path: &std::path::Path) -> u32
where
F: std::future::Future,
F::Output: std::fmt::Debug,
{
tokio::pin!(fut);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if let Some(pid) = std::fs::read_to_string(pid_path)
.ok()
.and_then(|s| s.trim().parse().ok())
{
return pid;
}
assert!(
std::time::Instant::now() < deadline,
"child never wrote its pid file"
);
tokio::select! {
out = &mut fut => panic!("future completed before drop: {out:?}"),
_ = tokio::time::sleep(Duration::from_millis(10)) => {}
}
}
}
fn assert_pid_killed(pid: u32) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let out = std::process::Command::new("ps")
.args(["-o", "stat=", "-p", &pid.to_string()])
.output()
.expect("run ps");
let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !out.status.success() || stat.is_empty() || stat.starts_with('Z') {
return;
}
assert!(
std::time::Instant::now() < deadline,
"process {pid} still alive (stat {stat}) after kill"
);
std::thread::sleep(Duration::from_millis(25));
}
}
#[cfg(feature = "async")]
fn assert_pid_not_live_now(pid: u32) {
let out = std::process::Command::new("ps")
.args(["-o", "stat=", "-p", &pid.to_string()])
.output()
.expect("run ps");
let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(
!out.status.success() || stat.is_empty() || stat.starts_with('Z'),
"process {pid} still live after terminal settlement (stat {stat})"
);
}
fn group_script(
pid_path: &std::path::Path,
gpid_path: &std::path::Path,
) -> (tempfile::TempDir, std::path::PathBuf) {
fake_script(&format!(
concat!(
"bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
"until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
"echo $$ > \"{p}\"\n",
"exec sleep 300",
),
g = gpid_path.display(),
p = pid_path.display(),
))
}
fn spew_script(stream: &str, bytes: usize) -> (tempfile::TempDir, std::path::PathBuf) {
let redirect = if stream == "stderr" { ">&2" } else { "" };
fake_script(&format!(
"head -c {bytes} /dev/zero | tr '\\0' 'x' {redirect}\nexit 0"
))
}
fn limited_client(path: &std::path::Path, limit: usize) -> Claude {
Claude::builder()
.binary(path)
.output_limit(limit)
.build()
.expect("build client")
}
fn assert_limit_error(error: &Error, want_stream: crate::OutputStream, want_limit: usize) {
match error {
Error::OutputLimitExceeded {
stream,
limit_bytes,
} => {
assert_eq!(*stream, want_stream);
assert_eq!(*limit_bytes, want_limit);
}
other => panic!("expected OutputLimitExceeded, got: {other:?}"),
}
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_stdout_over_limit_is_typed_not_truncated() {
let (_dir, path) = spew_script("stdout", 64 * 1024);
let error = run_claude(&limited_client(&path, 4096), vec![])
.await
.expect_err("over-limit stdout must fail");
assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_stderr_over_limit_names_stderr() {
let (_dir, path) = spew_script("stderr", 64 * 1024);
let error = run_claude(&limited_client(&path, 4096), vec![])
.await
.expect_err("over-limit stderr must fail");
assert_limit_error(&error, crate::OutputStream::Stderr, 4096);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_output_at_limit_succeeds() {
let (_dir, path) = fake_script(r#"printf 'abcde'"#);
let out = run_claude(&limited_client(&path, 5), vec![])
.await
.expect("output at the ceiling is complete");
assert_eq!(out.stdout, "abcde");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_unset_limit_leaves_large_output_intact() {
let (_dir, path) = spew_script("stdout", 256 * 1024);
let out = run_claude(&client(&path), vec![])
.await
.expect("no ceiling means no failure");
assert_eq!(out.stdout.len(), 256 * 1024);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_stdin_prompt_path_honors_limit() {
let (_dir, path) = spew_script("stdout", 64 * 1024);
let error = run_claude_with_stdin_prompt_sync_free(&limited_client(&path, 4096))
.await
.expect_err("over-limit stdout must fail on the stdin path");
assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
}
#[cfg(feature = "async")]
async fn run_claude_with_stdin_prompt_sync_free(claude: &Claude) -> Result<CommandOutput> {
run_claude_with_stdin_prompt(claude, vec![], "prompt".to_string()).await
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_limit_breach_kills_process_group() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let gpid_path = workdir.path().join("gpid");
let (_dir, path) = fake_script(&format!(
concat!(
"bash -c 'echo $$ > \"$0\"; exec sleep 300' \"{g}\" &\n",
"until [[ -s \"{g}\" ]]; do sleep 0.01; done\n",
"echo $$ > \"{p}\"\n",
"head -c 65536 /dev/zero | tr '\\0' 'x'\n",
"exec sleep 300",
),
g = gpid_path.display(),
p = pid_path.display(),
));
let error = run_claude(&limited_client(&path, 4096), vec![])
.await
.expect_err("over-limit stdout must fail");
assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
let pid = try_read_pid(&pid_path).expect("child recorded its pid");
let gpid = try_read_pid(&gpid_path).expect("grandchild recorded its pid");
assert_pid_killed(pid);
assert_pid_killed(gpid);
}
#[cfg(feature = "sync")]
#[test]
fn sync_stdout_over_limit_is_typed_not_truncated() {
let (_dir, path) = spew_script("stdout", 64 * 1024);
let error = run_claude_sync(&limited_client(&path, 4096), vec![])
.expect_err("over-limit stdout must fail");
assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
}
#[cfg(feature = "sync")]
#[test]
fn sync_stderr_over_limit_names_stderr() {
let (_dir, path) = spew_script("stderr", 64 * 1024);
let error = run_claude_sync(&limited_client(&path, 4096), vec![])
.expect_err("over-limit stderr must fail");
assert_limit_error(&error, crate::OutputStream::Stderr, 4096);
}
#[cfg(feature = "sync")]
#[test]
fn sync_output_at_limit_succeeds() {
let (_dir, path) = fake_script(r#"printf 'abcde'"#);
let out = run_claude_sync(&limited_client(&path, 5), vec![])
.expect("output at the ceiling is complete");
assert_eq!(out.stdout, "abcde");
}
#[cfg(feature = "sync")]
#[test]
fn sync_unset_limit_leaves_large_output_intact() {
let (_dir, path) = spew_script("stdout", 256 * 1024);
let out = run_claude_sync(&client(&path), vec![]).expect("no ceiling means no failure");
assert_eq!(out.stdout.len(), 256 * 1024);
}
#[cfg(feature = "sync")]
#[test]
fn sync_stdin_prompt_path_honors_limit() {
let (_dir, path) = spew_script("stdout", 64 * 1024);
let error =
run_claude_with_stdin_prompt_sync(&limited_client(&path, 4096), vec![], "p".into())
.expect_err("over-limit stdout must fail on the stdin path");
assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
}
#[cfg(feature = "sync")]
#[test]
fn sync_limit_breach_terminates_a_child_with_no_deadline() {
let (_dir, path) = fake_script(concat!(
"head -c 65536 /dev/zero | tr '\\0' 'x'\n",
"exec sleep 300",
));
let started = std::time::Instant::now();
let error = run_claude_sync(&limited_client(&path, 4096), vec![])
.expect_err("over-limit stdout must fail");
assert_limit_error(&error, crate::OutputStream::Stdout, 4096);
assert!(
started.elapsed() < Duration::from_secs(30),
"breach must not wait out the child, took {:?}",
started.elapsed(),
);
}
fn try_read_pid(path: &std::path::Path) -> Option<u32> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
#[cfg(feature = "async")]
fn read_pid(path: &std::path::Path) -> u32 {
try_read_pid(path).expect("pid file readable")
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_dropping_in_flight_future_kills_child() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let (_dir, path) = fake_script(&format!(
r#"echo $$ > "{}"; exec sleep 30"#,
pid_path.display()
));
let claude = client(&path);
let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
assert_pid_killed(pid);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_dropping_in_flight_future_kills_child_with_timeout() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let (_dir, path) = fake_script(&format!(
r#"echo $$ > "{}"; exec sleep 30"#,
pid_path.display()
));
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_secs(120))
.build()
.expect("build");
let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
assert_pid_killed(pid);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_dropping_in_flight_future_kills_process_group() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let gpid_path = workdir.path().join("gpid");
let (_dir, path) = group_script(&pid_path, &gpid_path);
let claude = client(&path);
let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
assert_pid_killed(pid);
assert_pid_killed(read_pid(&gpid_path));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_timeout_kills_process_group() {
let mut observed = false;
for _ in 0..5 {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let gpid_path = workdir.path().join("gpid");
let (_dir, path) = group_script(&pid_path, &gpid_path);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_millis(1000))
.build()
.expect("build");
let err = run_claude(&claude, vec![]).await.unwrap_err();
assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
assert_pid_killed(pid);
assert_pid_killed(gpid);
observed = true;
break;
}
}
assert!(observed, "child never recorded pids within 5 timeout runs");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_explicit_cancellation_settles_process_group() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let gpid_path = workdir.path().join("gpid");
let (_dir, path) = group_script(&pid_path, &gpid_path);
let claude = Claude::builder()
.binary(&path)
.kill_grace(Duration::from_millis(10))
.build()
.expect("build");
let cancel = async {
while !pid_path.exists() || !gpid_path.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
};
let error = run_claude_cancellable(&claude, vec![], cancel)
.await
.expect_err("run must be cancelled");
assert!(matches!(error, Error::Cancelled));
assert_pid_not_live_now(read_pid(&pid_path));
assert_pid_not_live_now(read_pid(&gpid_path));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_stdin_failure_settles_process_group() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let gpid_path = workdir.path().join("gpid");
let (_dir, path) = fake_script(&format!(
concat!(
"sleep 300 </dev/null &\n",
"child=$!\n",
"echo $$ > '{p}'\n",
"echo $child > '{g}'\n",
"exec 0<&-\n",
"wait $child",
),
p = pid_path.display(),
g = gpid_path.display(),
));
let claude = Claude::builder()
.binary(&path)
.kill_grace(Duration::from_millis(10))
.build()
.expect("build");
let error = run_claude_with_stdin_prompt(&claude, vec![], "x".repeat(4 * 1024 * 1024))
.await
.expect_err("stdin write must fail");
assert!(matches!(error, Error::Io { .. }));
assert_pid_not_live_now(read_pid(&pid_path));
assert_pid_not_live_now(read_pid(&gpid_path));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_process_group_opt_out_kills_only_direct_child() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let gpid_path = workdir.path().join("gpid");
let (_dir, path) = group_script(&pid_path, &gpid_path);
let claude = Claude::builder()
.binary(&path)
.process_group(false)
.build()
.expect("build");
let pid = drop_in_flight_and_capture_pid(run_claude(&claude, vec![]), &pid_path).await;
assert_pid_killed(pid);
let gpid = read_pid(&gpid_path);
let out = std::process::Command::new("ps")
.args(["-o", "stat=", "-p", &gpid.to_string()])
.output()
.expect("run ps");
let stat = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(
out.status.success() && !stat.is_empty() && !stat.starts_with('Z'),
"grandchild {gpid} should have survived the opt-out drop (stat {stat:?})"
);
let _ = std::process::Command::new("kill")
.args(["-9", &gpid.to_string()])
.status();
}
fn term_trap_script(marker: &std::path::Path) -> (tempfile::TempDir, std::path::PathBuf) {
fake_script(&format!(
concat!(
"trap 'echo term > \"{m}\"; exit 0' TERM\n",
"sleep 300 &\n",
"wait $!",
),
m = marker.display(),
))
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_timeout_with_grace_delivers_sigterm_first() {
let mut observed = false;
for _ in 0..5 {
let workdir = tempfile::tempdir().expect("workdir");
let marker = workdir.path().join("term-marker");
let (_dir, path) = term_trap_script(&marker);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_millis(500))
.kill_grace(Duration::from_secs(1))
.build()
.expect("build");
let err = run_claude(&claude, vec![]).await.unwrap_err();
assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
if marker.exists() {
observed = true;
break;
}
}
assert!(observed, "TERM marker never appeared within 5 timeout runs");
}
#[cfg(feature = "sync")]
#[test]
fn sync_timeout_with_grace_delivers_sigterm_first() {
let mut observed = false;
for _ in 0..5 {
let workdir = tempfile::tempdir().expect("workdir");
let marker = workdir.path().join("term-marker");
let (_dir, path) = term_trap_script(&marker);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_millis(500))
.kill_grace(Duration::from_secs(1))
.build()
.expect("build");
let err = run_claude_sync(&claude, vec![]).unwrap_err();
assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
if marker.exists() {
observed = true;
break;
}
}
assert!(observed, "TERM marker never appeared within 5 timeout runs");
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_dropping_in_flight_stdin_future_kills_child() {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let (_dir, path) = fake_script(&format!(
r#"echo $$ > "{}"; exec sleep 30"#,
pid_path.display()
));
let claude = client(&path);
let pid = drop_in_flight_and_capture_pid(
run_claude_with_stdin_prompt(&claude, vec![], "x".into()),
&pid_path,
)
.await;
assert_pid_killed(pid);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn async_spawn_failure_maps_io() {
let claude = Claude::builder()
.binary("/nonexistent/definitely/not/here")
.build()
.expect("build");
let err = run_claude(&claude, vec![]).await.unwrap_err();
assert!(matches!(err, Error::Io { .. }), "got: {err:?}");
}
#[cfg(feature = "sync")]
#[test]
fn sync_success_maps_output() {
let (_dir, path) = fake_script(r#"echo "hi sync"; exit 0"#);
let out = run_claude_sync(&client(&path), vec![]).expect("success");
assert!(out.success);
assert!(out.stdout.contains("hi sync"));
}
#[cfg(feature = "sync")]
#[test]
fn sync_nonzero_exit_maps_command_failed() {
let (_dir, path) = fake_script(r#"echo "boom" >&2; exit 3"#);
let err = run_claude_sync(&client(&path), vec![]).unwrap_err();
match err {
Error::CommandFailed {
exit_code, stderr, ..
} => {
assert_eq!(exit_code, 3);
assert!(stderr.contains("boom"));
}
other => panic!("expected CommandFailed, got {other:?}"),
}
}
#[cfg(feature = "sync")]
#[test]
fn sync_scrubs_claude_env_vars() {
let (_dir, path) =
fake_script(r#"echo "CC=[${CLAUDECODE:-}] EP=[${CLAUDE_CODE_ENTRYPOINT:-}]""#);
set_scrub_vars();
let out = run_claude_sync(&client(&path), vec![]).expect("success");
clear_scrub_vars();
assert!(out.stdout.contains("CC=[]"), "got: {}", out.stdout);
assert!(out.stdout.contains("EP=[]"), "got: {}", out.stdout);
}
#[cfg(feature = "sync")]
#[test]
fn sync_stdin_prompt_round_trips() {
let (_dir, path) = fake_script(r#"cat"#);
let out = run_claude_with_stdin_prompt_sync(&client(&path), vec![], "sync stdin".into())
.expect("success");
assert!(out.stdout.contains("sync stdin"));
}
#[cfg(feature = "sync")]
#[test]
fn sync_spawn_retry_passes_through_non_txtbsy_error() {
let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary");
let err =
spawn_retrying_txtbsy_sync(&mut cmd).expect_err("spawn of missing binary should fail");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "got: {err:?}");
}
#[cfg(feature = "sync")]
#[test]
fn sync_allow_exit_codes_permits_listed_code() {
let (_dir, path) = fake_script(r#"echo out; exit 2"#);
let out = run_claude_allow_exit_codes_sync(&client(&path), vec![], &[2])
.expect("allowed code is Ok");
assert!(!out.success);
assert_eq!(out.exit_code, 2);
}
#[cfg(feature = "sync")]
#[test]
fn sync_timeout_fires_on_slow_child() {
let (_dir, path) = fake_script(r#"sleep 3; echo done"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_millis(300))
.build()
.expect("build");
let err = run_claude_sync(&claude, vec![]).unwrap_err();
assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
}
#[cfg(feature = "sync")]
#[test]
fn sync_timeout_kills_process_group() {
let mut observed = false;
for _ in 0..5 {
let workdir = tempfile::tempdir().expect("workdir");
let pid_path = workdir.path().join("pid");
let gpid_path = workdir.path().join("gpid");
let (_dir, path) = group_script(&pid_path, &gpid_path);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_millis(1000))
.build()
.expect("build");
let err = run_claude_sync(&claude, vec![]).unwrap_err();
assert!(matches!(err, Error::Timeout { .. }), "got: {err:?}");
if let (Some(pid), Some(gpid)) = (try_read_pid(&pid_path), try_read_pid(&gpid_path)) {
assert_pid_killed(pid);
assert_pid_killed(gpid);
observed = true;
break;
}
}
assert!(observed, "child never recorded pids within 5 timeout runs");
}
#[cfg(feature = "sync")]
#[test]
fn sync_timeout_path_returns_output_when_fast() {
let (_dir, path) = fake_script(r#"echo quick"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_secs(30))
.build()
.expect("build");
let out = run_claude_sync(&claude, vec![]).expect("success");
assert!(out.stdout.contains("quick"));
}
#[cfg(feature = "sync")]
#[test]
fn sync_stdin_with_timeout_round_trips() {
let (_dir, path) = fake_script(r#"cat"#);
let claude = Claude::builder()
.binary(&path)
.timeout(Duration::from_secs(30))
.build()
.expect("build");
let out = run_claude_with_stdin_prompt_sync(&claude, vec![], "sync piped".into())
.expect("success");
assert!(out.stdout.contains("sync piped"));
}
}