mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Move local child-process spawning into codex-utils-pty (#46659)
## What changed Expose the shared `Child` type and `spawn_child` function from `codex-utils-pty`, and update the MCP stdio transport to use them. Move the existing macOS spawning implementation and its tests into the utility crate, preserving platform-specific spawning, piped stdio, and kill-on-drop behavior. GitOrigin-RevId: 26c2fb2f22cdba907aa9d18cab7dc8c4ac1c909e
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
//! Uniform child-process API for local subprocesses, with platform-specific spawning.
|
||||
//!
|
||||
//! Commands must use the launcher's cleared environment, process group, and default
|
||||
//! argv[0]. Both implementations expose Tokio stdio handles and kill on drop.
|
||||
|
||||
use std::io;
|
||||
use std::process::Stdio;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "macos_child.rs"]
|
||||
mod macos;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::Child;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub use tokio::process::Child;
|
||||
|
||||
pub fn spawn(mut command: Command) -> io::Result<Child> {
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Child::spawn(command)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
command.spawn()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
mod child;
|
||||
pub use child::Child;
|
||||
pub use child::spawn as spawn_child;
|
||||
pub mod pipe;
|
||||
mod process;
|
||||
pub mod process_group;
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Native spawning for macOS MCP executables, without rewriting script paths.
|
||||
//!
|
||||
//! Rust falls back to fork for a historical relative-path/cwd bug in Apple's
|
||||
//! `posix_spawnp`. Calling `posix_spawn` directly avoids that wrapper. This module only
|
||||
//! accepts the launcher's cleared-environment command shape, with piped stdio,
|
||||
//! a new process group, and default `argv[0]`. Bare commands search the child's
|
||||
//! PATH. Unsuccessful searches and executable files without shebangs retain the
|
||||
//! existing launcher. Each native child owns its PID until it has been reaped.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::Path;
|
||||
use std::process::ExitStatus;
|
||||
use std::ptr;
|
||||
|
||||
use tokio::process::Child as TokioChild;
|
||||
use tokio::process::ChildStderr;
|
||||
use tokio::process::ChildStdin;
|
||||
use tokio::process::ChildStdout;
|
||||
use tokio::process::Command;
|
||||
use tokio::signal::unix::Signal;
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tokio::signal::unix::signal;
|
||||
|
||||
/// Matches Tokio's child API while keeping native spawning private to macOS.
|
||||
pub struct Child {
|
||||
inner: ChildKind,
|
||||
pub stdin: Option<ChildStdin>,
|
||||
pub stdout: Option<ChildStdout>,
|
||||
pub stderr: Option<ChildStderr>,
|
||||
}
|
||||
|
||||
enum ChildKind {
|
||||
Tokio(TokioChild),
|
||||
Native(NativeChild),
|
||||
}
|
||||
|
||||
impl Child {
|
||||
/// Uses native spawning for relative paths and bare names, retaining Tokio's
|
||||
/// fallback for unsuccessful PATH searches and executable text without a shebang.
|
||||
pub(super) fn spawn(mut command: Command) -> io::Result<Self> {
|
||||
let program = command.as_std().get_program();
|
||||
if Path::new(program).is_relative()
|
||||
&& !program.is_empty()
|
||||
&& let Some((child, stdin, stdout, stderr)) = NativeChild::spawn(command.as_std())?
|
||||
{
|
||||
return Ok(Self {
|
||||
inner: ChildKind::Native(child),
|
||||
stdin: Some(stdin),
|
||||
stdout: Some(stdout),
|
||||
stderr: Some(stderr),
|
||||
});
|
||||
}
|
||||
let mut child = command.spawn()?;
|
||||
Ok(Self {
|
||||
stdin: child.stdin.take(),
|
||||
stdout: child.stdout.take(),
|
||||
stderr: child.stderr.take(),
|
||||
inner: ChildKind::Tokio(child),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Option<u32> {
|
||||
match &self.inner {
|
||||
ChildKind::Tokio(child) => child.id(),
|
||||
ChildKind::Native(child) => child.id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait(&mut self) -> io::Result<ExitStatus> {
|
||||
self.stdin.take();
|
||||
match &mut self.inner {
|
||||
ChildKind::Tokio(child) => child.wait().await,
|
||||
ChildKind::Native(child) => child.wait().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn kill(&mut self) -> io::Result<()> {
|
||||
self.stdin.take();
|
||||
match &mut self.inner {
|
||||
ChildKind::Tokio(child) => child.kill().await,
|
||||
ChildKind::Native(child) => child.kill().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// libc does not expose this Apple extension. It is available since macOS 10.15,
|
||||
// before Codex's minimum supported macOS version (12).
|
||||
unsafe extern "C" {
|
||||
fn posix_spawn_file_actions_addchdir_np(
|
||||
actions: *mut libc::posix_spawn_file_actions_t,
|
||||
path: *const libc::c_char,
|
||||
) -> libc::c_int;
|
||||
}
|
||||
|
||||
/// Owns a child PID until reaping, so cancellation cannot lose or reuse it.
|
||||
/// Dropping a live child kills it and reaps it independently of the Tokio runtime.
|
||||
struct NativeChild {
|
||||
pid: Option<libc::pid_t>,
|
||||
status: Option<ExitStatus>,
|
||||
sigchld: Signal,
|
||||
}
|
||||
|
||||
impl NativeChild {
|
||||
/// Spawns the MCP command without changing its executable path or `argv[0]`.
|
||||
/// The caller must clear inherited environment variables before setting the
|
||||
/// child's environment, because only explicit command entries are copied.
|
||||
fn spawn(
|
||||
command: &std::process::Command,
|
||||
) -> io::Result<Option<(Self, ChildStdin, ChildStdout, ChildStderr)>> {
|
||||
let program = c_string(command.get_program())?;
|
||||
let search_path = !program.as_bytes().contains(&b'/');
|
||||
let args = std::iter::once(command.get_program())
|
||||
.chain(command.get_args())
|
||||
.map(c_string)
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
let argv = args
|
||||
.iter()
|
||||
.map(|arg| arg.as_ptr().cast_mut())
|
||||
.chain(std::iter::once(ptr::null_mut()))
|
||||
.collect::<Vec<_>>();
|
||||
let env = command
|
||||
.get_envs()
|
||||
.filter_map(|(key, value)| value.map(|value| (key, value)))
|
||||
.map(|(key, value)| {
|
||||
let mut entry = key.to_os_string();
|
||||
entry.push("=");
|
||||
entry.push(value);
|
||||
c_string(&entry)
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
let envp = env
|
||||
.iter()
|
||||
.map(|entry| entry.as_ptr().cast_mut())
|
||||
.chain(std::iter::once(ptr::null_mut()))
|
||||
.collect::<Vec<_>>();
|
||||
let cwd = command
|
||||
.get_current_dir()
|
||||
.map(|cwd| c_string(cwd.as_os_str()))
|
||||
.transpose()?;
|
||||
|
||||
// Subscribe before spawning so a child that exits immediately cannot be missed.
|
||||
let sigchld = signal(SignalKind::child())?;
|
||||
let (stdin_read, stdin_write) = io::pipe()?;
|
||||
let (stdout_read, stdout_write) = io::pipe()?;
|
||||
let (stderr_read, stderr_write) = io::pipe()?;
|
||||
let child_fds = [
|
||||
child_fd(stdin_read.into())?,
|
||||
child_fd(stdout_write.into())?,
|
||||
child_fd(stderr_write.into())?,
|
||||
];
|
||||
let stdin = ChildStdin::from_std(OwnedFd::from(stdin_write).into())?;
|
||||
let stdout = ChildStdout::from_std(OwnedFd::from(stdout_read).into())?;
|
||||
let stderr = ChildStderr::from_std(OwnedFd::from(stderr_read).into())?;
|
||||
|
||||
let mut actions = FileActions(ptr::null_mut());
|
||||
let mut attrs = Attributes(ptr::null_mut());
|
||||
let mut pid = 0;
|
||||
// SAFETY: All C strings and pipe descriptors outlive this synchronous
|
||||
// spawn. The initialized action/attribute objects are destroyed by RAII.
|
||||
let result = unsafe {
|
||||
cvt(libc::posix_spawn_file_actions_init(&mut actions.0))?;
|
||||
cvt(libc::posix_spawnattr_init(&mut attrs.0))?;
|
||||
if let Some(cwd) = &cwd {
|
||||
cvt(posix_spawn_file_actions_addchdir_np(
|
||||
&mut actions.0,
|
||||
cwd.as_ptr(),
|
||||
))?;
|
||||
}
|
||||
for (target, source) in child_fds.iter().enumerate() {
|
||||
cvt(libc::posix_spawn_file_actions_adddup2(
|
||||
&mut actions.0,
|
||||
source.as_raw_fd(),
|
||||
target as i32,
|
||||
))?;
|
||||
}
|
||||
cvt(libc::posix_spawnattr_setpgroup(
|
||||
&mut attrs.0,
|
||||
/*pgroup*/ 0,
|
||||
))?;
|
||||
let mut defaults = 0;
|
||||
cvt_errno(libc::sigemptyset(&mut defaults))?;
|
||||
cvt_errno(libc::sigaddset(&mut defaults, libc::SIGPIPE))?;
|
||||
cvt(libc::posix_spawnattr_setsigdefault(&mut attrs.0, &defaults))?;
|
||||
// Match Command's descriptor inheritance: honor FD_CLOEXEC rather
|
||||
// than introducing a different policy with CLOEXEC_DEFAULT.
|
||||
cvt(libc::posix_spawnattr_setflags(
|
||||
&mut attrs.0,
|
||||
(libc::POSIX_SPAWN_SETPGROUP | libc::POSIX_SPAWN_SETSIGDEF) as _,
|
||||
))?;
|
||||
let mut spawn = |executable: &CString| {
|
||||
libc::posix_spawn(
|
||||
&mut pid,
|
||||
executable.as_ptr(),
|
||||
&actions.0,
|
||||
&attrs.0,
|
||||
argv.as_ptr(),
|
||||
envp.as_ptr(),
|
||||
)
|
||||
};
|
||||
if !search_path {
|
||||
spawn(&program)
|
||||
} else {
|
||||
// posix_spawnp searches the parent's PATH, not envp. Search the
|
||||
// child's PATH ourselves, using Apple's default when it is unset.
|
||||
let path = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == "PATH")
|
||||
.and_then(|(_, value)| value)
|
||||
.unwrap_or(OsStr::new("/usr/bin:/bin"));
|
||||
let mut result = libc::ENOENT;
|
||||
for directory in std::env::split_paths(path) {
|
||||
let mut executable = directory.into_os_string();
|
||||
if executable.is_empty() {
|
||||
executable.push(".");
|
||||
}
|
||||
// Preserve the spelling execvp would pass to a shebang
|
||||
// interpreter, including empty entries and trailing slashes.
|
||||
executable.push("/");
|
||||
executable.push(command.get_program());
|
||||
if executable.as_bytes().len() >= libc::PATH_MAX as usize {
|
||||
return Ok(None);
|
||||
}
|
||||
result = spawn(&c_string(&executable)?);
|
||||
if !matches!(
|
||||
result,
|
||||
libc::ENOENT
|
||||
| libc::ENOTDIR
|
||||
| libc::EACCES
|
||||
| libc::ELOOP
|
||||
| libc::ENAMETOOLONG
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
// Retain Command's shell fallback and exact PATH search errors.
|
||||
if result == libc::ENOEXEC || (search_path && result != 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
cvt(result)?;
|
||||
let child = Self {
|
||||
pid: Some(pid),
|
||||
status: None,
|
||||
sigchld,
|
||||
};
|
||||
Ok(Some((child, stdin, stdout, stderr)))
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<u32> {
|
||||
self.pid.map(|pid| pid as u32)
|
||||
}
|
||||
|
||||
/// Polls and caches the exit status, relinquishing the PID once it is reaped.
|
||||
/// `ECHILD` also relinquishes it to prevent later signaling of a reused PID.
|
||||
fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
|
||||
if self.status.is_some() {
|
||||
return Ok(self.status);
|
||||
}
|
||||
let pid = self
|
||||
.pid
|
||||
.ok_or_else(|| io::Error::from_raw_os_error(libc::ECHILD))?;
|
||||
let mut status = 0;
|
||||
// SAFETY: We own this child PID and provide writable status storage.
|
||||
match unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) } {
|
||||
0 => Ok(None),
|
||||
-1 => {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.raw_os_error() == Some(libc::ECHILD) {
|
||||
self.pid = None;
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
_ => {
|
||||
self.pid = None;
|
||||
self.status = Some(ExitStatus::from_raw(status));
|
||||
Ok(self.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits without transferring child ownership into the future, so callers
|
||||
/// may cancel a wait and then wait again or kill the same child.
|
||||
async fn wait(&mut self) -> io::Result<ExitStatus> {
|
||||
loop {
|
||||
match self.try_wait() {
|
||||
Ok(Some(status)) => return Ok(status),
|
||||
Ok(None) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
self.sigchld
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_else(|| io::Error::other("SIGCHLD stream closed"))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends SIGKILL if still owned, then waits for the child to be reaped.
|
||||
async fn kill(&mut self) -> io::Result<()> {
|
||||
if let Some(pid) = self.pid {
|
||||
// SAFETY: An unreaped child retains its PID, even after it exits.
|
||||
let result = unsafe { libc::kill(pid, libc::SIGKILL) };
|
||||
if result == -1 && io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
self.wait().await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeChild {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.try_wait();
|
||||
let Some(pid) = self.pid.take() else { return };
|
||||
// SAFETY: This child has not been reaped, so its PID cannot be reused.
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
// Drop may run during runtime shutdown. Reap independently of Tokio,
|
||||
// without blocking its worker threads while the killed process exits.
|
||||
if std::thread::Builder::new()
|
||||
.name("mcp-child-reaper".into())
|
||||
.spawn(move || reap(pid))
|
||||
.is_err()
|
||||
{
|
||||
// Resource exhaustion must not turn a dropped child into a zombie.
|
||||
reap(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reaps the child transferred by `Drop`, retrying interrupted waits without
|
||||
/// requiring a live Tokio runtime. The caller relinquishes ownership of `pid`.
|
||||
fn reap(pid: libc::pid_t) {
|
||||
loop {
|
||||
// SAFETY: The caller transferred exclusive ownership of this unreaped PID.
|
||||
let result = unsafe {
|
||||
libc::waitpid(pid, ptr::null_mut(), /*options*/ 0)
|
||||
};
|
||||
if result != -1 || io::Error::last_os_error().kind() != io::ErrorKind::Interrupted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn c_string(value: &OsStr) -> io::Result<CString> {
|
||||
CString::new(value.as_bytes())
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "nul byte in MCP command"))
|
||||
}
|
||||
|
||||
/// Keeps a child pipe source above stdio so `dup2` cannot clobber another source
|
||||
/// when the parent has closed standard descriptors. Close-on-exec disposes of
|
||||
/// this extra descriptor after the spawn actions duplicate it onto stdio.
|
||||
fn child_fd(fd: OwnedFd) -> io::Result<OwnedFd> {
|
||||
// SAFETY: fcntl duplicates this live descriptor; the returned fd is newly owned.
|
||||
let duplicate = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) };
|
||||
cvt_errno(duplicate)?;
|
||||
Ok(unsafe { OwnedFd::from_raw_fd(duplicate) })
|
||||
}
|
||||
|
||||
/// Converts a spawn API's returned error number, which does not use `errno`.
|
||||
fn cvt(result: libc::c_int) -> io::Result<()> {
|
||||
if result == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::from_raw_os_error(result))
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a syscall's `-1` sentinel using the thread's current `errno`.
|
||||
fn cvt_errno(result: libc::c_int) -> io::Result<()> {
|
||||
if result == -1 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FileActions(libc::posix_spawn_file_actions_t);
|
||||
struct Attributes(libc::posix_spawnattr_t);
|
||||
|
||||
impl Drop for FileActions {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
// SAFETY: This object was initialized by posix_spawn_file_actions_init.
|
||||
unsafe {
|
||||
libc::posix_spawn_file_actions_destroy(&mut self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Attributes {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
// SAFETY: This object was initialized by posix_spawnattr_init.
|
||||
unsafe {
|
||||
libc::posix_spawnattr_destroy(&mut self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "macos_child_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Regression coverage for native spawn compatibility, descriptor inheritance, and reaping.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
async fn native_output(command: Command) -> anyhow::Result<std::process::Output> {
|
||||
let mut child = crate::spawn_child(command)?;
|
||||
assert!(matches!(child.inner, ChildKind::Native(_)));
|
||||
drop(child.stdin.take());
|
||||
let mut stdout = child.stdout.take().expect("piped stdout");
|
||||
let mut stderr = child.stderr.take().expect("piped stderr");
|
||||
let mut output = Vec::new();
|
||||
let mut diagnostic = Vec::new();
|
||||
let (_, _, status) = tokio::try_join!(
|
||||
stdout.read_to_end(&mut output),
|
||||
stderr.read_to_end(&mut diagnostic),
|
||||
child.wait()
|
||||
)?;
|
||||
Ok(std::process::Output {
|
||||
status,
|
||||
stdout: output,
|
||||
stderr: diagnostic,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bare_script_search_matches_child_path_and_preserves_script_spelling() -> anyhow::Result<()>
|
||||
{
|
||||
let root = tempfile::tempdir()?;
|
||||
let program = std::ffi::OsString::from("server=é");
|
||||
let bin = root.path().join("bin");
|
||||
let blocked = root.path().join("blocked");
|
||||
fs::create_dir(&bin)?;
|
||||
fs::create_dir(&blocked)?;
|
||||
fs::write(blocked.join(&program), "not executable")?;
|
||||
let script = bin.join(&program);
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$0\" \"$1\" \"$MCP_TEST\"; /bin/pwd; printf diagnostic >&2; exit 23\n",
|
||||
)?;
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(/*mode*/ 0o755))?;
|
||||
symlink(&script, root.path().join(&program))?;
|
||||
for path in [
|
||||
bin.into_os_string(),
|
||||
"bin/".into(),
|
||||
"missing:blocked:bin".into(),
|
||||
"missing:".into(),
|
||||
"".into(),
|
||||
] {
|
||||
let mut command = Command::new(&program);
|
||||
command
|
||||
.current_dir(root.path())
|
||||
.env_clear()
|
||||
.env("PATH", path)
|
||||
.env("MCP_TEST", "kept")
|
||||
.arg("spaces ; literal $arg");
|
||||
let expected = command.output().await?;
|
||||
assert_eq!(native_output(command).await?, expected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bare_executable_uses_default_path_and_preserves_argv0() -> anyhow::Result<()> {
|
||||
let mut command = Command::new("sh");
|
||||
command.env_clear().args(["-c", "printf '%s' \"$0\""]);
|
||||
let expected = command.output().await?;
|
||||
assert_eq!(native_output(command).await?, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_script_preserves_paths_stdio_environment_and_process_group() -> anyhow::Result<()>
|
||||
{
|
||||
let root = tempfile::tempdir()?;
|
||||
fs::create_dir_all(root.path().join("actual/bin"))?;
|
||||
symlink("actual/bin", root.path().join("link"))?;
|
||||
let script = root.path().join("actual/server");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nread -r input\nprintf '%s\\n' \"$0\" \"$1\" \"$2\" \"$MCP_TEST\" \"$input\"\nprintf diagnostic >&2\nexit 23\n",
|
||||
)?;
|
||||
fs::set_permissions(script, fs::Permissions::from_mode(0o755))?;
|
||||
let mut command = std::process::Command::new("./link/../server");
|
||||
command
|
||||
.current_dir(root.path())
|
||||
.env_clear()
|
||||
.env("MCP_TEST", "kept")
|
||||
.arg("spaces ; literal $arg")
|
||||
.arg(std::ffi::OsString::from_vec(b"raw-\xff".to_vec()));
|
||||
let (mut child, mut stdin, mut stdout, mut stderr) =
|
||||
NativeChild::spawn(&command)?.expect("native child");
|
||||
let pid = child.id().expect("live PID") as libc::pid_t;
|
||||
// SAFETY: getpgid only inspects the live child, which waits for input below.
|
||||
assert_eq!(unsafe { libc::getpgid(pid) }, pid);
|
||||
stdin.write_all(b"hello\n").await?;
|
||||
drop(stdin);
|
||||
let mut output = Vec::new();
|
||||
let mut diagnostic = String::new();
|
||||
let (_, _, status) = tokio::try_join!(
|
||||
stdout.read_to_end(&mut output),
|
||||
stderr.read_to_string(&mut diagnostic),
|
||||
child.wait()
|
||||
)?;
|
||||
assert_eq!(
|
||||
(output.as_slice(), diagnostic.as_str(), status.code()),
|
||||
(
|
||||
b"./link/../server\nspaces ; literal $arg\nraw-\xff\nkept\nhello\n".as_slice(),
|
||||
"diagnostic",
|
||||
Some(23)
|
||||
)
|
||||
);
|
||||
assert_eq!(child.wait().await?, status);
|
||||
assert_eq!(child.id(), None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_executable_preserves_argv0() -> anyhow::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
symlink("/bin/sh", root.path().join("shell"))?;
|
||||
let program = Path::new("./shell");
|
||||
let mut command = std::process::Command::new(program);
|
||||
command
|
||||
.current_dir(root.path())
|
||||
.env_clear()
|
||||
.args(["-c", "printf '%s' \"$0\""]);
|
||||
let (mut child, stdin, mut stdout, _stderr) =
|
||||
NativeChild::spawn(&command)?.expect("native child");
|
||||
drop(stdin);
|
||||
let mut output = Vec::new();
|
||||
stdout.read_to_end(&mut output).await?;
|
||||
assert!(child.wait().await?.success());
|
||||
assert_eq!(output, program.as_os_str().as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_wait_can_still_kill_and_reap_child() -> anyhow::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
symlink("/bin/cat", root.path().join("server"))?;
|
||||
let mut command = std::process::Command::new("./server");
|
||||
command.current_dir(root.path()).env_clear();
|
||||
let (mut child, _stdin, _stdout, _stderr) =
|
||||
NativeChild::spawn(&command)?.expect("native child");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), child.wait())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
child.kill().await?;
|
||||
assert_eq!(child.wait().await?.signal(), Some(libc::SIGKILL));
|
||||
assert_eq!(child.id(), None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn descriptor_inheritance_matches_command() -> anyhow::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
symlink("/bin/sh", root.path().join("shell"))?;
|
||||
let file = fs::File::open("/dev/null")?;
|
||||
for (operation, expected) in [
|
||||
(libc::F_DUPFD, "inherited"),
|
||||
(libc::F_DUPFD_CLOEXEC, "closed"),
|
||||
] {
|
||||
// SAFETY: Duplicate a harmless descriptor with the requested inheritance flag.
|
||||
let fd = unsafe { libc::fcntl(file.as_raw_fd(), operation, 200) };
|
||||
cvt_errno(fd)?;
|
||||
// SAFETY: fcntl returned a new owned descriptor.
|
||||
let _sentinel = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
let mut command = std::process::Command::new("./shell");
|
||||
command
|
||||
.current_dir(root.path())
|
||||
.env_clear()
|
||||
.env("SENTINEL", fd.to_string())
|
||||
.args([
|
||||
"-c",
|
||||
"if [ -e /dev/fd/\"$SENTINEL\" ]; then printf inherited; else printf closed; fi",
|
||||
]);
|
||||
let (mut child, stdin, mut stdout, _stderr) =
|
||||
NativeChild::spawn(&command)?.expect("native child");
|
||||
drop(stdin);
|
||||
let mut output = String::new();
|
||||
stdout.read_to_string(&mut output).await?;
|
||||
assert!(child.wait().await?.success());
|
||||
assert_eq!(output, expected);
|
||||
assert_eq!(command.output()?.stdout, output.as_bytes());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_failures_preserve_os_errors() -> anyhow::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
fs::write(root.path().join("not-executable"), "#!/bin/sh\nexit 0\n")?;
|
||||
for (program, cwd, errno) in [
|
||||
("./missing", root.path().to_path_buf(), libc::ENOENT),
|
||||
("./not-executable", root.path().to_path_buf(), libc::EACCES),
|
||||
("missing", root.path().to_path_buf(), libc::ENOENT),
|
||||
("not-executable", root.path().to_path_buf(), libc::EACCES),
|
||||
("", root.path().to_path_buf(), libc::ENOENT),
|
||||
("/bin/sh", root.path().join("missing"), libc::ENOENT),
|
||||
] {
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.env_clear()
|
||||
.env("PATH", root.path())
|
||||
.current_dir(cwd);
|
||||
let error = crate::spawn_child(command)
|
||||
.err()
|
||||
.expect("spawn should fail");
|
||||
assert_eq!(error.raw_os_error(), Some(errno));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executable_text_without_shebang_retains_command_fallback() -> anyhow::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let script = root.path().join("server");
|
||||
fs::write(&script, "printf '%s' \"$0\"\n")?;
|
||||
fs::set_permissions(script, fs::Permissions::from_mode(0o755))?;
|
||||
for program in ["./server", "server"] {
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.current_dir(root.path())
|
||||
.env_clear()
|
||||
.env("PATH", ".");
|
||||
let mut child = crate::spawn_child(command)?;
|
||||
let mut output = Vec::new();
|
||||
child
|
||||
.stdout
|
||||
.take()
|
||||
.expect("piped stdout")
|
||||
.read_to_end(&mut output)
|
||||
.await?;
|
||||
assert!(child.wait().await?.success());
|
||||
assert_eq!(output, b"./server");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_after_runtime_shutdown_kills_and_reaps_child() -> anyhow::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
symlink("/bin/cat", root.path().join("server"))?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let (child, stdin, _stdout, _stderr) = runtime
|
||||
.block_on(async {
|
||||
let mut command = std::process::Command::new("./server");
|
||||
command.current_dir(root.path()).env_clear();
|
||||
NativeChild::spawn(&command)
|
||||
})?
|
||||
.expect("native child");
|
||||
let pid = child.id().expect("live PID") as libc::pid_t;
|
||||
drop(runtime);
|
||||
drop(child);
|
||||
drop(stdin);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
// SAFETY: Signal zero probes existence without changing the process.
|
||||
if unsafe { libc::kill(pid, 0) } == -1 {
|
||||
assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH));
|
||||
break;
|
||||
}
|
||||
anyhow::ensure!(std::time::Instant::now() < deadline, "child was not reaped");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
// SAFETY: WNOHANG verifies our drop reaper already collected this child.
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(pid, ptr::null_mut(), libc::WNOHANG) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(
|
||||
io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::ECHILD)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user