1use tokio::io::{AsyncBufReadExt, BufReader};
26use tokio::process::Command;
27use tracing::{Instrument, debug};
28
29use crate::Codex;
30use crate::command::CodexCommand;
31use crate::error::{Error, Result};
32use crate::types::JsonLineEvent;
33
34pub async fn stream_exec<F>(
40 codex: &Codex,
41 cmd: &crate::command::exec::ExecCommand,
42 handler: F,
43) -> Result<()>
44where
45 F: FnMut(JsonLineEvent),
46{
47 let mut args = cmd.args();
48 if !args.contains(&"--json".to_string()) {
49 args.push("--json".into());
50 }
51 run_streaming(codex, args, cmd.stdin_prompt(), handler).await
52}
53
54pub async fn stream_exec_resume<F>(
57 codex: &Codex,
58 cmd: &crate::command::exec::ExecResumeCommand,
59 handler: F,
60) -> Result<()>
61where
62 F: FnMut(JsonLineEvent),
63{
64 let mut args = cmd.args();
65 if !args.contains(&"--json".to_string()) {
66 args.push("--json".into());
67 }
68 run_streaming(codex, args, None, handler).await
69}
70
71async fn run_streaming<F>(
76 codex: &Codex,
77 args: Vec<String>,
78 stdin_prompt: Option<&str>,
79 mut handler: F,
80) -> Result<()>
81where
82 F: FnMut(JsonLineEvent),
83{
84 let span = crate::exec::command_span("codex.stream", codex, &args);
85 let command_args = crate::exec::assemble_args(codex, args);
86 let _span_guard = span.clone().entered();
87
88 debug!(binary = %codex.binary.display(), args = ?command_args, "streaming codex command");
89
90 let mut outcome = crate::exec::SpanOutcome::start(span.clone());
93
94 let mut child_cmd = Command::new(&codex.binary);
95 child_cmd.args(&command_args);
96 if stdin_prompt.is_some() {
97 child_cmd.stdin(std::process::Stdio::piped());
98 } else {
99 child_cmd.stdin(std::process::Stdio::null());
100 }
101 child_cmd.stdout(std::process::Stdio::piped());
102 child_cmd.stderr(std::process::Stdio::piped());
103
104 child_cmd.kill_on_drop(true);
108 crate::exec::own_process_group(&mut child_cmd, codex.process_group);
109
110 if let Some(dir) = &codex.working_dir {
111 child_cmd.current_dir(dir);
112 }
113 for (key, value) in &codex.env {
114 child_cmd.env(key, value);
115 }
116
117 let mut child = child_cmd.spawn().map_err(|e| Error::Io {
118 message: format!("failed to spawn codex: {e}"),
119 source: e,
120 working_dir: codex.working_dir.clone(),
121 })?;
122
123 let mut group =
127 crate::exec::GroupKillGuard::new(codex.process_group.then(|| child.id()).flatten());
128
129 let stdout = child.stdout.take().expect("stdout was configured as piped");
130 let stderr = child.stderr.take().expect("stderr was configured as piped");
131 let child_stdin = child.stdin.take();
134
135 let stdin_task = async {
139 let (Some(prompt), Some(mut stdin)) = (stdin_prompt, child_stdin) else {
140 return Ok(());
141 };
142 use tokio::io::AsyncWriteExt;
143 stdin
144 .write_all(prompt.as_bytes())
145 .await
146 .map_err(|e| Error::Io {
147 message: format!("failed to write the prompt to codex stdin: {e}"),
148 source: e,
149 working_dir: codex.working_dir.clone(),
150 })?;
151 stdin.shutdown().await.map_err(|e| Error::Io {
152 message: format!("failed to close codex stdin: {e}"),
153 source: e,
154 working_dir: codex.working_dir.clone(),
155 })
156 };
157
158 let stdout_task = async {
159 let reader = BufReader::new(stdout);
160 let mut lines = reader.lines();
161 while let Some(line) = lines.next_line().await.map_err(|e| Error::Io {
162 message: format!("failed to read stdout line: {e}"),
163 source: e,
164 working_dir: codex.working_dir.clone(),
165 })? {
166 if line.trim_start().starts_with('{') {
167 match serde_json::from_str::<JsonLineEvent>(&line) {
168 Ok(event) => handler(event),
169 Err(source) => {
170 return Err(Error::Json {
171 message: format!("failed to parse JSONL event: {line}"),
172 source,
173 });
174 }
175 }
176 }
177 }
178 Ok::<(), Error>(())
179 };
180
181 let stderr_task = async {
182 let reader = BufReader::new(stderr);
183 let mut lines = reader.lines();
184 let mut collected = String::new();
185 while let Some(line) = lines.next_line().await.map_err(|e| Error::Io {
186 message: format!("failed to read stderr line: {e}"),
187 source: e,
188 working_dir: codex.working_dir.clone(),
189 })? {
190 if !collected.is_empty() {
191 collected.push('\n');
192 }
193 collected.push_str(&line);
194 }
195 Ok::<String, Error>(collected)
196 };
197
198 let stream_future = async {
199 let (stdin_result, stdout_result, stderr_result) =
200 tokio::join!(stdin_task, stdout_task, stderr_task);
201 stdin_result?;
202 stdout_result?;
203 let stderr_output = stderr_result?;
204
205 let status = child.wait().await.map_err(|e| Error::Io {
206 message: format!("failed to wait on codex process: {e}"),
207 source: e,
208 working_dir: codex.working_dir.clone(),
209 })?;
210
211 let exit_code = status.code().unwrap_or(-1);
212 if !status.success() {
213 outcome.settle("failed", Some(exit_code));
214 return Err(Error::from_command_failure(
215 format!("{} {}", codex.binary.display(), command_args.join(" ")),
216 exit_code,
217 String::new(),
218 stderr_output,
219 codex.working_dir.clone(),
220 ));
221 }
222
223 outcome.settle("ok", Some(exit_code));
224 group.disarm();
225 Ok(())
226 };
227
228 drop(_span_guard);
232
233 if let Some(timeout) = codex.timeout {
234 match tokio::time::timeout(timeout, stream_future.instrument(span.clone())).await {
238 Ok(result) => result,
239 Err(_) => Err(Error::Timeout {
240 timeout_seconds: timeout.as_secs(),
241 }),
242 }
243 } else {
244 stream_future.instrument(span).await
245 }
246}
247
248#[cfg(all(test, unix))]
249mod tests {
250 use super::*;
251 use std::sync::{Arc, Mutex};
252
253 fn fake_codex(script_name: &str) -> Codex {
255 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
256 .join("tests")
257 .join(script_name);
258 Codex::builder()
259 .binary("/bin/bash")
260 .arg(script.to_str().unwrap())
261 .build()
262 .expect("bash must exist")
263 }
264
265 #[tokio::test]
266 async fn stream_exec_delivers_events() {
267 let codex = fake_codex("fake-codex.sh");
268 let cmd = crate::command::exec::ExecCommand::new("test prompt").json();
269 let events = Arc::new(Mutex::new(Vec::new()));
270 let events_clone = Arc::clone(&events);
271
272 stream_exec(&codex, &cmd, move |event| {
273 events_clone.lock().unwrap().push(event);
274 })
275 .await
276 .unwrap();
277
278 let events = events.lock().unwrap();
279 assert!(!events.is_empty(), "expected at least one event");
280
281 let types: Vec<&str> = events.iter().map(|e| e.event_type.as_str()).collect();
282 assert!(
283 types.contains(&"thread.started"),
284 "expected thread.started, got: {types:?}"
285 );
286 assert!(
287 types.contains(&"turn.completed"),
288 "expected turn.completed, got: {types:?}"
289 );
290 }
291
292 #[tokio::test]
296 async fn stream_exec_delivers_an_event_while_the_child_is_still_running() {
297 let codex = Codex::builder()
298 .binary("/bin/bash")
299 .arg("-c")
300 .arg(
301 "printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-early\"}'; sleep 10",
302 )
303 .build()
304 .expect("bash must exist");
305 let cmd = crate::command::exec::ExecCommand::new("probe").json();
306 let (sent, delivered) = tokio::sync::oneshot::channel();
307 let mut sent = Some(sent);
308
309 let task = tokio::spawn(async move {
310 stream_exec(&codex, &cmd, move |event| {
311 if event.thread_id() == Some("thread-early")
312 && let Some(sent) = sent.take()
313 {
314 let _ = sent.send(());
315 }
316 })
317 .await
318 });
319
320 let delivered = tokio::time::timeout(std::time::Duration::from_secs(1), delivered).await;
321 let still_running = !task.is_finished();
322 task.abort();
323 let _ = task.await;
324
325 assert!(delivered.is_ok(), "callback waited for the child to exit");
326 assert!(
327 still_running,
328 "the fixture must still be running when the callback fires"
329 );
330 }
331
332 #[tokio::test]
333 async fn stream_exec_resume_delivers_events() {
334 let codex = fake_codex("fake-codex.sh");
335 let cmd = crate::command::exec::ExecResumeCommand::new().last().json();
336 let events = Arc::new(Mutex::new(Vec::new()));
337 let events_clone = Arc::clone(&events);
338
339 stream_exec_resume(&codex, &cmd, move |event| {
340 events_clone.lock().unwrap().push(event);
341 })
342 .await
343 .unwrap();
344
345 let events = events.lock().unwrap();
346 assert!(!events.is_empty(), "expected at least one event");
347 }
348
349 #[tokio::test]
350 async fn stream_exec_timeout() {
351 let codex = Codex::builder()
352 .binary("/bin/bash")
353 .arg("-c")
354 .arg("sleep 10")
355 .timeout(std::time::Duration::from_millis(50))
356 .build()
357 .unwrap();
358
359 let cmd = crate::command::exec::ExecCommand::new("test").json();
360 let result = stream_exec(&codex, &cmd, |_| {}).await;
361
362 assert!(
363 matches!(result, Err(Error::Timeout { .. })),
364 "expected timeout error, got: {result:?}"
365 );
366 }
367
368 #[tokio::test]
371 async fn stream_exec_timeout_kills_the_spawned_process() {
372 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
373
374 let pid_file = PidFile::new("stream-timeout");
375 let codex = blocking_codex(&pid_file)
376 .timeout(std::time::Duration::from_millis(500))
377 .build()
378 .expect("bash must exist");
379
380 let cmd = crate::command::exec::ExecCommand::new("probe").json();
381 let result = stream_exec(&codex, &cmd, |_| {}).await;
382 assert!(
383 matches!(result, Err(Error::Timeout { .. })),
384 "expected timeout error, got: {result:?}"
385 );
386
387 let pid = pid_file.read_pid().await;
388 assert!(
389 wait_until_gone(pid).await,
390 "codex ({pid}) survived the timeout"
391 );
392 }
393
394 #[tokio::test]
396 async fn stream_exec_cancellation_kills_the_spawned_process() {
397 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
398
399 let pid_file = PidFile::new("stream-cancel");
400 let codex = blocking_codex(&pid_file).build().expect("bash must exist");
401
402 let cmd = crate::command::exec::ExecCommand::new("probe").json();
403 let cancelled = tokio::time::timeout(
404 std::time::Duration::from_millis(500),
405 stream_exec(&codex, &cmd, |_| {}),
406 )
407 .await;
408 assert!(
409 cancelled.is_err(),
410 "fake codex should still have been running, got: {cancelled:?}"
411 );
412
413 let pid = pid_file.read_pid().await;
414 assert!(
415 wait_until_gone(pid).await,
416 "codex ({pid}) survived the dropped future"
417 );
418 }
419
420 #[tokio::test]
421 async fn stream_exec_parse_error() {
422 let codex = fake_codex("fake-codex-bad-json.sh");
423 let cmd = crate::command::exec::ExecCommand::new("test").json();
424 let result = stream_exec(&codex, &cmd, |_| {}).await;
425
426 assert!(
427 matches!(result, Err(Error::Json { .. })),
428 "expected json parse error, got: {result:?}"
429 );
430 }
431}