use std::fmt;
use std::time::{Duration, Instant};
use tokio::process::Command;
use tracing::{Instrument, Span, debug, field, info_span};
use crate::Codex;
use crate::error::{Error, Result};
pub(crate) fn command_span(name: &'static str, codex: &Codex, args: &[String]) -> Span {
let working_dir = codex
.working_dir
.as_ref()
.map_or_else(|| "(inherited)".to_string(), |p| p.display().to_string());
info_span!(
parent: Span::current(),
"codex",
otel.name = name,
subcommand = args.first().map_or("(none)", String::as_str),
binary = %codex.binary.display(),
working_dir = %working_dir,
outcome = field::Empty,
exit_code = field::Empty,
duration_ms = field::Empty,
)
}
pub(crate) struct SpanOutcome {
span: Span,
started: Instant,
settled: bool,
}
impl SpanOutcome {
pub(crate) fn start(span: Span) -> Self {
Self {
span,
started: Instant::now(),
settled: false,
}
}
pub(crate) fn settle(&mut self, outcome: &'static str, exit_code: Option<i32>) {
self.settled = true;
self.span.record("outcome", outcome);
self.span
.record("duration_ms", self.started.elapsed().as_millis() as u64);
if let Some(code) = exit_code {
self.span.record("exit_code", code);
}
}
fn settle_from_ref(&mut self, result: &Result<CommandOutput>) {
self.settle_from(result);
}
fn settle_from(&mut self, result: &Result<CommandOutput>) {
match result {
Ok(output) => self.settle("ok", Some(output.exit_code)),
Err(Error::Timeout { .. }) => self.settle("timeout", None),
Err(e) => match e.exit_code() {
Some(code) => self.settle("failed", Some(code)),
None => self.settle("error", None),
},
}
}
}
impl Drop for SpanOutcome {
fn drop(&mut self) {
if !self.settled {
self.settle("cancelled", None);
}
}
}
#[cfg(unix)]
pub(crate) fn own_process_group(cmd: &mut Command, enabled: bool) {
if enabled {
cmd.process_group(0);
}
}
#[cfg(not(unix))]
pub(crate) fn own_process_group(_cmd: &mut Command, _enabled: bool) {}
#[cfg(unix)]
pub(crate) fn signal_group(pid: u32, signal: i32) {
let Ok(pid) = i32::try_from(pid) else {
return;
};
unsafe {
libc::kill(-pid, signal);
}
}
pub(crate) struct GroupKillGuard {
pid: Option<u32>,
}
impl GroupKillGuard {
pub(crate) fn new(pid: Option<u32>) -> Self {
Self { pid }
}
pub(crate) fn disarm(&mut self) {
self.pid = None;
}
#[cfg(unix)]
pub(crate) async fn terminate(&mut self, grace: Duration) {
let Some(pid) = self.pid.take() else {
return;
};
signal_group(pid, libc::SIGTERM);
tokio::time::sleep(grace).await;
signal_group(pid, libc::SIGKILL);
}
#[cfg(not(unix))]
pub(crate) async fn terminate(&mut self, _grace: Duration) {
let _ = self.pid.take();
}
}
impl Drop for GroupKillGuard {
fn drop(&mut self) {
if let Some(pid) = self.pid.take() {
#[cfg(unix)]
signal_group(pid, libc::SIGKILL);
#[cfg(not(unix))]
let _ = pid;
}
}
}
#[derive(Clone)]
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub success: bool,
}
const DEBUG_TRUNCATE_LEN: usize = 200;
fn truncate_for_debug(s: &str) -> String {
if s.len() > DEBUG_TRUNCATE_LEN {
format!("{}... ({} bytes total)", &s[..DEBUG_TRUNCATE_LEN], s.len())
} else {
s.to_string()
}
}
impl fmt::Debug for CommandOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommandOutput")
.field("stdout", &truncate_for_debug(&self.stdout))
.field("stderr", &truncate_for_debug(&self.stderr))
.field("exit_code", &self.exit_code)
.field("success", &self.success)
.finish()
}
}
pub async fn run_codex(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
run_codex_with_retry(codex, args, None).await
}
pub async fn run_codex_with_retry(
codex: &Codex,
args: Vec<String>,
retry_override: Option<&crate::retry::RetryPolicy>,
) -> Result<CommandOutput> {
let policy = retry_override.or(codex.retry_policy.as_ref());
match policy {
Some(policy) => {
let span = info_span!(
"codex.retry",
subcommand = args.first().map_or("(none)", String::as_str),
max_attempts = policy.max_attempts,
);
crate::retry::with_retry(policy, || run_codex_once(codex, args.clone()))
.instrument(span)
.await
}
None => run_codex_once(codex, args).await,
}
}
pub(crate) fn assemble_args(codex: &Codex, args: Vec<String>) -> Vec<String> {
let mut command_args = Vec::with_capacity(codex.global_args.len() + args.len());
command_args.extend(codex.global_args.iter().cloned());
command_args.extend(args);
command_args
}
pub(crate) fn command_string(codex: &Codex, args: Vec<String>) -> String {
let mut out = shell_quote(&codex.binary.display().to_string());
for arg in assemble_args(codex, args) {
out.push(' ');
out.push_str(&shell_quote(&arg));
}
out
}
pub(crate) fn shell_quote(arg: &str) -> String {
if arg.is_empty() {
return "''".to_string();
}
if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}*?!~#".contains(c)) {
return format!("'{}'", arg.replace('\'', r"'\''"));
}
arg.to_string()
}
async fn run_codex_once(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
let span = command_span("codex.exec", codex, &args);
let outcome_span = span.clone();
let command_args = assemble_args(codex, args);
async move {
debug!(binary = %codex.binary.display(), args = ?command_args, "executing codex command");
let mut outcome = SpanOutcome::start(outcome_span);
let result = match codex.timeout {
Some(timeout) => {
run_with_timeout(
&codex.binary,
&command_args,
&codex.env,
codex.working_dir.as_deref(),
timeout,
codex.process_group,
)
.await
}
None => {
run_internal(
&codex.binary,
&command_args,
&codex.env,
codex.working_dir.as_deref(),
codex.process_group,
)
.await
}
};
outcome.settle_from(&result);
result
}
.instrument(span)
.await
}
pub async fn run_codex_cancellable<C>(
codex: &Codex,
args: Vec<String>,
cancel: C,
) -> Result<CommandOutput>
where
C: std::future::Future<Output = ()> + Send,
{
let span = command_span("codex.exec", codex, &args);
let outcome_span = span.clone();
let command_args = assemble_args(codex, args);
async move {
debug!(binary = %codex.binary.display(), args = ?command_args, "executing cancellable codex command");
let mut outcome = SpanOutcome::start(outcome_span);
let result = run_internal_inner(
SpawnSpec {
binary: &codex.binary,
args: &command_args,
env: &codex.env,
working_dir: codex.working_dir.as_deref(),
stdin_prompt: None,
process_group: codex.process_group,
},
Some(Box::pin(cancel)),
codex.termination_grace,
)
.await;
match &result {
Err(Error::Cancelled { .. }) => outcome.settle("cancelled", None),
other => outcome.settle_from_ref(other),
}
result
}
.instrument(span)
.await
}
pub async fn run_codex_allow_exit_codes(
codex: &Codex,
args: Vec<String>,
allowed_codes: &[i32],
) -> Result<CommandOutput> {
let output = run_codex(codex, args).await;
match output {
Err(e)
if e.exit_code()
.is_some_and(|code| allowed_codes.contains(&code)) =>
{
let exit_code = e.exit_code().unwrap_or(-1);
let (stdout, stderr) = match &e {
Error::CommandFailed { stdout, stderr, .. } => (stdout.clone(), stderr.clone()),
Error::Auth { message, .. }
| Error::Config { message, .. }
| Error::NotTrustedDirectory { message, .. }
| Error::SessionNotFound { message, .. } => (String::new(), message.clone()),
_ => (String::new(), String::new()),
};
Ok(CommandOutput {
stdout,
stderr,
exit_code,
success: false,
})
}
other => other,
}
}
pub async fn run_codex_with_stdin_prompt(
codex: &Codex,
args: Vec<String>,
prompt: &str,
) -> Result<CommandOutput> {
let span = command_span("codex.exec", codex, &args);
let outcome_span = span.clone();
let command_args = assemble_args(codex, args);
async move {
debug!(
binary = %codex.binary.display(),
args = ?command_args,
prompt_bytes = prompt.len(),
"executing codex command with a stdin prompt"
);
let mut outcome = SpanOutcome::start(outcome_span);
let run = run_internal_inner(
SpawnSpec {
binary: &codex.binary,
args: &command_args,
env: &codex.env,
working_dir: codex.working_dir.as_deref(),
stdin_prompt: Some(prompt),
process_group: codex.process_group,
},
None,
Duration::from_secs(0),
);
let result = match codex.timeout {
Some(timeout) => match tokio::time::timeout(timeout, run).await {
Ok(result) => result,
Err(_) => Err(Error::Timeout {
timeout_seconds: timeout.as_secs(),
}),
},
None => run.await,
};
outcome.settle_from(&result);
result
}
.instrument(span)
.await
}
async fn run_internal(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
working_dir: Option<&std::path::Path>,
process_group: bool,
) -> Result<CommandOutput> {
run_internal_inner(
SpawnSpec {
binary,
args,
env,
working_dir,
stdin_prompt: None,
process_group,
},
None,
Duration::from_secs(0),
)
.await
}
type CancelFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>;
struct SpawnSpec<'a> {
binary: &'a std::path::Path,
args: &'a [String],
env: &'a std::collections::HashMap<String, String>,
working_dir: Option<&'a std::path::Path>,
stdin_prompt: Option<&'a str>,
process_group: bool,
}
async fn run_internal_inner(
spec: SpawnSpec<'_>,
cancel: Option<CancelFuture<'_>>,
grace: Duration,
) -> Result<CommandOutput> {
let SpawnSpec {
binary,
args,
env,
working_dir,
stdin_prompt,
process_group,
} = spec;
let mut cmd = Command::new(binary);
cmd.args(args);
if stdin_prompt.is_some() {
cmd.stdin(std::process::Stdio::piped());
} else {
cmd.stdin(std::process::Stdio::null());
}
cmd.kill_on_drop(true);
own_process_group(&mut cmd, process_group);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
for (key, value) in env {
cmd.env(key, value);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| Error::Io {
message: format!("failed to spawn codex: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})?;
let mut group = GroupKillGuard::new(process_group.then(|| child.id()).flatten());
let child_stdin = child.stdin.take();
let write = async move {
let (Some(prompt), Some(mut stdin)) = (stdin_prompt, child_stdin) else {
return Ok(());
};
use tokio::io::AsyncWriteExt;
stdin.write_all(prompt.as_bytes()).await?;
stdin.shutdown().await
};
let run = async { tokio::join!(write, child.wait_with_output()) };
let finished = match cancel {
None => Some(run.await),
Some(cancel) => tokio::select! {
outcome = run => Some(outcome),
() = cancel => None,
},
};
let Some((write_result, output_result)) = finished else {
group.terminate(grace).await;
return Err(Error::Cancelled {
grace_seconds: grace.as_secs(),
});
};
write_result.map_err(|e| Error::Io {
message: format!("failed to write the prompt to codex stdin: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})?;
let output = output_result.map_err(|e| Error::Io {
message: format!("failed to wait on codex: {e}"),
source: e,
working_dir: working_dir.map(|p| p.to_path_buf()),
})?;
group.disarm();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
if !output.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,
})
}
async fn run_with_timeout(
binary: &std::path::Path,
args: &[String],
env: &std::collections::HashMap<String, String>,
working_dir: Option<&std::path::Path>,
timeout: Duration,
process_group: bool,
) -> Result<CommandOutput> {
tokio::time::timeout(
timeout,
run_internal(binary, args, env, working_dir, process_group),
)
.await
.map_err(|_| Error::Timeout {
timeout_seconds: timeout.as_secs(),
})?
}
#[cfg(test)]
mod tests {
use super::*;
fn make_output(stdout: &str, stderr: &str) -> CommandOutput {
CommandOutput {
stdout: stdout.to_string(),
stderr: stderr.to_string(),
exit_code: 0,
success: true,
}
}
#[test]
fn shell_quote_leaves_plain_words_alone() {
assert_eq!(shell_quote("exec"), "exec");
assert_eq!(shell_quote("--ephemeral"), "--ephemeral");
assert_eq!(shell_quote("model=gpt-5"), "model=gpt-5");
}
#[test]
fn shell_quote_wraps_anything_a_shell_would_read() {
assert_eq!(shell_quote("fix the tests"), "'fix the tests'");
assert_eq!(shell_quote("$HOME"), "'$HOME'");
assert_eq!(shell_quote("a;b"), "'a;b'");
assert_eq!(shell_quote("*.rs"), "'*.rs'");
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
#[test]
fn shell_quote_keeps_the_empty_argument_visible() {
assert_eq!(shell_quote(""), "''");
}
#[test]
fn debug_short_output_not_truncated() {
let output = make_output("hello", "world");
let debug = format!("{output:?}");
assert!(debug.contains("hello"));
assert!(debug.contains("world"));
assert!(!debug.contains("bytes total"));
}
#[test]
fn debug_long_output_truncated() {
let long = "x".repeat(300);
let output = make_output(&long, &long);
let debug = format!("{output:?}");
assert!(debug.contains("... (300 bytes total)"));
assert!(!debug.contains(&long));
}
#[cfg(unix)]
#[tokio::test]
async fn timeout_kills_the_spawned_process() {
use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
let pid_file = PidFile::new("exec-timeout");
let codex = blocking_codex(&pid_file)
.timeout(Duration::from_millis(500))
.build()
.expect("bash must exist");
let result = run_codex(&codex, vec!["exec".into(), "probe".into()]).await;
assert!(
matches!(result, Err(Error::Timeout { .. })),
"expected timeout error, got: {result:?}"
);
let pid = pid_file.read_pid().await;
assert!(
wait_until_gone(pid).await,
"codex ({pid}) survived the timeout"
);
}
#[cfg(unix)]
#[tokio::test]
async fn cancellation_kills_the_spawned_process() {
use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
let pid_file = PidFile::new("exec-cancel");
let codex = blocking_codex(&pid_file).build().expect("bash must exist");
let cancelled = tokio::time::timeout(
Duration::from_millis(500),
run_codex(&codex, vec!["exec".into(), "probe".into()]),
)
.await;
assert!(
cancelled.is_err(),
"fake codex should still have been running, got: {cancelled:?}"
);
let pid = pid_file.read_pid().await;
assert!(
wait_until_gone(pid).await,
"codex ({pid}) survived the dropped future"
);
}
#[cfg(unix)]
mod recorder {
use std::cell::RefCell;
use std::sync::{Arc, Mutex, Once};
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing::{Event, Metadata, Subscriber};
type Sink = Arc<Mutex<Vec<(String, String)>>>;
thread_local! {
static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
}
struct Global;
impl Global {
fn collect(f: impl FnOnce(&mut Vec<(String, String)>)) {
SINK.with(|sink| {
if let Some(sink) = sink.borrow().as_ref() {
f(&mut sink.lock().unwrap());
}
});
}
}
impl Subscriber for Global {
fn enabled(&self, _: &Metadata<'_>) -> bool {
true
}
fn new_span(&self, attrs: &Attributes<'_>) -> Id {
Self::collect(|fields| attrs.record(&mut Collect(fields)));
Id::from_u64(1)
}
fn record(&self, _: &Id, values: &Record<'_>) {
Self::collect(|fields| values.record(&mut Collect(fields)));
}
fn record_follows_from(&self, _: &Id, _: &Id) {}
fn event(&self, _: &Event<'_>) {}
fn enter(&self, _: &Id) {}
fn exit(&self, _: &Id) {}
}
struct Collect<'a>(&'a mut Vec<(String, String)>);
impl Visit for Collect<'_> {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.0.push((field.name().into(), format!("{value:?}")));
}
fn record_str(&mut self, field: &Field, value: &str) {
self.0.push((field.name().into(), value.into()));
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.0.push((field.name().into(), value.to_string()));
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.0.push((field.name().into(), value.to_string()));
}
}
pub(super) struct Recorder(Sink);
impl Recorder {
pub(super) fn install() -> Self {
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = tracing::subscriber::set_global_default(Global);
});
let sink: Sink = Arc::new(Mutex::new(Vec::new()));
SINK.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&sink)));
Self(sink)
}
pub(super) fn dump(&self) -> String {
format!("{:?}", self.0.lock().unwrap())
}
pub(super) fn value(&self, field: &str) -> Option<String> {
self.0
.lock()
.unwrap()
.iter()
.rev()
.find(|(name, _)| name == field)
.map(|(_, value)| value.clone())
}
}
impl Drop for Recorder {
fn drop(&mut self) {
SINK.with(|slot| *slot.borrow_mut() = None);
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn span_records_the_subcommand_and_a_clean_outcome() {
let recorder = recorder::Recorder::install();
let codex = Codex::builder()
.binary("/bin/echo")
.build()
.expect("echo must exist");
run_codex(&codex, vec!["exec".into()]).await.unwrap();
assert_eq!(recorder.value("subcommand").as_deref(), Some("exec"));
assert_eq!(recorder.value("outcome").as_deref(), Some("ok"));
assert_eq!(recorder.value("exit_code").as_deref(), Some("0"));
assert!(recorder.value("duration_ms").is_some());
}
#[cfg(unix)]
#[tokio::test]
async fn span_does_not_carry_the_prompt() {
let recorder = recorder::Recorder::install();
let codex = Codex::builder()
.binary("/bin/echo")
.build()
.expect("echo must exist");
run_codex(&codex, vec!["exec".into(), "a very secret prompt".into()])
.await
.unwrap();
let recorded = format!("{:?}", recorder.value("subcommand"));
assert!(!recorded.contains("secret"));
for field in ["binary", "working_dir", "outcome", "exit_code"] {
let value = recorder.value(field).unwrap_or_default();
assert!(
!value.contains("secret"),
"{field} leaked the prompt: {value}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn a_cancelled_run_is_recorded_as_cancelled() {
let recorder = recorder::Recorder::install();
let pid_file = crate::test_support::PidFile::new("span-cancel");
let codex = crate::test_support::blocking_codex(&pid_file)
.build()
.expect("bash must exist");
let cancelled = tokio::time::timeout(
Duration::from_millis(300),
run_codex(&codex, vec!["exec".into()]),
)
.await;
assert!(cancelled.is_err(), "the run should still have been going");
assert_eq!(
recorder.value("outcome").as_deref(),
Some("cancelled"),
"recorded: {}",
recorder.dump()
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_timed_out_run_is_recorded_as_timeout() {
let recorder = recorder::Recorder::install();
let pid_file = crate::test_support::PidFile::new("span-timeout");
let codex = crate::test_support::blocking_codex(&pid_file)
.timeout(Duration::from_millis(300))
.build()
.expect("bash must exist");
let result = run_codex(&codex, vec!["exec".into()]).await;
assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
assert_eq!(recorder.value("outcome").as_deref(), Some("timeout"));
}
#[cfg(unix)]
fn failing_codex(case: &str) -> Codex {
let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fake-codex-failure.sh");
Codex::builder()
.binary("/bin/bash")
.arg(script.to_str().unwrap())
.env("CODEX_WRAPPER_TEST_FAILURE", case)
.build()
.expect("bash must exist")
}
#[cfg(unix)]
#[tokio::test]
async fn a_real_spawn_returns_a_classified_error() {
use crate::error::FailureKind;
for (case, expected) in [
("auth", FailureKind::Auth),
("not-trusted", FailureKind::NotTrustedDirectory),
("config", FailureKind::Config),
("session", FailureKind::SessionNotFound),
("mystery", FailureKind::Unclassified),
] {
let codex = failing_codex(case);
let err = run_codex(&codex, vec!["exec".into()]).await.unwrap_err();
assert_eq!(err.failure_kind(), Some(expected), "case {case}: {err}");
}
}
#[cfg(unix)]
#[tokio::test]
async fn a_classified_failure_is_not_retried() {
let policy = crate::retry::RetryPolicy::new()
.max_attempts(3)
.initial_backoff(Duration::from_millis(1))
.retry_on_exit_codes([1]);
let started = Instant::now();
let codex = failing_codex("auth");
let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
.await
.unwrap_err();
assert!(matches!(err, Error::Auth { .. }), "{err}");
assert!(
started.elapsed() < Duration::from_secs(2),
"looks like it retried: {:?}",
started.elapsed()
);
}
#[cfg(unix)]
#[tokio::test]
async fn an_unclassified_failure_still_retries() {
let policy = crate::retry::RetryPolicy::new()
.max_attempts(2)
.initial_backoff(Duration::from_millis(1))
.retry_on_exit_codes([1]);
let codex = failing_codex("mystery");
let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
.await
.unwrap_err();
assert!(matches!(err, Error::CommandFailed { .. }), "{err}");
}
#[cfg(unix)]
#[tokio::test]
async fn allowed_exit_codes_still_apply_to_a_classified_failure() {
let codex = failing_codex("auth");
let output = run_codex_allow_exit_codes(&codex, vec!["exec".into()], &[1])
.await
.expect("exit code 1 was allowed");
assert_eq!(output.exit_code, 1);
assert!(!output.success);
assert!(
output.stderr.contains("401 Unauthorized"),
"{}",
output.stderr
);
}
#[cfg(unix)]
fn spawning_codex(label: &str) -> (Codex, crate::test_support::PidFile) {
let pid_file = crate::test_support::PidFile::new(label);
let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fake-codex-spawns-child.sh");
let codex = Codex::builder()
.binary("/bin/bash")
.arg(script.to_str().unwrap())
.env(
"CODEX_WRAPPER_TEST_PIDFILE",
pid_file.path().to_str().unwrap(),
)
.build()
.expect("bash must exist");
(codex, pid_file)
}
#[cfg(unix)]
async fn read_pids(pid_file: &crate::test_support::PidFile) -> (u32, u32) {
for _ in 0..200 {
if let Ok(contents) = std::fs::read_to_string(pid_file.path()) {
let parse = |prefix: &str| -> Option<u32> {
contents
.lines()
.find_map(|l| l.strip_prefix(prefix))
.and_then(|v| v.trim().parse().ok())
};
if let (Some(parent), Some(child)) = (parse("parent="), parse("child=")) {
return (parent, child);
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("the fake codex never recorded both pids");
}
#[cfg(unix)]
#[tokio::test]
async fn cancelling_kills_the_whole_process_group() {
use crate::test_support::wait_until_gone;
let (codex, pid_file) = spawning_codex("group-drop");
let cancelled = tokio::time::timeout(
Duration::from_millis(400),
run_codex(&codex, vec!["exec".into()]),
)
.await;
assert!(cancelled.is_err(), "the run should still have been going");
let (parent, child) = read_pids(&pid_file).await;
assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
assert!(
wait_until_gone(child).await,
"the subprocess ({child}) survived the cancelled run"
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_codex_cancellable_stops_the_group_gracefully() {
use crate::test_support::wait_until_gone;
let (codex, pid_file) = spawning_codex("group-cancel");
let codex = Codex::builder()
.binary(codex.binary())
.arg(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fake-codex-spawns-child.sh")
.to_str()
.unwrap(),
)
.env(
"CODEX_WRAPPER_TEST_PIDFILE",
pid_file.path().to_str().unwrap(),
)
.termination_grace(Duration::from_millis(50))
.build()
.unwrap();
let cancel = async {
tokio::time::sleep(Duration::from_millis(300)).await;
};
let result = run_codex_cancellable(&codex, vec!["exec".into()], cancel).await;
assert!(
matches!(result, Err(Error::Cancelled { .. })),
"expected a cancellation, got: {result:?}"
);
let (parent, child) = read_pids(&pid_file).await;
assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
assert!(
wait_until_gone(child).await,
"the subprocess ({child}) survived cancellation"
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_run_that_finishes_first_is_not_cancelled() {
let codex = Codex::builder()
.binary("/bin/echo")
.build()
.expect("echo must exist");
let never = std::future::pending::<()>();
let output = run_codex_cancellable(&codex, vec!["exec".into()], never)
.await
.unwrap();
assert!(output.success);
}
#[cfg(unix)]
#[tokio::test]
async fn opting_out_of_process_groups_leaves_the_subprocess() {
use crate::test_support::wait_until_gone;
let pid_file = crate::test_support::PidFile::new("group-optout");
let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fake-codex-spawns-child.sh");
let codex = Codex::builder()
.binary("/bin/bash")
.arg(script.to_str().unwrap())
.env(
"CODEX_WRAPPER_TEST_PIDFILE",
pid_file.path().to_str().unwrap(),
)
.process_group(false)
.build()
.expect("bash must exist");
let cancelled = tokio::time::timeout(
Duration::from_millis(400),
run_codex(&codex, vec!["exec".into()]),
)
.await;
assert!(cancelled.is_err(), "the run should still have been going");
let (parent, child) = read_pids(&pid_file).await;
assert!(
wait_until_gone(parent).await,
"kill_on_drop still reaps the direct child ({parent})"
);
assert!(
crate::test_support::is_running_for_test(child),
"with groups off, the subprocess ({child}) is expected to survive"
);
signal_group(child, libc::SIGKILL);
unsafe { libc::kill(i32::try_from(child).unwrap_or(0), libc::SIGKILL) };
}
}