Skip to main content

codex_wrapper/
streaming.rs

1//! Streaming execution for `codex exec` commands.
2//!
3//! Instead of buffering all JSONL output and returning it at once,
4//! the streaming API pipes stdout from the child process and delivers
5//! each [`JsonLineEvent`] to a caller-supplied callback as soon as it
6//! arrives.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use codex_wrapper::{Codex, ExecCommand, JsonLineEvent};
12//!
13//! # async fn example() -> codex_wrapper::Result<()> {
14//! let codex = Codex::builder().build()?;
15//! let cmd = ExecCommand::new("what is 2+2?").ephemeral();
16//!
17//! cmd.stream(&codex, |event: JsonLineEvent| {
18//!     println!("{}: {:?}", event.event_type, event.extra);
19//! })
20//! .await?;
21//! # Ok(())
22//! # }
23//! ```
24
25use 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
34/// Stream JSONL events from `codex exec <prompt>`, invoking `handler` for each
35/// parsed [`JsonLineEvent`].
36///
37/// The child's stderr is drained concurrently and returned in the error if the
38/// process exits with a non-zero status.
39pub 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
54/// Stream JSONL events from `codex exec resume`, invoking `handler` for each
55/// parsed [`JsonLineEvent`].
56pub 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
71/// Core streaming implementation shared by both exec variants.
72///
73/// `stdin_prompt` carries the prompt for a `codex exec -` run, where it is
74/// delivered on stdin rather than in argv.
75async 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    // Settles on every exit below, and on drop for the one that has no exit:
91    // a cancelled stream, which is the outcome most easily missed.
92    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    // Kill the child if this future is dropped: on timeout, on caller
105    // cancellation, or on task abort. Without this, tokio detaches the child
106    // and codex keeps running with no handle left to stop it.
107    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    // Armed for the whole stream. Dropping this future signals the group,
124    // which reaches the subprocesses codex started for tool use; kill_on_drop
125    // alone would leave those running (#78).
126    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    // Taken up front so the write does not borrow `child`, which the wait
132    // below needs.
133    let child_stdin = child.stdin.take();
134
135    // Write the prompt and close the handle, so the CLI stops waiting for
136    // more. This runs as part of the streamed future rather than before it,
137    // so a prompt larger than the pipe buffer cannot block the readers.
138    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    // Dropped explicitly before awaiting: the guard exists so the span is the
229    // parent of everything above, while the await below must not hold it
230    // across a yield point.
231    drop(_span_guard);
232
233    if let Some(timeout) = codex.timeout {
234        // On elapse the stream future is dropped, taking `outcome` with it,
235        // whose drop records the run as cancelled. That is the same path a
236        // caller dropping this future takes.
237        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    /// Build a [`Codex`] client that uses `bash` to run the fake-codex script.
254    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    /// The API promises delivery as events arrive. Buffering until stdout
293    /// closes loses a thread id when a long-running process or its host dies
294    /// after `thread.started`, which is the durability use case for streaming.
295    #[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    /// The streaming path drops both `stream_future` and the child it borrows.
369    /// Without `kill_on_drop`, the timeout above would leave codex running.
370    #[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    /// The caller dropping the stream future, with no wrapper timeout.
395    #[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}