openai / openai/codex

Windows sandbox silently drops command output bursts larger than ~2 MiB

Open
#45,540 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug sandbox windows-os
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description

What issue are you seeing?

With either Windows sandbox backend (RestrictedToken/legacy or elevated), a command that writes a large burst to stdout loses bytes. There is no error and the exit code is normal.

Observed on rust-v0.152.0 by embedding codex-windows-sandbox (spawn_windows_sandbox_session_for_level, WindowsSandboxLevel::RestrictedToken) and draining the returned stdout_rx: a single ~4 MiB write arrived as 4,194,306 bytes unsandboxed but 1,338,370 bytes sandboxed, on a GitHub-hosted windows-latest runner. The same code path also exists on main as of 2026-09-14.

What steps can reproduce the bug?
  1. Spawn a sandboxed PowerShell through spawn_windows_sandbox_session_for_level with piped stdio (tty: false).
  2. Run a single large write, e.g. Write-Output (('A' * 1023 + "n") * 4096)` (~4 MiB).
  3. Drain SpawnedProcess.stdout_rx to EOF and count bytes.
  4. Compare with the same command spawned unsandboxed: the sandboxed total is short by megabytes.

Many small writes (e.g. a per-line pipeline) usually stay under the backlog and do not reproduce it, which may be why it has gone unnoticed.

What is the expected behavior?

All bytes the child writes reach stdout_rx, as on the unsandboxed pipe path. If the consumer is slower than the child, the child should block on its pipe rather than lose output.

Additional information

Cause. Both backends forward pipe output over tokio::sync::broadcast::channel(256):

  • unified_exec/backends/legacy.rsspawn_output_reader (8 KiB reads)
  • unified_exec/backends/windows_common.rsstart_runner_stdout_reader

A broadcast sender never waits, and codex_utils_pty::spawn_from_driver bridges into mpsc with RecvError::Lagged(_) => continue (utils/pty/src/process.rs). Once ~256 chunks queue behind a slower consumer, chunks are discarded. The unsandboxed pipe path (utils/pty/src/pipe.rs) awaits mpsc::Sender::send, so there the child blocks instead. The default output cap (DEFAULT_MAX_OUTPUT_TOKENS) probably hides this in the CLI, but any consumer that keeps full output sees the gap.

Suggested fix (~15 lines; type-checks for x86_64-pc-windows-msvc against rust-v0.152.0, not yet run). Hold the reader thread while the broadcast backlog is high, so backpressure reaches the child's pipe as on the unsandboxed path:

--- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs
+++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs
@@ -1,4 +1,5 @@
 use super::windows_common::finish_driver_spawn;
+use super::windows_common::send_output;
 use crate::conpty::ConptyInstance;
 use crate::conpty::spawn_conpty_process_as_user;
 use crate::desktop::LaunchDesktop;
@@ -161,7 +162,7 @@
     output_tx: broadcast::Sender<Vec<u8>>,
 ) -> std::thread::JoinHandle<()> {
     read_handle_loop(output_read, move |chunk| {
-        let _ = output_tx.send(chunk.to_vec());
+        send_output(&output_tx, chunk.to_vec());
     })
 }
 
--- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs
+++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs
@@ -14,10 +14,24 @@
 use codex_utils_pty::WindowsTtyInputNormalizer;
 use codex_utils_pty::spawn_from_driver;
 use std::fs::File;
+use std::time::Duration;
 use tokio::sync::broadcast;
 use tokio::sync::mpsc;
 use tokio::sync::oneshot;
 
+const OUTPUT_BACKLOG_HIGH_WATER: usize = 128;
+const OUTPUT_BACKLOG_POLL_INTERVAL: Duration = Duration::from_millis(1);
+
+/// A broadcast sender never waits, so a reader that outpaces the consumer makes
+/// `spawn_from_driver` skip `Lagged` chunks. Hold the reader thread instead, which leaves
+/// the child blocked on its pipe exactly as the unsandboxed `mpsc` path does.
+pub(crate) fn send_output(output_tx: &broadcast::Sender<Vec<u8>>, chunk: Vec<u8>) {
+    while output_tx.receiver_count() > 0 && output_tx.len() >= OUTPUT_BACKLOG_HIGH_WATER {
+        std::thread::sleep(OUTPUT_BACKLOG_POLL_INTERVAL);
+    }
+    let _ = output_tx.send(chunk);
+}
+
 pub(crate) fn finish_driver_spawn(driver: ProcessDriver, stdin_open: bool) -> SpawnedProcess {
     let spawned = spawn_from_driver(driver);
     if !stdin_open {
@@ -112,14 +126,10 @@
                     if let Ok(data) = decode_bytes(&payload.data_b64) {
                         match payload.stream {
                             OutputStream::Stdout => {
-                                let _ = stdout_tx.send(data);
+                                send_output(&stdout_tx, data);
                             }
                             OutputStream::Stderr => {
-                                if let Some(stderr_tx) = stderr_tx.as_ref() {
-                                    let _ = stderr_tx.send(data);
-                                } else {
-                                    let _ = stdout_tx.send(data);
-                                }
+                                send_output(stderr_tx.as_ref().unwrap_or(&stdout_tx), data);
                             }
                         }
                     }

Tradeoff: a consumer that stops reading while the process is still running now stalls the child on a full pipe instead of losing output — the same behavior as the unsandboxed path. The receiver_count() > 0 guard avoids hanging once every receiver is dropped.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs and windows_common.rs, then inspect utils/pty/src/process.rs and pipe.rs to compare the sandboxed and unsandboxed output paths. Reproduce the large PowerShell write on x86_64-pc-windows-msvc and verify that both sandbox backends deliver every byte through stdout_rx without silently dropping chunks.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend, operating-systems
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.