1use std::fmt;
5use std::time::{Duration, Instant};
6
7use tokio::process::Command;
8use tracing::{Instrument, Span, debug, field, info_span};
9
10use crate::Codex;
11use crate::error::{Error, Result};
12
13pub(crate) fn command_span(name: &'static str, codex: &Codex, args: &[String]) -> Span {
19 let working_dir = codex
20 .working_dir
21 .as_ref()
22 .map_or_else(|| "(inherited)".to_string(), |p| p.display().to_string());
23
24 info_span!(
25 parent: Span::current(),
26 "codex",
27 otel.name = name,
28 subcommand = args.first().map_or("(none)", String::as_str),
29 binary = %codex.binary.display(),
30 working_dir = %working_dir,
31 outcome = field::Empty,
32 exit_code = field::Empty,
33 duration_ms = field::Empty,
34 )
35}
36
37pub(crate) struct SpanOutcome {
47 span: Span,
48 started: Instant,
49 settled: bool,
50}
51
52impl SpanOutcome {
53 pub(crate) fn start(span: Span) -> Self {
54 Self {
55 span,
56 started: Instant::now(),
57 settled: false,
58 }
59 }
60
61 pub(crate) fn settle(&mut self, outcome: &'static str, exit_code: Option<i32>) {
62 self.settled = true;
63 self.span.record("outcome", outcome);
64 self.span
65 .record("duration_ms", self.started.elapsed().as_millis() as u64);
66 if let Some(code) = exit_code {
67 self.span.record("exit_code", code);
68 }
69 }
70
71 fn settle_from_ref(&mut self, result: &Result<CommandOutput>) {
72 self.settle_from(result);
73 }
74
75 fn settle_from(&mut self, result: &Result<CommandOutput>) {
76 match result {
77 Ok(output) => self.settle("ok", Some(output.exit_code)),
78 Err(Error::Timeout { .. }) => self.settle("timeout", None),
79 Err(e) => match e.exit_code() {
82 Some(code) => self.settle("failed", Some(code)),
83 None => self.settle("error", None),
84 },
85 }
86 }
87}
88
89impl Drop for SpanOutcome {
90 fn drop(&mut self) {
91 if !self.settled {
92 self.settle("cancelled", None);
93 }
94 }
95}
96
97#[cfg(unix)]
104pub(crate) fn own_process_group(cmd: &mut Command, enabled: bool) {
105 if enabled {
106 cmd.process_group(0);
107 }
108}
109
110#[cfg(not(unix))]
111pub(crate) fn own_process_group(_cmd: &mut Command, _enabled: bool) {}
112
113#[cfg(unix)]
122pub(crate) fn signal_group(pid: u32, signal: i32) {
123 let Ok(pid) = i32::try_from(pid) else {
124 return;
125 };
126 unsafe {
129 libc::kill(-pid, signal);
130 }
131}
132
133pub(crate) struct GroupKillGuard {
140 pid: Option<u32>,
141}
142
143impl GroupKillGuard {
144 pub(crate) fn new(pid: Option<u32>) -> Self {
145 Self { pid }
146 }
147
148 pub(crate) fn disarm(&mut self) {
150 self.pid = None;
151 }
152
153 #[cfg(unix)]
157 pub(crate) async fn terminate(&mut self, grace: Duration) {
158 let Some(pid) = self.pid.take() else {
159 return;
160 };
161 signal_group(pid, libc::SIGTERM);
162 tokio::time::sleep(grace).await;
163 signal_group(pid, libc::SIGKILL);
164 }
165
166 #[cfg(not(unix))]
169 pub(crate) async fn terminate(&mut self, _grace: Duration) {
170 let _ = self.pid.take();
171 }
172}
173
174impl Drop for GroupKillGuard {
175 fn drop(&mut self) {
176 if let Some(pid) = self.pid.take() {
179 #[cfg(unix)]
180 signal_group(pid, libc::SIGKILL);
181 #[cfg(not(unix))]
182 let _ = pid;
183 }
184 }
185}
186
187#[derive(Clone)]
192pub struct CommandOutput {
193 pub stdout: String,
195 pub stderr: String,
197 pub exit_code: i32,
199 pub success: bool,
201}
202
203const DEBUG_TRUNCATE_LEN: usize = 200;
204
205fn truncate_for_debug(s: &str) -> String {
206 if s.len() > DEBUG_TRUNCATE_LEN {
207 format!("{}... ({} bytes total)", &s[..DEBUG_TRUNCATE_LEN], s.len())
208 } else {
209 s.to_string()
210 }
211}
212
213impl fmt::Debug for CommandOutput {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 f.debug_struct("CommandOutput")
216 .field("stdout", &truncate_for_debug(&self.stdout))
217 .field("stderr", &truncate_for_debug(&self.stderr))
218 .field("exit_code", &self.exit_code)
219 .field("success", &self.success)
220 .finish()
221 }
222}
223
224pub async fn run_codex(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
230 run_codex_with_retry(codex, args, None).await
231}
232
233pub async fn run_codex_with_retry(
235 codex: &Codex,
236 args: Vec<String>,
237 retry_override: Option<&crate::retry::RetryPolicy>,
238) -> Result<CommandOutput> {
239 let policy = retry_override.or(codex.retry_policy.as_ref());
240
241 match policy {
242 Some(policy) => {
243 let span = info_span!(
246 "codex.retry",
247 subcommand = args.first().map_or("(none)", String::as_str),
248 max_attempts = policy.max_attempts,
249 );
250 crate::retry::with_retry(policy, || run_codex_once(codex, args.clone()))
251 .instrument(span)
252 .await
253 }
254 None => run_codex_once(codex, args).await,
255 }
256}
257
258pub(crate) fn assemble_args(codex: &Codex, args: Vec<String>) -> Vec<String> {
266 let mut command_args = Vec::with_capacity(codex.global_args.len() + args.len());
267 command_args.extend(codex.global_args.iter().cloned());
268 command_args.extend(args);
269 command_args
270}
271
272pub(crate) fn command_string(codex: &Codex, args: Vec<String>) -> String {
274 let mut out = shell_quote(&codex.binary.display().to_string());
275 for arg in assemble_args(codex, args) {
276 out.push(' ');
277 out.push_str(&shell_quote(&arg));
278 }
279 out
280}
281
282pub(crate) fn shell_quote(arg: &str) -> String {
288 if arg.is_empty() {
289 return "''".to_string();
290 }
291 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}*?!~#".contains(c)) {
292 return format!("'{}'", arg.replace('\'', r"'\''"));
293 }
294 arg.to_string()
295}
296
297async fn run_codex_once(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
298 let span = command_span("codex.exec", codex, &args);
299 let outcome_span = span.clone();
300 let command_args = assemble_args(codex, args);
301
302 async move {
303 debug!(binary = %codex.binary.display(), args = ?command_args, "executing codex command");
304
305 let mut outcome = SpanOutcome::start(outcome_span);
306 let result = match codex.timeout {
307 Some(timeout) => {
308 run_with_timeout(
309 &codex.binary,
310 &command_args,
311 &codex.env,
312 codex.working_dir.as_deref(),
313 timeout,
314 codex.process_group,
315 )
316 .await
317 }
318 None => {
319 run_internal(
320 &codex.binary,
321 &command_args,
322 &codex.env,
323 codex.working_dir.as_deref(),
324 codex.process_group,
325 )
326 .await
327 }
328 };
329 outcome.settle_from(&result);
330 result
331 }
332 .instrument(span)
333 .await
334}
335
336pub async fn run_codex_cancellable<C>(
352 codex: &Codex,
353 args: Vec<String>,
354 cancel: C,
355) -> Result<CommandOutput>
356where
357 C: std::future::Future<Output = ()> + Send,
358{
359 let span = command_span("codex.exec", codex, &args);
360 let outcome_span = span.clone();
361 let command_args = assemble_args(codex, args);
362
363 async move {
364 debug!(binary = %codex.binary.display(), args = ?command_args, "executing cancellable codex command");
365
366 let mut outcome = SpanOutcome::start(outcome_span);
367 let result = run_internal_inner(
368 SpawnSpec {
369 binary: &codex.binary,
370 args: &command_args,
371 env: &codex.env,
372 working_dir: codex.working_dir.as_deref(),
373 stdin_prompt: None,
374 process_group: codex.process_group,
375 },
376 Some(Box::pin(cancel)),
377 codex.termination_grace,
378 )
379 .await;
380
381 match &result {
382 Err(Error::Cancelled { .. }) => outcome.settle("cancelled", None),
383 other => outcome.settle_from_ref(other),
384 }
385 result
386 }
387 .instrument(span)
388 .await
389}
390
391pub async fn run_codex_allow_exit_codes(
393 codex: &Codex,
394 args: Vec<String>,
395 allowed_codes: &[i32],
396) -> Result<CommandOutput> {
397 let output = run_codex(codex, args).await;
398
399 match output {
400 Err(e)
404 if e.exit_code()
405 .is_some_and(|code| allowed_codes.contains(&code)) =>
406 {
407 let exit_code = e.exit_code().unwrap_or(-1);
408 let (stdout, stderr) = match &e {
409 Error::CommandFailed { stdout, stderr, .. } => (stdout.clone(), stderr.clone()),
410 Error::Auth { message, .. }
411 | Error::Config { message, .. }
412 | Error::NotTrustedDirectory { message, .. }
413 | Error::SessionNotFound { message, .. } => (String::new(), message.clone()),
414 _ => (String::new(), String::new()),
415 };
416 Ok(CommandOutput {
417 stdout,
418 stderr,
419 exit_code,
420 success: false,
421 })
422 }
423 other => other,
424 }
425}
426
427pub async fn run_codex_with_stdin_prompt(
439 codex: &Codex,
440 args: Vec<String>,
441 prompt: &str,
442) -> Result<CommandOutput> {
443 let span = command_span("codex.exec", codex, &args);
444 let outcome_span = span.clone();
445 let command_args = assemble_args(codex, args);
446
447 async move {
448 debug!(
449 binary = %codex.binary.display(),
450 args = ?command_args,
451 prompt_bytes = prompt.len(),
452 "executing codex command with a stdin prompt"
453 );
454
455 let mut outcome = SpanOutcome::start(outcome_span);
456 let run = run_internal_inner(
457 SpawnSpec {
458 binary: &codex.binary,
459 args: &command_args,
460 env: &codex.env,
461 working_dir: codex.working_dir.as_deref(),
462 stdin_prompt: Some(prompt),
463 process_group: codex.process_group,
464 },
465 None,
466 Duration::from_secs(0),
467 );
468
469 let result = match codex.timeout {
470 Some(timeout) => match tokio::time::timeout(timeout, run).await {
471 Ok(result) => result,
472 Err(_) => Err(Error::Timeout {
473 timeout_seconds: timeout.as_secs(),
474 }),
475 },
476 None => run.await,
477 };
478 outcome.settle_from(&result);
479 result
480 }
481 .instrument(span)
482 .await
483}
484
485async fn run_internal(
486 binary: &std::path::Path,
487 args: &[String],
488 env: &std::collections::HashMap<String, String>,
489 working_dir: Option<&std::path::Path>,
490 process_group: bool,
491) -> Result<CommandOutput> {
492 run_internal_inner(
493 SpawnSpec {
494 binary,
495 args,
496 env,
497 working_dir,
498 stdin_prompt: None,
499 process_group,
500 },
501 None,
502 Duration::from_secs(0),
503 )
504 .await
505}
506
507type CancelFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>;
508
509struct SpawnSpec<'a> {
511 binary: &'a std::path::Path,
512 args: &'a [String],
513 env: &'a std::collections::HashMap<String, String>,
514 working_dir: Option<&'a std::path::Path>,
515 stdin_prompt: Option<&'a str>,
517 process_group: bool,
519}
520
521async fn run_internal_inner(
522 spec: SpawnSpec<'_>,
523 cancel: Option<CancelFuture<'_>>,
524 grace: Duration,
525) -> Result<CommandOutput> {
526 let SpawnSpec {
527 binary,
528 args,
529 env,
530 working_dir,
531 stdin_prompt,
532 process_group,
533 } = spec;
534 let mut cmd = Command::new(binary);
535 cmd.args(args);
536
537 if stdin_prompt.is_some() {
540 cmd.stdin(std::process::Stdio::piped());
541 } else {
542 cmd.stdin(std::process::Stdio::null());
543 }
544
545 cmd.kill_on_drop(true);
549 own_process_group(&mut cmd, process_group);
550
551 if let Some(dir) = working_dir {
552 cmd.current_dir(dir);
553 }
554
555 for (key, value) in env {
556 cmd.env(key, value);
557 }
558
559 cmd.stdout(std::process::Stdio::piped());
563 cmd.stderr(std::process::Stdio::piped());
564
565 let mut child = cmd.spawn().map_err(|e| Error::Io {
566 message: format!("failed to spawn codex: {e}"),
567 source: e,
568 working_dir: working_dir.map(|p| p.to_path_buf()),
569 })?;
570
571 let mut group = GroupKillGuard::new(process_group.then(|| child.id()).flatten());
576 let child_stdin = child.stdin.take();
577
578 let write = async move {
579 let (Some(prompt), Some(mut stdin)) = (stdin_prompt, child_stdin) else {
580 return Ok(());
581 };
582 use tokio::io::AsyncWriteExt;
583 stdin.write_all(prompt.as_bytes()).await?;
584 stdin.shutdown().await
587 };
588
589 let run = async { tokio::join!(write, child.wait_with_output()) };
594
595 let finished = match cancel {
596 None => Some(run.await),
597 Some(cancel) => tokio::select! {
601 outcome = run => Some(outcome),
602 () = cancel => None,
603 },
604 };
605
606 let Some((write_result, output_result)) = finished else {
607 group.terminate(grace).await;
608 return Err(Error::Cancelled {
609 grace_seconds: grace.as_secs(),
610 });
611 };
612
613 write_result.map_err(|e| Error::Io {
614 message: format!("failed to write the prompt to codex stdin: {e}"),
615 source: e,
616 working_dir: working_dir.map(|p| p.to_path_buf()),
617 })?;
618 let output = output_result.map_err(|e| Error::Io {
619 message: format!("failed to wait on codex: {e}"),
620 source: e,
621 working_dir: working_dir.map(|p| p.to_path_buf()),
622 })?;
623
624 group.disarm();
626
627 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
628 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
629 let exit_code = output.status.code().unwrap_or(-1);
630
631 if !output.status.success() {
632 return Err(Error::from_command_failure(
633 format!("{} {}", binary.display(), args.join(" ")),
634 exit_code,
635 stdout,
636 stderr,
637 working_dir.map(|p| p.to_path_buf()),
638 ));
639 }
640
641 Ok(CommandOutput {
642 stdout,
643 stderr,
644 exit_code,
645 success: true,
646 })
647}
648
649async fn run_with_timeout(
650 binary: &std::path::Path,
651 args: &[String],
652 env: &std::collections::HashMap<String, String>,
653 working_dir: Option<&std::path::Path>,
654 timeout: Duration,
655 process_group: bool,
656) -> Result<CommandOutput> {
657 tokio::time::timeout(
658 timeout,
659 run_internal(binary, args, env, working_dir, process_group),
660 )
661 .await
662 .map_err(|_| Error::Timeout {
663 timeout_seconds: timeout.as_secs(),
664 })?
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 fn make_output(stdout: &str, stderr: &str) -> CommandOutput {
672 CommandOutput {
673 stdout: stdout.to_string(),
674 stderr: stderr.to_string(),
675 exit_code: 0,
676 success: true,
677 }
678 }
679
680 #[test]
681 fn shell_quote_leaves_plain_words_alone() {
682 assert_eq!(shell_quote("exec"), "exec");
683 assert_eq!(shell_quote("--ephemeral"), "--ephemeral");
684 assert_eq!(shell_quote("model=gpt-5"), "model=gpt-5");
685 }
686
687 #[test]
688 fn shell_quote_wraps_anything_a_shell_would_read() {
689 assert_eq!(shell_quote("fix the tests"), "'fix the tests'");
690 assert_eq!(shell_quote("$HOME"), "'$HOME'");
691 assert_eq!(shell_quote("a;b"), "'a;b'");
692 assert_eq!(shell_quote("*.rs"), "'*.rs'");
693 assert_eq!(shell_quote("it's"), r"'it'\''s'");
694 }
695
696 #[test]
699 fn shell_quote_keeps_the_empty_argument_visible() {
700 assert_eq!(shell_quote(""), "''");
701 }
702
703 #[test]
704 fn debug_short_output_not_truncated() {
705 let output = make_output("hello", "world");
706 let debug = format!("{output:?}");
707 assert!(debug.contains("hello"));
708 assert!(debug.contains("world"));
709 assert!(!debug.contains("bytes total"));
710 }
711
712 #[test]
713 fn debug_long_output_truncated() {
714 let long = "x".repeat(300);
715 let output = make_output(&long, &long);
716 let debug = format!("{output:?}");
717 assert!(debug.contains("... (300 bytes total)"));
718 assert!(!debug.contains(&long));
719 }
720
721 #[cfg(unix)]
725 #[tokio::test]
726 async fn timeout_kills_the_spawned_process() {
727 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
728
729 let pid_file = PidFile::new("exec-timeout");
730 let codex = blocking_codex(&pid_file)
731 .timeout(Duration::from_millis(500))
732 .build()
733 .expect("bash must exist");
734
735 let result = run_codex(&codex, vec!["exec".into(), "probe".into()]).await;
736 assert!(
737 matches!(result, Err(Error::Timeout { .. })),
738 "expected timeout error, got: {result:?}"
739 );
740
741 let pid = pid_file.read_pid().await;
742 assert!(
743 wait_until_gone(pid).await,
744 "codex ({pid}) survived the timeout"
745 );
746 }
747
748 #[cfg(unix)]
751 #[tokio::test]
752 async fn cancellation_kills_the_spawned_process() {
753 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
754
755 let pid_file = PidFile::new("exec-cancel");
756 let codex = blocking_codex(&pid_file).build().expect("bash must exist");
757
758 let cancelled = tokio::time::timeout(
759 Duration::from_millis(500),
760 run_codex(&codex, vec!["exec".into(), "probe".into()]),
761 )
762 .await;
763 assert!(
764 cancelled.is_err(),
765 "fake codex should still have been running, got: {cancelled:?}"
766 );
767
768 let pid = pid_file.read_pid().await;
769 assert!(
770 wait_until_gone(pid).await,
771 "codex ({pid}) survived the dropped future"
772 );
773 }
774
775 #[cfg(unix)]
786 mod recorder {
787 use std::cell::RefCell;
788 use std::sync::{Arc, Mutex, Once};
789
790 use tracing::field::{Field, Visit};
791 use tracing::span::{Attributes, Id, Record};
792 use tracing::{Event, Metadata, Subscriber};
793
794 type Sink = Arc<Mutex<Vec<(String, String)>>>;
795
796 thread_local! {
797 static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
798 }
799
800 struct Global;
801
802 impl Global {
803 fn collect(f: impl FnOnce(&mut Vec<(String, String)>)) {
804 SINK.with(|sink| {
805 if let Some(sink) = sink.borrow().as_ref() {
806 f(&mut sink.lock().unwrap());
807 }
808 });
809 }
810 }
811
812 impl Subscriber for Global {
813 fn enabled(&self, _: &Metadata<'_>) -> bool {
815 true
816 }
817 fn new_span(&self, attrs: &Attributes<'_>) -> Id {
818 Self::collect(|fields| attrs.record(&mut Collect(fields)));
819 Id::from_u64(1)
820 }
821 fn record(&self, _: &Id, values: &Record<'_>) {
822 Self::collect(|fields| values.record(&mut Collect(fields)));
823 }
824 fn record_follows_from(&self, _: &Id, _: &Id) {}
825 fn event(&self, _: &Event<'_>) {}
826 fn enter(&self, _: &Id) {}
827 fn exit(&self, _: &Id) {}
828 }
829
830 struct Collect<'a>(&'a mut Vec<(String, String)>);
831
832 impl Visit for Collect<'_> {
833 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
834 self.0.push((field.name().into(), format!("{value:?}")));
835 }
836 fn record_str(&mut self, field: &Field, value: &str) {
837 self.0.push((field.name().into(), value.into()));
838 }
839 fn record_i64(&mut self, field: &Field, value: i64) {
840 self.0.push((field.name().into(), value.to_string()));
841 }
842 fn record_u64(&mut self, field: &Field, value: u64) {
843 self.0.push((field.name().into(), value.to_string()));
844 }
845 }
846
847 pub(super) struct Recorder(Sink);
848
849 impl Recorder {
850 pub(super) fn install() -> Self {
852 static INIT: Once = Once::new();
853 INIT.call_once(|| {
854 let _ = tracing::subscriber::set_global_default(Global);
855 });
856 let sink: Sink = Arc::new(Mutex::new(Vec::new()));
857 SINK.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&sink)));
858 Self(sink)
859 }
860
861 pub(super) fn dump(&self) -> String {
862 format!("{:?}", self.0.lock().unwrap())
863 }
864
865 pub(super) fn value(&self, field: &str) -> Option<String> {
866 self.0
867 .lock()
868 .unwrap()
869 .iter()
870 .rev()
871 .find(|(name, _)| name == field)
872 .map(|(_, value)| value.clone())
873 }
874 }
875
876 impl Drop for Recorder {
877 fn drop(&mut self) {
878 SINK.with(|slot| *slot.borrow_mut() = None);
879 }
880 }
881 }
882
883 #[cfg(unix)]
884 #[tokio::test]
885 async fn span_records_the_subcommand_and_a_clean_outcome() {
886 let recorder = recorder::Recorder::install();
887
888 let codex = Codex::builder()
889 .binary("/bin/echo")
890 .build()
891 .expect("echo must exist");
892 run_codex(&codex, vec!["exec".into()]).await.unwrap();
893
894 assert_eq!(recorder.value("subcommand").as_deref(), Some("exec"));
895 assert_eq!(recorder.value("outcome").as_deref(), Some("ok"));
896 assert_eq!(recorder.value("exit_code").as_deref(), Some("0"));
897 assert!(recorder.value("duration_ms").is_some());
898 }
899
900 #[cfg(unix)]
903 #[tokio::test]
904 async fn span_does_not_carry_the_prompt() {
905 let recorder = recorder::Recorder::install();
906
907 let codex = Codex::builder()
908 .binary("/bin/echo")
909 .build()
910 .expect("echo must exist");
911 run_codex(&codex, vec!["exec".into(), "a very secret prompt".into()])
912 .await
913 .unwrap();
914
915 let recorded = format!("{:?}", recorder.value("subcommand"));
916 assert!(!recorded.contains("secret"));
917 for field in ["binary", "working_dir", "outcome", "exit_code"] {
918 let value = recorder.value(field).unwrap_or_default();
919 assert!(
920 !value.contains("secret"),
921 "{field} leaked the prompt: {value}"
922 );
923 }
924 }
925
926 #[cfg(unix)]
929 #[tokio::test]
930 async fn a_cancelled_run_is_recorded_as_cancelled() {
931 let recorder = recorder::Recorder::install();
932
933 let pid_file = crate::test_support::PidFile::new("span-cancel");
934 let codex = crate::test_support::blocking_codex(&pid_file)
935 .build()
936 .expect("bash must exist");
937
938 let cancelled = tokio::time::timeout(
939 Duration::from_millis(300),
940 run_codex(&codex, vec!["exec".into()]),
941 )
942 .await;
943 assert!(cancelled.is_err(), "the run should still have been going");
944
945 assert_eq!(
946 recorder.value("outcome").as_deref(),
947 Some("cancelled"),
948 "recorded: {}",
949 recorder.dump()
950 );
951 }
952
953 #[cfg(unix)]
956 #[tokio::test]
957 async fn a_timed_out_run_is_recorded_as_timeout() {
958 let recorder = recorder::Recorder::install();
959
960 let pid_file = crate::test_support::PidFile::new("span-timeout");
961 let codex = crate::test_support::blocking_codex(&pid_file)
962 .timeout(Duration::from_millis(300))
963 .build()
964 .expect("bash must exist");
965
966 let result = run_codex(&codex, vec!["exec".into()]).await;
967 assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
968
969 assert_eq!(recorder.value("outcome").as_deref(), Some("timeout"));
970 }
971
972 #[cfg(unix)]
977 fn failing_codex(case: &str) -> Codex {
978 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
979 .join("tests")
980 .join("fake-codex-failure.sh");
981 Codex::builder()
982 .binary("/bin/bash")
983 .arg(script.to_str().unwrap())
984 .env("CODEX_WRAPPER_TEST_FAILURE", case)
985 .build()
986 .expect("bash must exist")
987 }
988
989 #[cfg(unix)]
992 #[tokio::test]
993 async fn a_real_spawn_returns_a_classified_error() {
994 use crate::error::FailureKind;
995
996 for (case, expected) in [
997 ("auth", FailureKind::Auth),
998 ("not-trusted", FailureKind::NotTrustedDirectory),
999 ("config", FailureKind::Config),
1000 ("session", FailureKind::SessionNotFound),
1001 ("mystery", FailureKind::Unclassified),
1002 ] {
1003 let codex = failing_codex(case);
1004 let err = run_codex(&codex, vec!["exec".into()]).await.unwrap_err();
1005 assert_eq!(err.failure_kind(), Some(expected), "case {case}: {err}");
1006 }
1007 }
1008
1009 #[cfg(unix)]
1013 #[tokio::test]
1014 async fn a_classified_failure_is_not_retried() {
1015 let policy = crate::retry::RetryPolicy::new()
1016 .max_attempts(3)
1017 .initial_backoff(Duration::from_millis(1))
1018 .retry_on_exit_codes([1]);
1019
1020 let started = Instant::now();
1021 let codex = failing_codex("auth");
1022 let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1023 .await
1024 .unwrap_err();
1025
1026 assert!(matches!(err, Error::Auth { .. }), "{err}");
1027 assert!(
1030 started.elapsed() < Duration::from_secs(2),
1031 "looks like it retried: {:?}",
1032 started.elapsed()
1033 );
1034 }
1035
1036 #[cfg(unix)]
1038 #[tokio::test]
1039 async fn an_unclassified_failure_still_retries() {
1040 let policy = crate::retry::RetryPolicy::new()
1041 .max_attempts(2)
1042 .initial_backoff(Duration::from_millis(1))
1043 .retry_on_exit_codes([1]);
1044
1045 let codex = failing_codex("mystery");
1046 let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1047 .await
1048 .unwrap_err();
1049
1050 assert!(matches!(err, Error::CommandFailed { .. }), "{err}");
1051 }
1052
1053 #[cfg(unix)]
1056 #[tokio::test]
1057 async fn allowed_exit_codes_still_apply_to_a_classified_failure() {
1058 let codex = failing_codex("auth");
1059 let output = run_codex_allow_exit_codes(&codex, vec!["exec".into()], &[1])
1060 .await
1061 .expect("exit code 1 was allowed");
1062
1063 assert_eq!(output.exit_code, 1);
1064 assert!(!output.success);
1065 assert!(
1066 output.stderr.contains("401 Unauthorized"),
1067 "{}",
1068 output.stderr
1069 );
1070 }
1071
1072 #[cfg(unix)]
1079 fn spawning_codex(label: &str) -> (Codex, crate::test_support::PidFile) {
1080 let pid_file = crate::test_support::PidFile::new(label);
1081 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1082 .join("tests")
1083 .join("fake-codex-spawns-child.sh");
1084 let codex = Codex::builder()
1085 .binary("/bin/bash")
1086 .arg(script.to_str().unwrap())
1087 .env(
1088 "CODEX_WRAPPER_TEST_PIDFILE",
1089 pid_file.path().to_str().unwrap(),
1090 )
1091 .build()
1092 .expect("bash must exist");
1093 (codex, pid_file)
1094 }
1095
1096 #[cfg(unix)]
1097 async fn read_pids(pid_file: &crate::test_support::PidFile) -> (u32, u32) {
1098 for _ in 0..200 {
1099 if let Ok(contents) = std::fs::read_to_string(pid_file.path()) {
1100 let parse = |prefix: &str| -> Option<u32> {
1101 contents
1102 .lines()
1103 .find_map(|l| l.strip_prefix(prefix))
1104 .and_then(|v| v.trim().parse().ok())
1105 };
1106 if let (Some(parent), Some(child)) = (parse("parent="), parse("child=")) {
1107 return (parent, child);
1108 }
1109 }
1110 tokio::time::sleep(Duration::from_millis(10)).await;
1111 }
1112 panic!("the fake codex never recorded both pids");
1113 }
1114
1115 #[cfg(unix)]
1118 #[tokio::test]
1119 async fn cancelling_kills_the_whole_process_group() {
1120 use crate::test_support::wait_until_gone;
1121
1122 let (codex, pid_file) = spawning_codex("group-drop");
1123
1124 let cancelled = tokio::time::timeout(
1125 Duration::from_millis(400),
1126 run_codex(&codex, vec!["exec".into()]),
1127 )
1128 .await;
1129 assert!(cancelled.is_err(), "the run should still have been going");
1130
1131 let (parent, child) = read_pids(&pid_file).await;
1132 assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
1133 assert!(
1134 wait_until_gone(child).await,
1135 "the subprocess ({child}) survived the cancelled run"
1136 );
1137 }
1138
1139 #[cfg(unix)]
1142 #[tokio::test]
1143 async fn run_codex_cancellable_stops_the_group_gracefully() {
1144 use crate::test_support::wait_until_gone;
1145
1146 let (codex, pid_file) = spawning_codex("group-cancel");
1147 let codex = Codex::builder()
1148 .binary(codex.binary())
1149 .arg(
1150 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1151 .join("tests")
1152 .join("fake-codex-spawns-child.sh")
1153 .to_str()
1154 .unwrap(),
1155 )
1156 .env(
1157 "CODEX_WRAPPER_TEST_PIDFILE",
1158 pid_file.path().to_str().unwrap(),
1159 )
1160 .termination_grace(Duration::from_millis(50))
1161 .build()
1162 .unwrap();
1163
1164 let cancel = async {
1165 tokio::time::sleep(Duration::from_millis(300)).await;
1166 };
1167 let result = run_codex_cancellable(&codex, vec!["exec".into()], cancel).await;
1168
1169 assert!(
1170 matches!(result, Err(Error::Cancelled { .. })),
1171 "expected a cancellation, got: {result:?}"
1172 );
1173
1174 let (parent, child) = read_pids(&pid_file).await;
1175 assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
1176 assert!(
1177 wait_until_gone(child).await,
1178 "the subprocess ({child}) survived cancellation"
1179 );
1180 }
1181
1182 #[cfg(unix)]
1185 #[tokio::test]
1186 async fn a_run_that_finishes_first_is_not_cancelled() {
1187 let codex = Codex::builder()
1188 .binary("/bin/echo")
1189 .build()
1190 .expect("echo must exist");
1191
1192 let never = std::future::pending::<()>();
1193 let output = run_codex_cancellable(&codex, vec!["exec".into()], never)
1194 .await
1195 .unwrap();
1196 assert!(output.success);
1197 }
1198
1199 #[cfg(unix)]
1204 #[tokio::test]
1205 async fn opting_out_of_process_groups_leaves_the_subprocess() {
1206 use crate::test_support::wait_until_gone;
1207
1208 let pid_file = crate::test_support::PidFile::new("group-optout");
1209 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1210 .join("tests")
1211 .join("fake-codex-spawns-child.sh");
1212 let codex = Codex::builder()
1213 .binary("/bin/bash")
1214 .arg(script.to_str().unwrap())
1215 .env(
1216 "CODEX_WRAPPER_TEST_PIDFILE",
1217 pid_file.path().to_str().unwrap(),
1218 )
1219 .process_group(false)
1220 .build()
1221 .expect("bash must exist");
1222
1223 let cancelled = tokio::time::timeout(
1224 Duration::from_millis(400),
1225 run_codex(&codex, vec!["exec".into()]),
1226 )
1227 .await;
1228 assert!(cancelled.is_err(), "the run should still have been going");
1229
1230 let (parent, child) = read_pids(&pid_file).await;
1231 assert!(
1232 wait_until_gone(parent).await,
1233 "kill_on_drop still reaps the direct child ({parent})"
1234 );
1235 assert!(
1238 crate::test_support::is_running_for_test(child),
1239 "with groups off, the subprocess ({child}) is expected to survive"
1240 );
1241 signal_group(child, libc::SIGKILL);
1243 unsafe { libc::kill(i32::try_from(child).unwrap_or(0), libc::SIGKILL) };
1244 }
1245}