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, cmd.stdin_prompt(), 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 crate::exec::apply_child_environment(&mut child_cmd, codex.clear_env, &codex.env);
114
115 let mut child = child_cmd.spawn().map_err(|e| Error::Io {
116 message: format!("failed to spawn codex: {e}"),
117 source: e,
118 working_dir: codex.working_dir.clone(),
119 })?;
120
121 let mut group =
125 crate::exec::GroupKillGuard::new(codex.process_group.then(|| child.id()).flatten());
126
127 let stdout = child.stdout.take().expect("stdout was configured as piped");
128 let stderr = child.stderr.take().expect("stderr was configured as piped");
129 let child_stdin = child.stdin.take();
132
133 let stdin_task = async {
137 let (Some(prompt), Some(mut stdin)) = (stdin_prompt, child_stdin) else {
138 return Ok(());
139 };
140 use tokio::io::AsyncWriteExt;
141 stdin
142 .write_all(prompt.as_bytes())
143 .await
144 .map_err(|e| Error::Io {
145 message: format!("failed to write the prompt to codex stdin: {e}"),
146 source: e,
147 working_dir: codex.working_dir.clone(),
148 })?;
149 stdin.shutdown().await.map_err(|e| Error::Io {
150 message: format!("failed to close codex stdin: {e}"),
151 source: e,
152 working_dir: codex.working_dir.clone(),
153 })
154 };
155
156 let stdout_task = async {
157 let reader = BufReader::new(stdout);
158 let mut lines = reader.lines();
159 while let Some(line) = lines.next_line().await.map_err(|e| Error::Io {
160 message: format!("failed to read stdout line: {e}"),
161 source: e,
162 working_dir: codex.working_dir.clone(),
163 })? {
164 if line.trim_start().starts_with('{') {
165 match serde_json::from_str::<JsonLineEvent>(&line) {
166 Ok(event) => handler(event),
167 Err(source) => {
168 return Err(Error::Json {
169 message: format!("failed to parse JSONL event: {line}"),
170 source,
171 });
172 }
173 }
174 }
175 }
176 Ok::<(), Error>(())
177 };
178
179 let stderr_task = async {
180 let reader = BufReader::new(stderr);
181 let mut lines = reader.lines();
182 let mut collected = String::new();
183 while let Some(line) = lines.next_line().await.map_err(|e| Error::Io {
184 message: format!("failed to read stderr line: {e}"),
185 source: e,
186 working_dir: codex.working_dir.clone(),
187 })? {
188 if !collected.is_empty() {
189 collected.push('\n');
190 }
191 collected.push_str(&line);
192 }
193 Ok::<String, Error>(collected)
194 };
195
196 let stream_future = async {
197 let (stdin_result, stdout_result, stderr_result) =
198 tokio::join!(stdin_task, stdout_task, stderr_task);
199 stdin_result?;
200 stdout_result?;
201 let stderr_output = stderr_result?;
202
203 let status = child.wait().await.map_err(|e| Error::Io {
204 message: format!("failed to wait on codex process: {e}"),
205 source: e,
206 working_dir: codex.working_dir.clone(),
207 })?;
208
209 let exit_code = status.code().unwrap_or(-1);
210 if !status.success() {
211 outcome.settle("failed", Some(exit_code));
212 return Err(Error::from_command_failure(
213 format!("{} {}", codex.binary.display(), command_args.join(" ")),
214 exit_code,
215 String::new(),
216 stderr_output,
217 codex.working_dir.clone(),
218 ));
219 }
220
221 outcome.settle("ok", Some(exit_code));
222 group.disarm();
223 Ok(())
224 };
225
226 drop(_span_guard);
230
231 if let Some(timeout) = codex.timeout {
232 match tokio::time::timeout(timeout, stream_future.instrument(span.clone())).await {
236 Ok(result) => result,
237 Err(_) => Err(Error::Timeout {
238 timeout_seconds: timeout.as_secs(),
239 }),
240 }
241 } else {
242 stream_future.instrument(span).await
243 }
244}
245
246#[cfg(all(test, unix))]
247mod tests {
248 use super::*;
249 use std::sync::{Arc, Mutex};
250
251 fn fake_codex(script_name: &str) -> Codex {
253 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
254 .join("tests")
255 .join(script_name);
256 Codex::builder()
257 .binary("/bin/bash")
258 .arg(script.to_str().unwrap())
259 .build()
260 .expect("bash must exist")
261 }
262
263 #[tokio::test]
264 async fn stream_exec_delivers_events() {
265 let codex = fake_codex("fake-codex.sh");
266 let cmd = crate::command::exec::ExecCommand::new("test prompt").json();
267 let events = Arc::new(Mutex::new(Vec::new()));
268 let events_clone = Arc::clone(&events);
269
270 stream_exec(&codex, &cmd, move |event| {
271 events_clone.lock().unwrap().push(event);
272 })
273 .await
274 .unwrap();
275
276 let events = events.lock().unwrap();
277 assert!(!events.is_empty(), "expected at least one event");
278
279 let types: Vec<&str> = events.iter().map(|e| e.event_type.as_str()).collect();
280 assert!(
281 types.contains(&"thread.started"),
282 "expected thread.started, got: {types:?}"
283 );
284 assert!(
285 types.contains(&"turn.completed"),
286 "expected turn.completed, got: {types:?}"
287 );
288 }
289
290 #[tokio::test]
294 async fn stream_exec_delivers_an_event_while_the_child_is_still_running() {
295 let codex = Codex::builder()
296 .binary("/bin/bash")
297 .arg("-c")
298 .arg(
299 "printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-early\"}'; sleep 10",
300 )
301 .build()
302 .expect("bash must exist");
303 let cmd = crate::command::exec::ExecCommand::new("probe").json();
304 let (sent, delivered) = tokio::sync::oneshot::channel();
305 let mut sent = Some(sent);
306
307 let task = tokio::spawn(async move {
308 stream_exec(&codex, &cmd, move |event| {
309 if event.thread_id() == Some("thread-early")
310 && let Some(sent) = sent.take()
311 {
312 let _ = sent.send(());
313 }
314 })
315 .await
316 });
317
318 let delivered = tokio::time::timeout(std::time::Duration::from_secs(1), delivered).await;
319 let still_running = !task.is_finished();
320 task.abort();
321 let _ = task.await;
322
323 assert!(delivered.is_ok(), "callback waited for the child to exit");
324 assert!(
325 still_running,
326 "the fixture must still be running when the callback fires"
327 );
328 }
329
330 #[tokio::test]
331 async fn stream_exec_resume_delivers_events() {
332 let codex = fake_codex("fake-codex.sh");
333 let cmd = crate::command::exec::ExecResumeCommand::new().last().json();
334 let events = Arc::new(Mutex::new(Vec::new()));
335 let events_clone = Arc::clone(&events);
336
337 stream_exec_resume(&codex, &cmd, move |event| {
338 events_clone.lock().unwrap().push(event);
339 })
340 .await
341 .unwrap();
342
343 let events = events.lock().unwrap();
344 assert!(!events.is_empty(), "expected at least one event");
345 }
346
347 #[tokio::test]
351 async fn cleared_environment_reaches_every_streaming_variant() {
352 let capture = crate::test_support::EnvCapture::new("env-streaming");
353 let codex = crate::test_support::env_capturing_codex(&capture)
354 .clear_env()
355 .env("CODEX_WRAPPER_EXPLICIT", "streaming")
356 .build()
357 .expect("bash must exist");
358
359 crate::ExecCommand::new("opening")
360 .stream(&codex, |_| {})
361 .await
362 .unwrap();
363 let opening_environment = capture.read();
364 assert!(!opening_environment.contains_key("PATH"));
365 assert_eq!(
366 opening_environment
367 .get("CODEX_WRAPPER_EXPLICIT")
368 .map(String::as_str),
369 Some("streaming")
370 );
371
372 crate::ExecCommand::new("stdin")
373 .prompt_via_stdin()
374 .stream(&codex, |_| {})
375 .await
376 .unwrap();
377 let stdin_environment = capture.read();
378 assert!(!stdin_environment.contains_key("PATH"));
379 assert_eq!(
380 stdin_environment
381 .get("CODEX_WRAPPER_EXPLICIT")
382 .map(String::as_str),
383 Some("streaming")
384 );
385
386 crate::ExecResumeCommand::new()
387 .last()
388 .stream(&codex, |_| {})
389 .await
390 .unwrap();
391 let resume_environment = capture.read();
392 assert!(!resume_environment.contains_key("PATH"));
393 assert_eq!(
394 resume_environment
395 .get("CODEX_WRAPPER_EXPLICIT")
396 .map(String::as_str),
397 Some("streaming")
398 );
399 }
400
401 #[tokio::test]
405 async fn stream_exec_classifies_native_rollout_budget_exhaustion() {
406 let codex = fake_codex("fake-codex-rollout-budget.sh");
407 let cmd = crate::command::exec::ExecCommand::new("probe").json();
408 let events = Arc::new(Mutex::new(Vec::new()));
409 let collected = Arc::clone(&events);
410
411 let error = stream_exec(&codex, &cmd, move |event| {
412 collected.lock().unwrap().push(event);
413 })
414 .await
415 .expect_err("captured rollout exhaustion exits non-zero");
416
417 assert_eq!(error.exit_code(), Some(1));
418 let events = events.lock().unwrap();
419 let terminal = events
420 .iter()
421 .find(|event| event.is_turn_failed())
422 .expect("turn.failed must be delivered");
423 assert_eq!(
424 terminal.turn_failure_kind(),
425 Some(crate::TurnFailureKind::RolloutBudgetExhausted)
426 );
427 assert_eq!(terminal.usage(), None);
428 assert_eq!(crate::QueryResult::from_events(events.clone()).result, "ok");
429 }
430
431 #[tokio::test]
432 async fn stream_exec_timeout() {
433 let codex = Codex::builder()
434 .binary("/bin/bash")
435 .arg("-c")
436 .arg("sleep 10")
437 .timeout(std::time::Duration::from_millis(50))
438 .build()
439 .unwrap();
440
441 let cmd = crate::command::exec::ExecCommand::new("test").json();
442 let result = stream_exec(&codex, &cmd, |_| {}).await;
443
444 assert!(
445 matches!(result, Err(Error::Timeout { .. })),
446 "expected timeout error, got: {result:?}"
447 );
448 }
449
450 #[tokio::test]
453 async fn stream_exec_timeout_kills_the_spawned_process() {
454 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
455
456 let pid_file = PidFile::new("stream-timeout");
457 let codex = blocking_codex(&pid_file)
458 .timeout(std::time::Duration::from_millis(500))
459 .build()
460 .expect("bash must exist");
461
462 let cmd = crate::command::exec::ExecCommand::new("probe").json();
463 let result = stream_exec(&codex, &cmd, |_| {}).await;
464 assert!(
465 matches!(result, Err(Error::Timeout { .. })),
466 "expected timeout error, got: {result:?}"
467 );
468
469 let pid = pid_file.read_pid().await;
470 assert!(
471 wait_until_gone(pid).await,
472 "codex ({pid}) survived the timeout"
473 );
474 }
475
476 #[tokio::test]
478 async fn stream_exec_cancellation_kills_the_spawned_process() {
479 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
480
481 let pid_file = PidFile::new("stream-cancel");
482 let codex = blocking_codex(&pid_file).build().expect("bash must exist");
483
484 let cmd = crate::command::exec::ExecCommand::new("probe").json();
485 let cancelled = tokio::time::timeout(
486 std::time::Duration::from_millis(500),
487 stream_exec(&codex, &cmd, |_| {}),
488 )
489 .await;
490 assert!(
491 cancelled.is_err(),
492 "fake codex should still have been running, got: {cancelled:?}"
493 );
494
495 let pid = pid_file.read_pid().await;
496 assert!(
497 wait_until_gone(pid).await,
498 "codex ({pid}) survived the dropped future"
499 );
500 }
501
502 #[tokio::test]
503 async fn stream_exec_parse_error() {
504 let codex = fake_codex("fake-codex-bad-json.sh");
505 let cmd = crate::command::exec::ExecCommand::new("test").json();
506 let result = stream_exec(&codex, &cmd, |_| {}).await;
507
508 assert!(
509 matches!(result, Err(Error::Json { .. })),
510 "expected json parse error, got: {result:?}"
511 );
512 }
513}