Route Linux PTY launches through the process setup helper (#47617)

## Why

PTY launches need to establish a controlling terminal and reset signal state. Extend the Linux process setup helper to perform this work in a fresh, single-threaded process image without forking the application when the native helper is available.

## What changed

- Add a PTY setup mode and support caller-owned stdout and stderr descriptors so all three standard streams use the PTY slave.
- Use the helper for eligible Linux PTY launches, including launches with explicitly preserved descriptors, and wait for child exit asynchronously.
- Preserve portable PTY behavior for `PATH` lookup, custom `argv[0]`, missing working directories, default `SHELL`, stdin EOF, and signal exit status.
- Retain compatibility fallbacks and release the initial PTY descriptors before allocating a portable replacement.

## Testing

Add regression tests for controlling-terminal setup without forking, portable signal exit status, and successful fallback under file-descriptor pressure. Resolve the Python test executable to an absolute path to exercise the helper path.

GitOrigin-RevId: 4b07ce68aa33c5b79548581e221caf5953b0eda1
This commit is contained in:
Charlie Marsh
2026-09-23 18:36:48 +00:00
committed by copyberry
parent 15922a50aa
commit 88b498ded4
9 changed files with 383 additions and 98 deletions
+17
View File
@@ -80,6 +80,10 @@ pub struct Command {
#[cfg(target_os = "linux")]
parent_pid: Option<libc::pid_t>,
#[cfg(unix)]
pub(crate) stdout_file: Option<std::os::fd::OwnedFd>,
#[cfg(unix)]
pub(crate) stderr_file: Option<std::os::fd::OwnedFd>,
#[cfg(unix)]
pub(crate) arg0: Option<OsString>,
}
@@ -107,6 +111,10 @@ impl Command {
#[cfg(target_os = "linux")]
parent_pid: None,
#[cfg(unix)]
stdout_file: None,
#[cfg(unix)]
stderr_file: None,
#[cfg(unix)]
arg0: None,
}
}
@@ -298,6 +306,15 @@ impl Command {
}
}
#[cfg(unix)]
{
if let Some(fd) = self.stdout_file {
self.inner.stdout(TokioStdio::from(fd));
}
if let Some(fd) = self.stderr_file {
self.inner.stderr(TokioStdio::from(fd));
}
}
#[cfg(unix)]
let reaper = match self.drop_policy {
ChildDropPolicy::KillAndReap => None,
ChildDropPolicy::ReapOnly => Some(crate::child::reaper::sender()?),
+5 -4
View File
@@ -173,10 +173,11 @@ async fn spawn_process_with_stdin_mode(
#[cfg(windows)]
let job = crate::win::JobObject::create().map(Arc::new);
#[cfg(target_os = "linux")]
let mut child = match crate::spawn_helper::spawn(&command).await? {
Some(child) => child,
None => command.spawn()?,
};
let mut child =
match crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe).await? {
Some(child) => child,
None => command.spawn()?,
};
#[cfg(not(target_os = "linux"))]
let mut child = command.spawn()?;
#[cfg(windows)]
+23 -9
View File
@@ -148,15 +148,19 @@ impl NativeChild {
crate::ChildStdin::Null => (std::fs::File::open("/dev/null")?.into(), None),
crate::ChildStdin::File(fd) => (fd.try_clone()?, None),
};
let (stdout_read, stdout_write) = io::pipe()?;
let (stderr_read, stderr_write) = io::pipe()?;
let (stdout_read, stdout_write) = child_output(request.stdout_file.as_ref())?;
let (stderr_read, stderr_write) = child_output(request.stderr_file.as_ref())?;
let child_fds = [
child_fd(stdin_read)?,
child_fd(stdout_write.into())?,
child_fd(stderr_write.into())?,
child_fd(stdout_write)?,
child_fd(stderr_write)?,
];
let stdout = ChildStdout::from_std(OwnedFd::from(stdout_read).into())?;
let stderr = ChildStderr::from_std(OwnedFd::from(stderr_read).into())?;
let stdout = stdout_read
.map(|fd| ChildStdout::from_std(fd.into()))
.transpose()?;
let stderr = stderr_read
.map(|fd| ChildStderr::from_std(fd.into()))
.transpose()?;
let mut actions = MaybeUninit::uninit();
// SAFETY: Successful initialization makes each object valid; RAII only
@@ -184,7 +188,7 @@ impl NativeChild {
let result =
libc::posix_spawn_file_actions_adddup2(&mut actions.0, *target, *target);
// macOS file actions can reject valid high descriptors below
// RLIMIT_NOFILE. The existing dup2 fallback supports them.
// RLIMIT_NOFILE. The compatibility backend can inherit them directly.
if result == libc::EBADF && request.fallback == crate::SpawnFallback::Compatible {
return Ok(None);
}
@@ -293,8 +297,8 @@ impl NativeChild {
Ok(Some(crate::Child {
inner: super::ChildKind::Native(child),
stdin,
stdout: Some(stdout),
stderr: Some(stderr),
stdout,
stderr,
}))
}
@@ -453,3 +457,13 @@ impl Drop for Attributes {
}
}
}
/// Wire a caller-owned output descriptor or return a new pipe for the parent.
fn child_output(file: Option<&OwnedFd>) -> io::Result<(Option<OwnedFd>, OwnedFd)> {
if let Some(fd) = file {
Ok((None, fd.try_clone()?))
} else {
let (reader, writer) = io::pipe()?;
Ok((Some(reader.into()), writer.into()))
}
}
+138 -68
View File
@@ -8,12 +8,12 @@ use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
#[cfg(unix)]
use std::os::fd::RawFd;
#[cfg(unix)]
#[cfg(all(unix, not(target_os = "linux")))]
use std::os::unix::process::CommandExt;
use std::path::Path;
#[cfg(unix)]
#[cfg(all(unix, not(target_os = "linux")))]
use std::process::Command as StdCommand;
#[cfg(unix)]
#[cfg(all(unix, not(target_os = "linux")))]
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
@@ -141,6 +141,18 @@ pub async fn spawn_process(
#[cfg(not(unix))]
let _ = inherited_fds;
#[cfg(target_os = "linux")]
if crate::spawn_helper::is_available()
&& inherited_fds.is_empty()
&& Path::new(program).is_absolute()
&& arg0.is_none()
&& cwd.is_dir()
{
// Keep portable-pty's PATH, argv0, and missing-cwd behavior for requests
// whose launch settings require its command builder.
return spawn_process_preserving_fds(program, args, cwd, env, arg0, size, inherited_fds)
.await;
}
#[cfg(unix)]
if !inherited_fds.is_empty() {
return spawn_process_preserving_fds(program, args, cwd, env, arg0, size, inherited_fds)
@@ -299,84 +311,99 @@ async fn spawn_process_preserving_fds(
) -> Result<SpawnedProcess> {
let (master, slave) = open_unix_pty(size)?;
let io = crate::unix_io::PtyIo::new(master.as_raw_fd())?;
let mut command = StdCommand::new(program);
if let Some(arg0) = arg0 {
command.arg0(arg0);
}
command.current_dir(cwd);
command.env_clear();
for arg in args {
command.arg(arg);
}
for (key, value) in env {
command.env(key, value);
}
// The child should see one terminal on all three stdio streams. Cloning
// the slave fd gives us three owned handles to the same PTY slave device
// so Command can wire them up independently as stdin/stdout/stderr.
let stdin = slave.try_clone()?;
let stdout = slave.try_clone()?;
let stderr = slave.try_clone()?;
let inherited_fds = inherited_fds.to_vec();
unsafe {
let uses_portable_status = inherited_fds.is_empty();
let stdin_close_behavior = if inherited_fds.is_empty() {
crate::unix_io::StdinCloseBehavior::SendEof
} else {
crate::unix_io::StdinCloseBehavior::NoEof
};
#[cfg(target_os = "linux")]
let mut child = {
let mut command = crate::Command::new(program);
if inherited_fds.is_empty() && !env.contains_key("SHELL") {
// portable-pty supplies the login shell when SHELL is absent.
let mut builder = CommandBuilder::new(program);
builder.env_clear();
command.env("SHELL", builder.get_shell());
}
command
.stdin(Stdio::from(stdin))
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.pre_exec(move || {
for signo in &[
libc::SIGCHLD,
libc::SIGHUP,
libc::SIGINT,
libc::SIGQUIT,
libc::SIGTERM,
libc::SIGALRM,
] {
libc::signal(*signo, libc::SIG_DFL);
.args(args)
.current_dir(cwd)
.envs(env)
.drop_policy(crate::child_command::ChildDropPolicy::ReapOnly)
.descriptor_policy(crate::DescriptorPolicy::Explicit)
.preserve_fds(inherited_fds)
.stdin(crate::ChildStdin::File(slave.try_clone()?.into()));
if let Some(arg0) = arg0 {
command.arg0(arg0);
}
command.stdout_file = Some(slave.try_clone()?.into());
command.stderr_file = Some(slave.try_clone()?.into());
match crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pty).await? {
Some(child) => child,
None if inherited_fds.is_empty() => {
// Free the first PTY before allocating its portable replacement.
drop(command);
drop(io);
drop(slave);
drop(master);
return spawn_process_portable(program, args, cwd, env, arg0, size).await;
}
None => {
// Explicit descriptors select the shared fallback on Linux. Keep
// terminal setup there when this executable cannot run the helper.
unsafe {
command.inner.pre_exec(configure_child_terminal);
}
let empty_set: libc::sigset_t = std::mem::zeroed();
libc::sigprocmask(libc::SIG_SETMASK, &empty_set, std::ptr::null_mut());
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
// stdin now refers to the PTY slave, so make that fd the
// controlling terminal for the child's new session. stdout and
// stderr point at clones of the same slave device.
#[allow(clippy::cast_lossless)]
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
close_inherited_fds_except(&inherited_fds);
Ok(())
});
}
let mut child = command.spawn()?;
command.spawn()?
}
}
};
#[cfg(not(target_os = "linux"))]
let mut child = {
let mut command = StdCommand::new(program);
if let Some(arg0) = arg0 {
command.arg0(arg0);
}
command.current_dir(cwd).env_clear().args(args).envs(env);
let inherited_fds = inherited_fds.to_vec();
// The child sees the same terminal on stdin, stdout, and stderr.
unsafe {
command
.stdin(Stdio::from(slave.try_clone()?))
.stdout(Stdio::from(slave.try_clone()?))
.stderr(Stdio::from(slave.try_clone()?))
.pre_exec(move || {
configure_child_terminal()?;
close_inherited_fds_except(&inherited_fds);
Ok(())
});
}
command.spawn()?
};
drop(slave);
#[cfg(target_os = "linux")]
let process_group_id = child
.id()
.ok_or_else(|| std::io::Error::other("missing child pid"))?;
#[cfg(not(target_os = "linux"))]
let process_group_id = child.id();
let (writer_tx, writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(128);
let (_stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>(1);
let (reader_handle, writer_handle) = io.spawn(
stdout_tx,
writer_rx,
crate::unix_io::StdinCloseBehavior::NoEof,
);
let (reader_handle, writer_handle) = io.spawn(stdout_tx, writer_rx, stdin_close_behavior);
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
let exit_status = Arc::new(AtomicBool::new(false));
let wait_exit_status = Arc::clone(&exit_status);
let exit_code = Arc::new(StdMutex::new(None));
let wait_exit_code = Arc::clone(&exit_code);
let wait_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || {
let code = match child.wait() {
let on_exit = move |status: std::io::Result<std::process::ExitStatus>| {
let code = match status {
Ok(status) if uses_portable_status => {
portable_pty::ExitStatus::from(status).exit_code() as i32
}
Ok(status) => exit_code_from_status(status),
Err(_) => -1,
};
@@ -385,7 +412,11 @@ async fn spawn_process_preserving_fds(
*guard = Some(code);
}
let _ = exit_tx.send(code);
});
};
#[cfg(target_os = "linux")]
let wait_handle = tokio::spawn(async move { on_exit(child.wait().await) });
#[cfg(not(target_os = "linux"))]
let wait_handle = tokio::task::spawn_blocking(move || on_exit(child.wait()));
let handles = PtyHandles {
_slave: None,
@@ -561,3 +592,42 @@ pub(crate) fn close_inherited_fds_except(preserved_fds: &[RawFd]) {
}
}
}
/// Establish the controlling terminal and signal state before a PTY target exec.
/// This also runs in the legacy pre-exec callback, so it must stay async-signal-safe.
#[cfg(unix)]
pub(crate) fn configure_child_terminal() -> std::io::Result<()> {
// SAFETY: These operations only change the calling child's signal/session state.
unsafe {
for signo in &[
libc::SIGCHLD,
libc::SIGHUP,
libc::SIGINT,
libc::SIGQUIT,
libc::SIGTERM,
libc::SIGALRM,
] {
libc::signal(*signo, libc::SIG_DFL);
}
let empty_set: libc::sigset_t = std::mem::zeroed();
libc::sigprocmask(libc::SIG_SETMASK, &empty_set, std::ptr::null_mut());
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
// stdin now refers to the PTY slave, so make that fd the
// controlling terminal for the child's new session. stdout and
// stderr point at clones of the same slave device.
#[allow(clippy::cast_lossless)]
if libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
#[cfg(all(test, target_os = "linux"))]
#[path = "pty_linux_tests.rs"]
mod linux_tests;
+69
View File
@@ -0,0 +1,69 @@
//! Portable PTY fallback must release the unsuccessful native launch's resources.
use std::collections::HashMap;
use std::io;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
use std::os::fd::OwnedFd;
use std::path::Path;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn portable_fallback_releases_native_pty_descriptors() -> anyhow::Result<()> {
if std::env::var_os("CODEX_TEST_PTY_FALLBACK_PRESSURE").is_none() {
let output = std::process::Command::new(std::env::current_exe()?)
.args([
"--exact",
"pty::linux_tests::portable_fallback_releases_native_pty_descriptors",
"--nocapture",
])
.env("CODEX_TEST_PTY_FALLBACK_PRESSURE", "1")
.output()?;
assert!(output.status.success(), "{output:?}");
return Ok(());
}
let source = std::fs::File::open("/dev/null")?;
let mut limit = std::mem::MaybeUninit::<libc::rlimit>::uninit();
// SAFETY: This isolated subprocess owns its descriptor limit and reservations.
assert_eq!(
unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, limit.as_mut_ptr()) },
0
);
let mut limit = unsafe { limit.assume_init() };
limit.rlim_cur = limit.rlim_cur.min(128);
assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) }, 0);
let mut occupied = Vec::new();
loop {
let fd = unsafe { libc::fcntl(source.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) };
if fd == -1 {
assert_eq!(
io::Error::last_os_error().raw_os_error(),
Some(libc::EMFILE)
);
break;
}
occupied.push(unsafe { OwnedFd::from_raw_fd(fd) });
}
// The initial PTY fits, but helper setup cannot. The portable replacement
// fits only after the initial PTY and its stdio copies have been closed.
anyhow::ensure!(occupied.len() >= 10, "not enough descriptors to reserve");
occupied.truncate(occupied.len() - 10);
let mut child = crate::spawn_pty_process(
"/bin/sh",
&["-c".to_owned(), "printf fallback".to_owned()],
Path::new("/"),
&HashMap::from([("SHELL".to_owned(), "/bin/sh".to_owned())]),
&None,
crate::TerminalSize::default(),
&[],
)
.await?;
drop(occupied);
let mut output = Vec::new();
while let Some(bytes) = child.stdout_rx.recv().await {
output.extend(bytes);
}
assert_eq!((child.exit_rx.await?, output), (0, b"fallback".to_vec()));
Ok(())
}
+31 -2
View File
@@ -20,6 +20,14 @@ use tokio::io::AsyncWriteExt;
use crate::child_command::ChildDropPolicy;
/// Setup that needs a fresh, single-threaded image before target exec.
pub(crate) enum Setup {
/// Detach from the terminal and terminate when the parent dies.
Pipe,
/// Establish a controlling terminal and reset interactive signal state.
Pty,
}
pub(super) const HELPER_ARG: &str = "--codex-run-as-process-setup";
pub(super) const MAX_ENV_BYTES: usize = 8 * 1024 * 1024;
pub(super) const REPORT_PREFIX: u8 = 0;
@@ -56,12 +64,19 @@ pub fn init_spawn_helper(args: impl IntoIterator<Item = std::ffi::OsString>) {
}
}
pub(crate) fn is_available() -> bool {
HELPER_READY.load(Ordering::Relaxed)
}
/// Start the helper and await target exec without blocking a runtime worker.
/// Return `None` when registration, helper resource setup, or helper launch fails,
/// preserving compatibility with callers and sandboxes that only permit the target.
pub(crate) async fn spawn(target: &crate::Command) -> io::Result<Option<crate::Child>> {
pub(crate) async fn spawn(
target: &crate::Command,
setup: Setup,
) -> io::Result<Option<crate::Child>> {
target.validate()?;
if !HELPER_READY.load(Ordering::Relaxed) {
if !is_available() {
return Ok(None);
}
let settings = target.inner.as_std();
@@ -88,6 +103,10 @@ pub(crate) async fn spawn(target: &crate::Command) -> io::Result<Option<crate::C
.arg(HELPER_ARG)
.arg(fd.to_string())
.arg(std::process::id().to_string())
.arg(match setup {
Setup::Pipe => "pipe",
Setup::Pty => "pty",
})
.arg(
target
.inherited_fds
@@ -106,6 +125,16 @@ pub(crate) async fn spawn(target: &crate::Command) -> io::Result<Option<crate::C
crate::ChildStdin::Null => crate::ChildStdin::Null,
crate::ChildStdin::File(fd) => crate::ChildStdin::File(fd.try_clone()?),
});
helper.stdout_file = target
.stdout_file
.as_ref()
.map(std::os::fd::OwnedFd::try_clone)
.transpose()?;
helper.stderr_file = target
.stderr_file
.as_ref()
.map(std::os::fd::OwnedFd::try_clone)
.transpose()?;
helper.inherited_fds = target.inherited_fds.clone();
helper.inherited_fds.push(fd);
Ok((control, helper, helper_control))
+16 -3
View File
@@ -1,4 +1,4 @@
//! Early executable dispatch for Linux pipe spawns. All child setup happens
//! Early executable dispatch for Linux pipe and PTY spawns. All child setup happens
//! after exec, before application initialization or runtime threads exist.
//! A report prefix precedes target exec; failures append errno before exit.
@@ -31,6 +31,14 @@ pub(super) fn dispatch(mut args: impl Iterator<Item = std::ffi::OsString>) -> !
.and_then(|arg| arg.to_str()?.parse::<libc::pid_t>().ok())
.filter(|pid| *pid > 0)
.ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?;
let setup = args
.next()
.ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?;
let setup = match setup.to_str() {
Some("pipe") => crate::spawn_helper::Setup::Pipe,
Some("pty") => crate::spawn_helper::Setup::Pty,
_ => return Err(io::Error::from_raw_os_error(libc::EINVAL)),
};
let preserved = args
.next()
.ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?;
@@ -57,8 +65,13 @@ pub(super) fn dispatch(mut args: impl Iterator<Item = std::ffi::OsString>) -> !
if unsafe { libc::fcntl(control_fd, libc::F_SETFD, libc::FD_CLOEXEC) } == -1 {
return Err(io::Error::last_os_error());
}
crate::process_group::detach_from_tty()?;
crate::process_group::set_parent_death_signal(parent_pid)?;
match setup {
crate::spawn_helper::Setup::Pipe => {
crate::process_group::detach_from_tty()?;
crate::process_group::set_parent_death_signal(parent_pid)?;
}
crate::spawn_helper::Setup::Pty => crate::pty::configure_child_terminal()?,
}
// Allocation is safe in this fresh, single-threaded image. CLOEXEC
// leaves the report socket open until the target actually execs.
crate::pty::close_inherited_fds_except(&inherited_fds);
+81 -10
View File
@@ -179,7 +179,9 @@ async fn native_helper_available() -> anyhow::Result<bool> {
return Ok(false);
}
let command = crate::Command::new("/bin/true");
let Some(child) = crate::spawn_helper::spawn(&command).await? else {
let Some(child) =
crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe).await?
else {
return Ok(false);
};
Ok(child.wait_with_output().await?.status.success())
@@ -263,7 +265,8 @@ async fn target_loader_environment_does_not_disable_helper_dispatch() -> anyhow:
let mut command = crate::Command::new("/codex-missing-loader-env-target");
command.current_dir("/").env("LD_TRACE_LOADED_OBJECTS", "1");
// A failed helper bootstrap returns None; require the helper's target error.
let Err(error) = crate::spawn_helper::spawn(&command).await else {
let Err(error) = crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe).await
else {
panic!("helper must reach target exec despite loader settings");
};
assert_eq!(error.raw_os_error(), Some(libc::ENOENT));
@@ -285,9 +288,12 @@ async fn large_environment_can_cross_the_control_socket() -> anyhow::Result<()>
])
.current_dir("/")
.envs((0..8).map(|index| (format!("LARGE_{index}"), "x".repeat(8_192))));
let child = tokio::time::timeout(Duration::from_secs(5), crate::spawn_helper::spawn(&command))
.await??
.expect("native helper must transfer the environment");
let child = tokio::time::timeout(
Duration::from_secs(5),
crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe),
)
.await??
.expect("native helper must transfer the environment");
assert!(child.wait_with_output().await?.status.success());
Ok(())
}
@@ -311,7 +317,7 @@ async fn helper_preserves_inheritable_fds_and_closes_unrequested_fds() -> anyhow
.current_dir("/")
.descriptor_policy(crate::DescriptorPolicy::Explicit)
.preserve_fds(&[preserved]);
let child = crate::spawn_helper::spawn(&command)
let child = crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe)
.await?
.expect("native helper must preserve requested descriptors");
assert!(child.wait_with_output().await?.status.success());
@@ -550,6 +556,64 @@ async fn helper_exit_before_acknowledgement_falls_back() -> anyhow::Result<()> {
wait_for_fallback_reaping(pid)
}
#[tokio::test]
async fn pty_helper_establishes_a_controlling_terminal_without_forking() -> anyhow::Result<()> {
use std::cell::Cell;
thread_local! { static FORKS: Cell<usize> = const { Cell::new(0) }; }
extern "C" fn after_fork() {
FORKS.with(|count| count.set(count.get() + 1));
}
FORKS.with(|count| count.set(0));
// SAFETY: The parent callback only updates initialized thread-local storage.
assert_eq!(
unsafe {
libc::pthread_atfork(/*prepare*/ None, Some(after_fork), /*child*/ None)
},
0
);
let mut spawned = crate::spawn_pty_process(
"/bin/sh",
&[
"-c".to_owned(),
"test -t 0 && test -t 1 && test -t 2 && printf ready >/dev/tty".to_owned(),
],
std::path::Path::new("."),
&std::env::vars().collect(),
/*arg0*/ &None,
crate::TerminalSize::default(),
&[],
)
.await?;
let mut output = Vec::new();
while let Some(chunk) = spawned.stdout_rx.recv().await {
output.extend(chunk);
}
assert_eq!((spawned.exit_rx.await?, output), (0, b"ready".to_vec()));
if native_helper_available().await? {
assert_eq!(FORKS.with(Cell::get), 0);
}
Ok(())
}
#[tokio::test]
async fn pty_helper_preserves_portable_signal_exit_status() -> anyhow::Result<()> {
let env = std::env::vars().collect();
for program in ["/bin/sh", "sh"] {
let spawned = crate::spawn_pty_process(
program,
&["-c".to_owned(), "kill -TERM $$".to_owned()],
std::path::Path::new("."),
&env,
/*arg0*/ &None,
crate::TerminalSize::default(),
&[],
)
.await?;
assert_eq!(spawned.exit_rx.await?, 1);
}
Ok(())
}
#[test]
fn helper_dispatch_before_rust_initializes_argv() -> anyhow::Result<()> {
let output = std::process::Command::new(std::env::current_exe()?)
@@ -605,7 +669,9 @@ async fn helper_control_socket_survives_closed_stdio() -> anyhow::Result<()> {
}
let mut command = crate::Command::new("/bin/echo");
command.arg("closed-stdio").stdin(crate::ChildStdin::Null);
let Some(probe) = crate::spawn_helper::spawn(&command).await? else {
let Some(probe) =
crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe).await?
else {
return Ok(());
};
assert!(probe.wait_with_output().await?.status.success());
@@ -618,7 +684,7 @@ async fn helper_control_socket_survives_closed_stdio() -> anyhow::Result<()> {
for fd in [0, 1] {
assert_eq!(unsafe { libc::close(fd) }, 0);
}
let result = crate::spawn_helper::spawn(&command).await;
let result = crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe).await;
for (fd, saved) in [0, 1].into_iter().zip(saved) {
assert_eq!(unsafe { libc::dup2(saved.as_raw_fd(), fd) }, fd);
}
@@ -692,7 +758,11 @@ async fn helper_descriptor_pressure_falls_back_to_direct_spawn() -> anyhow::Resu
// Direct spawning fits, but the helper also clones stdin and opens its control socket.
anyhow::ensure!(occupied.len() >= 7, "not enough descriptors to reserve");
occupied.truncate(occupied.len() - 7);
assert!(crate::spawn_helper::spawn(&command).await?.is_none());
assert!(
crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe)
.await?
.is_none()
);
let output = command.spawn()?.wait_with_output().await?;
assert_eq!(
(output.status.code(), output.stdout, output.stderr),
@@ -723,7 +793,8 @@ async fn parent_loader_environment_does_not_disable_helper_dispatch() -> anyhow:
}
let mut command = crate::Command::new("/codex-missing-parent-loader-env-target");
command.current_dir("/");
let Err(error) = crate::spawn_helper::spawn(&command).await else {
let Err(error) = crate::spawn_helper::spawn(&command, crate::spawn_helper::Setup::Pipe).await
else {
panic!("helper must reach target exec despite parent loader settings");
};
assert_eq!(error.raw_os_error(), Some(libc::ENOENT));
+3 -2
View File
@@ -20,11 +20,12 @@ mod windows_tests;
pub(super) fn find_python() -> Option<String> {
for candidate in ["python3", "python"] {
if let Ok(output) = std::process::Command::new(candidate)
.arg("--version")
.args(["-c", "import sys; print(sys.executable)"])
.env("PYTHONIOENCODING", "utf-8")
.output()
&& output.status.success()
{
return Some(candidate.to_string());
return Some(String::from_utf8(output.stdout).ok()?.trim().to_owned());
}
}
None