## What changed - keep the explicit stdin-close behavior after writing so the child still receives EOF deterministically - on Windows, stop using `python -c` for the round-trip assertion and instead run a native `cmd.exe` pipeline that reads one line from stdin with `set /p` and echoes it back - send ` ` on Windows so the stdin payload matches the platform-native line ending the shell reader expects ## Why this fixes flakiness The failing branch-local flake was not in `spawn_pipe_process` itself. The child exited cleanly, but the Windows ARM runner sometimes produced an empty stdout string when the test used Python as the stdin consumer. That makes the test sensitive to Python startup and stdin-close timing rather than the pipe primitive we actually want to validate. Switching the Windows path to a native `cmd.exe` reader keeps the assertion focused on our pipe behavior: bytes written to stdin should come back on stdout before EOF closes the process. The explicit ` ` write removes line-ending ambiguity on Windows. ## Scope - test-only - no production logic change |
||
|---|---|---|
| .. | ||
| src | ||
| BUILD.bazel | ||
| Cargo.toml | ||
| README.md | ||
codex-utils-pty
Lightweight helpers for spawning interactive processes either under a PTY (pseudo terminal) or regular pipes. The public API is minimal and mirrors both backends so callers can switch based on their needs (e.g., enabling or disabling TTY).
API surface
spawn_pty_process(program, args, cwd, env, arg0, size)→SpawnedProcessspawn_pipe_process(program, args, cwd, env, arg0)→SpawnedProcessspawn_pipe_process_no_stdin(program, args, cwd, env, arg0)→SpawnedProcesscombine_output_receivers(stdout_rx, stderr_rx)→broadcast::Receiver<Vec<u8>>conpty_supported()→bool(Windows only; always true elsewhere)TerminalSize { rows, cols }selects PTY dimensions in character cells.ProcessHandleexposes:writer_sender()→mpsc::Sender<Vec<u8>>(stdin)resize(TerminalSize)close_stdin()has_exited(),exit_code(),terminate()
SpawnedProcessbundlessession,stdout_rx,stderr_rx, andexit_rx(oneshot exit code).
Usage examples
use std::collections::HashMap;
use std::path::Path;
use codex_utils_pty::combine_output_receivers;
use codex_utils_pty::spawn_pty_process;
use codex_utils_pty::TerminalSize;
# tokio_test::block_on(async {
let env_map: HashMap<String, String> = std::env::vars().collect();
let spawned = spawn_pty_process(
"bash",
&["-lc".into(), "echo hello".into()],
Path::new("."),
&env_map,
&None,
TerminalSize::default(),
).await?;
let writer = spawned.session.writer_sender();
writer.send(b"exit\n".to_vec()).await?;
// Collect output until the process exits.
let mut output_rx = combine_output_receivers(spawned.stdout_rx, spawned.stderr_rx);
let mut collected = Vec::new();
while let Ok(chunk) = output_rx.try_recv() {
collected.extend_from_slice(&chunk);
}
let exit_code = spawned.exit_rx.await.unwrap_or(-1);
# let _ = (collected, exit_code);
# anyhow::Ok(())
# });
Swap in spawn_pipe_process for a non-TTY subprocess; the rest of the API stays the same.
Use spawn_pipe_process_no_stdin to force stdin closed (commands that read stdin will see EOF immediately).
Tests
Unit tests live in src/lib.rs and cover both backends (PTY Python REPL and pipe-based stdin roundtrip). Run with:
cargo test -p codex-utils-pty -- --nocapture