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, cmd.stdin_prompt(), 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    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    // Armed for the whole stream. Dropping this future signals the group,
122    // which reaches the subprocesses codex started for tool use; kill_on_drop
123    // alone would leave those running (#78).
124    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    // Taken up front so the write does not borrow `child`, which the wait
130    // below needs.
131    let child_stdin = child.stdin.take();
132
133    // Write the prompt and close the handle, so the CLI stops waiting for
134    // more. This runs as part of the streamed future rather than before it,
135    // so a prompt larger than the pipe buffer cannot block the readers.
136    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    // Dropped explicitly before awaiting: the guard exists so the span is the
227    // parent of everything above, while the await below must not hold it
228    // across a yield point.
229    drop(_span_guard);
230
231    if let Some(timeout) = codex.timeout {
232        // On elapse the stream future is dropped, taking `outcome` with it,
233        // whose drop records the run as cancelled. That is the same path a
234        // caller dropping this future takes.
235        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    /// Build a [`Codex`] client that uses `bash` to run the fake-codex script.
252    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    /// The API promises delivery as events arrive. Buffering until stdout
291    /// closes loses a thread id when a long-running process or its host dies
292    /// after `thread.started`, which is the durability use case for streaming.
293    #[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    /// Streaming owns a separate process builder from buffered execution.
348    /// Cover opening, stdin opening, and resume so none can regress to ambient
349    /// inheritance while the others stay isolated.
350    #[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    /// Contract captured from a paid 0.145.0 run: the callback receives the
402    /// typed terminal before the process reports its non-zero exit, including
403    /// an assistant message but no fabricated token counts.
404    #[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    /// The streaming path drops both `stream_future` and the child it borrows.
451    /// Without `kill_on_drop`, the timeout above would leave codex running.
452    #[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    /// The caller dropping the stream future, with no wrapper timeout.
477    #[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}