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 let mut events = Vec::new();
162 while let Some(line) = lines.next_line().await.map_err(|e| Error::Io {
163 message: format!("failed to read stdout line: {e}"),
164 source: e,
165 working_dir: codex.working_dir.clone(),
166 })? {
167 if line.trim_start().starts_with('{') {
168 match serde_json::from_str::<JsonLineEvent>(&line) {
169 Ok(event) => events.push(event),
170 Err(source) => {
171 return Err(Error::Json {
172 message: format!("failed to parse JSONL event: {line}"),
173 source,
174 });
175 }
176 }
177 }
178 }
179 Ok::<Vec<JsonLineEvent>, Error>(events)
180 };
181
182 let stderr_task = async {
183 let reader = BufReader::new(stderr);
184 let mut lines = reader.lines();
185 let mut collected = String::new();
186 while let Some(line) = lines.next_line().await.map_err(|e| Error::Io {
187 message: format!("failed to read stderr line: {e}"),
188 source: e,
189 working_dir: codex.working_dir.clone(),
190 })? {
191 if !collected.is_empty() {
192 collected.push('\n');
193 }
194 collected.push_str(&line);
195 }
196 Ok::<String, Error>(collected)
197 };
198
199 let stream_future = async {
200 let (stdin_result, events_result, stderr_result) =
201 tokio::join!(stdin_task, stdout_task, stderr_task);
202 stdin_result?;
203 let events = events_result?;
204 let stderr_output = stderr_result?;
205
206 for event in events {
207 handler(event);
208 }
209
210 let status = child.wait().await.map_err(|e| Error::Io {
211 message: format!("failed to wait on codex process: {e}"),
212 source: e,
213 working_dir: codex.working_dir.clone(),
214 })?;
215
216 let exit_code = status.code().unwrap_or(-1);
217 if !status.success() {
218 outcome.settle("failed", Some(exit_code));
219 return Err(Error::from_command_failure(
220 format!("{} {}", codex.binary.display(), command_args.join(" ")),
221 exit_code,
222 String::new(),
223 stderr_output,
224 codex.working_dir.clone(),
225 ));
226 }
227
228 outcome.settle("ok", Some(exit_code));
229 group.disarm();
230 Ok(())
231 };
232
233 drop(_span_guard);
237
238 if let Some(timeout) = codex.timeout {
239 match tokio::time::timeout(timeout, stream_future.instrument(span.clone())).await {
243 Ok(result) => result,
244 Err(_) => Err(Error::Timeout {
245 timeout_seconds: timeout.as_secs(),
246 }),
247 }
248 } else {
249 stream_future.instrument(span).await
250 }
251}
252
253#[cfg(all(test, unix))]
254mod tests {
255 use super::*;
256 use std::sync::{Arc, Mutex};
257
258 fn fake_codex(script_name: &str) -> Codex {
260 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
261 .join("tests")
262 .join(script_name);
263 Codex::builder()
264 .binary("/bin/bash")
265 .arg(script.to_str().unwrap())
266 .build()
267 .expect("bash must exist")
268 }
269
270 #[tokio::test]
271 async fn stream_exec_delivers_events() {
272 let codex = fake_codex("fake-codex.sh");
273 let cmd = crate::command::exec::ExecCommand::new("test prompt").json();
274 let events = Arc::new(Mutex::new(Vec::new()));
275 let events_clone = Arc::clone(&events);
276
277 stream_exec(&codex, &cmd, move |event| {
278 events_clone.lock().unwrap().push(event);
279 })
280 .await
281 .unwrap();
282
283 let events = events.lock().unwrap();
284 assert!(!events.is_empty(), "expected at least one event");
285
286 let types: Vec<&str> = events.iter().map(|e| e.event_type.as_str()).collect();
287 assert!(
288 types.contains(&"thread.started"),
289 "expected thread.started, got: {types:?}"
290 );
291 assert!(
292 types.contains(&"turn.completed"),
293 "expected turn.completed, got: {types:?}"
294 );
295 }
296
297 #[tokio::test]
298 async fn stream_exec_resume_delivers_events() {
299 let codex = fake_codex("fake-codex.sh");
300 let cmd = crate::command::exec::ExecResumeCommand::new().last().json();
301 let events = Arc::new(Mutex::new(Vec::new()));
302 let events_clone = Arc::clone(&events);
303
304 stream_exec_resume(&codex, &cmd, move |event| {
305 events_clone.lock().unwrap().push(event);
306 })
307 .await
308 .unwrap();
309
310 let events = events.lock().unwrap();
311 assert!(!events.is_empty(), "expected at least one event");
312 }
313
314 #[tokio::test]
315 async fn stream_exec_timeout() {
316 let codex = Codex::builder()
317 .binary("/bin/bash")
318 .arg("-c")
319 .arg("sleep 10")
320 .timeout(std::time::Duration::from_millis(50))
321 .build()
322 .unwrap();
323
324 let cmd = crate::command::exec::ExecCommand::new("test").json();
325 let result = stream_exec(&codex, &cmd, |_| {}).await;
326
327 assert!(
328 matches!(result, Err(Error::Timeout { .. })),
329 "expected timeout error, got: {result:?}"
330 );
331 }
332
333 #[tokio::test]
336 async fn stream_exec_timeout_kills_the_spawned_process() {
337 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
338
339 let pid_file = PidFile::new("stream-timeout");
340 let codex = blocking_codex(&pid_file)
341 .timeout(std::time::Duration::from_millis(500))
342 .build()
343 .expect("bash must exist");
344
345 let cmd = crate::command::exec::ExecCommand::new("probe").json();
346 let result = stream_exec(&codex, &cmd, |_| {}).await;
347 assert!(
348 matches!(result, Err(Error::Timeout { .. })),
349 "expected timeout error, got: {result:?}"
350 );
351
352 let pid = pid_file.read_pid().await;
353 assert!(
354 wait_until_gone(pid).await,
355 "codex ({pid}) survived the timeout"
356 );
357 }
358
359 #[tokio::test]
361 async fn stream_exec_cancellation_kills_the_spawned_process() {
362 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
363
364 let pid_file = PidFile::new("stream-cancel");
365 let codex = blocking_codex(&pid_file).build().expect("bash must exist");
366
367 let cmd = crate::command::exec::ExecCommand::new("probe").json();
368 let cancelled = tokio::time::timeout(
369 std::time::Duration::from_millis(500),
370 stream_exec(&codex, &cmd, |_| {}),
371 )
372 .await;
373 assert!(
374 cancelled.is_err(),
375 "fake codex should still have been running, got: {cancelled:?}"
376 );
377
378 let pid = pid_file.read_pid().await;
379 assert!(
380 wait_until_gone(pid).await,
381 "codex ({pid}) survived the dropped future"
382 );
383 }
384
385 #[tokio::test]
386 async fn stream_exec_parse_error() {
387 let codex = fake_codex("fake-codex-bad-json.sh");
388 let cmd = crate::command::exec::ExecCommand::new("test").json();
389 let result = stream_exec(&codex, &cmd, |_| {}).await;
390
391 assert!(
392 matches!(result, Err(Error::Json { .. })),
393 "expected json parse error, got: {result:?}"
394 );
395 }
396}