Make local child process launch settings explicit (#46660)

## Why

The native macOS backend cannot inspect every setting or callback on a Tokio command. A shared, constrained launch API makes the supported settings explicit for both backends.

## What changed

- Replace `spawn_child` with `codex_utils_pty::Command`, enforcing an explicitly supplied environment, piped stdio, and kill-on-drop behavior.
- Add `ProcessMode::Inherit` and `ProcessMode::NewGroup`, honoring the selected mode in native macOS and Tokio spawning.
- Share the `Child` wrapper across platforms and migrate local MCP server launching to the new API, retaining its new process group and Windows suspended-spawn support.

## Testing

Adapt the existing macOS spawn compatibility and child lifecycle tests to the new API. Add a regression test that checks inherited and new process groups with both backends.

GitOrigin-RevId: 3e7a401659e3a8a94a13c4ff58d67bdcd2eea33d
This commit is contained in:
Charlie Marsh
2026-09-19 15:00:41 +00:00
committed by copyberry
parent d086e2752d
commit 3801fc8dea
7 changed files with 275 additions and 145 deletions

View File

@@ -6,6 +6,7 @@ use std::future::Future;
use std::io;
use std::time::Duration;
use codex_utils_pty::Command;
use futures::FutureExt;
use rmcp::service::RoleClient;
use rmcp::service::RxJsonRpcMessage;
@@ -15,14 +16,13 @@ use rmcp::transport::async_rw::AsyncRwTransport;
use tokio::process::ChildStderr;
use tokio::process::ChildStdin;
use tokio::process::ChildStdout;
use tokio::process::Command;
use crate::bounded_stdio_transport::BoundedStdioTransport;
use crate::protocol_mode::McpProtocolMode;
use codex_utils_pty::Child;
pub(super) struct LocalStdioTransport {
child: Child,
child: Box<Child>,
transport: StdioTransport,
}
@@ -39,7 +39,7 @@ impl LocalStdioTransport {
program_name: String,
protocol_mode: McpProtocolMode,
) -> io::Result<(Self, Option<ChildStderr>)> {
let mut child = codex_utils_pty::spawn_child(command)?;
let mut child = command.spawn()?;
let stdin = child
.stdin
.take()
@@ -55,7 +55,13 @@ impl LocalStdioTransport {
StdioTransport::V20260728(BoundedStdioTransport::new(stdin, stdout, program_name))
}
};
Ok((Self { child, transport }, stderr))
Ok((
Self {
child: Box::new(child),
transport,
},
stderr,
))
}
pub(super) fn id(&self) -> Option<u32> {

View File

@@ -38,6 +38,8 @@ use codex_exec_server::ExecProcess;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use codex_utils_pty::Command;
use codex_utils_pty::ProcessMode;
#[cfg(unix)]
use codex_utils_pty::process_group::kill_process_group;
#[cfg(unix)]
@@ -50,7 +52,6 @@ use rmcp::service::TxJsonRpcMessage;
use rmcp::transport::Transport;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio::sync::watch;
use tokio::time::Instant;
use tracing::info;
@@ -276,13 +277,8 @@ impl LocalStdioServerLauncher {
let build_command = || {
let mut command = Command::new(&resolved_program);
command
.current_dir(&cwd)
.env_clear()
.envs(&envs)
.args(&args);
#[cfg(unix)]
command.process_group(0);
command.current_dir(&cwd).envs(&envs).args(&args);
command.process_mode(ProcessMode::NewGroup);
command
};
#[cfg(windows)]
@@ -292,7 +288,7 @@ impl LocalStdioServerLauncher {
#[cfg(windows)]
let job = match codex_utils_pty::JobObject::create_without_breakaway() {
Ok(job) => {
job.prepare_suspended_spawn(&mut command);
command.prepare_suspended_spawn(&job);
Some(job)
}
Err(error) => {

View File

@@ -1,34 +1,59 @@
//! Uniform child-process API for local subprocesses, with platform-specific spawning.
//! Local child ownership and platform selection, independent of process transports.
//!
//! Commands must use the launcher's cleared environment, process group, and default
//! argv[0]. Both implementations expose Tokio stdio handles and kill on drop.
//! Every child exposes Tokio stdio handles. Native children retain their PID until
//! reaped, including when a wait is cancelled or the async runtime shuts down.
use std::io;
use std::process::Stdio;
use std::process::ExitStatus;
use tokio::process::Command;
use tokio::process::ChildStderr;
use tokio::process::ChildStdin;
use tokio::process::ChildStdout;
#[cfg(target_os = "macos")]
#[path = "macos_child.rs"]
mod macos;
pub(super) mod macos;
#[cfg(target_os = "macos")]
pub use macos::Child;
#[cfg(not(target_os = "macos"))]
pub use tokio::process::Child;
/// A local subprocess with owned stdio and cancellation-safe exit handling.
pub struct Child {
pub(super) inner: ChildKind,
pub stdin: Option<ChildStdin>,
pub stdout: Option<ChildStdout>,
pub stderr: Option<ChildStderr>,
}
pub fn spawn(mut command: Command) -> io::Result<Child> {
command
.kill_on_drop(true)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
pub(super) enum ChildKind {
Tokio(tokio::process::Child),
#[cfg(target_os = "macos")]
{
Child::spawn(command)
Native(macos::NativeChild),
}
impl Child {
pub fn id(&self) -> Option<u32> {
match &self.inner {
ChildKind::Tokio(child) => child.id(),
#[cfg(target_os = "macos")]
ChildKind::Native(child) => child.id(),
}
}
#[cfg(not(target_os = "macos"))]
{
command.spawn()
/// Close retained stdin and wait without giving up ownership on cancellation.
pub async fn wait(&mut self) -> io::Result<ExitStatus> {
self.stdin.take();
match &mut self.inner {
ChildKind::Tokio(child) => child.wait().await,
#[cfg(target_os = "macos")]
ChildKind::Native(child) => child.wait().await,
}
}
/// Kill the direct child and reap it. Process-tree policy belongs to the caller.
pub async fn kill(&mut self) -> io::Result<()> {
self.stdin.take();
match &mut self.inner {
ChildKind::Tokio(child) => child.kill().await,
#[cfg(target_os = "macos")]
ChildKind::Native(child) => child.kill().await,
}
}
}

View File

@@ -0,0 +1,118 @@
//! Explicit local launch settings shared by native and Tokio process creation.
//!
//! The wrapped Tokio command is private: callers cannot install callbacks or
//! change settings that the native backend cannot inspect. Children receive only
//! explicitly supplied environment variables, piped stdio, and kill-on-drop.
use std::ffi::OsStr;
use std::io;
use std::path::Path;
use std::process::Stdio;
use crate::child::Child;
use crate::child::ChildKind;
/// Relationship between a child and its parent's process group.
#[derive(Clone, Copy)]
pub enum ProcessMode {
Inherit,
NewGroup,
}
/// A local command whose complete launch contract is known to both backends.
pub struct Command {
inner: tokio::process::Command,
process_mode: ProcessMode,
}
impl Command {
pub fn new(program: impl AsRef<OsStr>) -> Self {
let mut inner = tokio::process::Command::new(program);
inner
.env_clear()
.kill_on_drop(true)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Self {
inner,
process_mode: ProcessMode::Inherit,
}
}
pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
self.inner.arg(arg);
self
}
pub fn args(&mut self, args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> &mut Self {
self.inner.args(args);
self
}
pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
self.inner.env(key, value);
self
}
pub fn envs<K, V>(&mut self, env: impl IntoIterator<Item = (K, V)>) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.inner.envs(env);
self
}
pub fn current_dir(&mut self, cwd: impl AsRef<Path>) -> &mut Self {
self.inner.current_dir(cwd);
self
}
pub fn process_mode(&mut self, mode: ProcessMode) -> &mut Self {
self.process_mode = mode;
self
}
/// Preserve Job Object assignment before the child begins executing on Windows.
#[cfg(windows)]
pub fn prepare_suspended_spawn(&mut self, job: &crate::JobObject) {
job.prepare_suspended_spawn(&mut self.inner);
}
/// Launch with native macOS path handling and the existing compatibility fallback.
pub fn spawn(mut self) -> io::Result<Child> {
#[cfg(unix)]
if let ProcessMode::NewGroup = self.process_mode {
self.inner.process_group(/*pgroup*/ 0);
}
#[cfg(target_os = "macos")]
{
let command = self.inner.as_std();
let program = command.get_program();
if Path::new(program).is_relative()
&& !program.is_empty()
&& let Some((child, stdin, stdout, stderr)) =
crate::child::macos::NativeChild::spawn(command, self.process_mode)?
{
return Ok(Child {
inner: ChildKind::Native(child),
stdin: Some(stdin),
stdout: Some(stdout),
stderr: Some(stderr),
});
}
}
let mut child = self.inner.spawn()?;
Ok(Child {
stdin: child.stdin.take(),
stdout: child.stdout.take(),
stderr: child.stderr.take(),
inner: ChildKind::Tokio(child),
})
}
}
#[cfg(all(test, target_os = "macos"))]
#[path = "macos_child_tests.rs"]
mod tests;

View File

@@ -1,6 +1,8 @@
mod child;
pub use child::Child;
pub use child::spawn as spawn_child;
mod child_command;
pub use child_command::Command;
pub use child_command::ProcessMode;
pub mod pipe;
mod process;
pub mod process_group;

View File

@@ -3,7 +3,7 @@
//! 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
//! an explicit process-group mode, 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.
@@ -15,81 +15,16 @@ 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" {
@@ -101,7 +36,7 @@ unsafe extern "C" {
/// 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 {
pub(crate) struct NativeChild {
pid: Option<libc::pid_t>,
status: Option<ExitStatus>,
sigchld: Signal,
@@ -111,8 +46,9 @@ 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(
pub(crate) fn spawn(
command: &std::process::Command,
process_mode: crate::ProcessMode,
) -> io::Result<Option<(Self, ChildStdin, ChildStdout, ChildStderr)>> {
let program = c_string(command.get_program())?;
let search_path = !program.as_bytes().contains(&b'/');
@@ -180,10 +116,16 @@ impl NativeChild {
target as i32,
))?;
}
cvt(libc::posix_spawnattr_setpgroup(
&mut attrs.0,
/*pgroup*/ 0,
))?;
let group_flags = match process_mode {
crate::ProcessMode::Inherit => 0,
crate::ProcessMode::NewGroup => {
cvt(libc::posix_spawnattr_setpgroup(
&mut attrs.0,
/*pgroup*/ 0,
))?;
libc::POSIX_SPAWN_SETPGROUP
}
};
let mut defaults = 0;
cvt_errno(libc::sigemptyset(&mut defaults))?;
cvt_errno(libc::sigaddset(&mut defaults, libc::SIGPIPE))?;
@@ -192,7 +134,7 @@ impl NativeChild {
// 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 _,
(group_flags | libc::POSIX_SPAWN_SETSIGDEF) as _,
))?;
let mut spawn = |executable: &CString| {
libc::posix_spawn(
@@ -255,7 +197,7 @@ impl NativeChild {
Ok(Some((child, stdin, stdout, stderr)))
}
fn id(&self) -> Option<u32> {
pub(crate) fn id(&self) -> Option<u32> {
self.pid.map(|pid| pid as u32)
}
@@ -289,7 +231,7 @@ impl NativeChild {
/// 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> {
pub(crate) async fn wait(&mut self) -> io::Result<ExitStatus> {
loop {
match self.try_wait() {
Ok(Some(status)) => return Ok(status),
@@ -305,7 +247,7 @@ impl NativeChild {
}
/// Sends SIGKILL if still owned, then waits for the child to be reaped.
async fn kill(&mut self) -> io::Result<()> {
pub(crate) 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) };
@@ -409,7 +351,3 @@ impl Drop for Attributes {
}
}
}
#[cfg(test)]
#[path = "macos_child_tests.rs"]
mod tests;

View File

@@ -1,21 +1,35 @@
//! Regression coverage for native spawn compatibility, descriptor inheritance, and reaping.
use super::*;
use crate::child::ChildKind;
use crate::child::macos::*;
use pretty_assertions::assert_eq;
use std::fs;
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::ffi::OsStringExt;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::symlink;
use std::os::unix::process::ExitStatusExt;
use std::ptr;
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)?;
let mut child = command.spawn()?;
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 stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("piped stdout"))?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| io::Error::other("piped stderr"))?;
let mut output = Vec::new();
let mut diagnostic = Vec::new();
let (_, _, status) = tokio::try_join!(
@@ -57,11 +71,11 @@ async fn bare_script_search_matches_child_path_and_preserves_script_spelling() -
let mut command = Command::new(&program);
command
.current_dir(root.path())
.env_clear()
.process_mode(ProcessMode::NewGroup)
.env("PATH", path)
.env("MCP_TEST", "kept")
.arg("spaces ; literal $arg");
let expected = command.output().await?;
let expected = command.inner.output().await?;
assert_eq!(native_output(command).await?, expected);
}
Ok(())
@@ -70,8 +84,10 @@ async fn bare_script_search_matches_child_path_and_preserves_script_spelling() -
#[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?;
command
.process_mode(ProcessMode::NewGroup)
.args(["-c", "printf '%s' \"$0\""]);
let expected = command.inner.output().await?;
assert_eq!(native_output(command).await?, expected);
Ok(())
}
@@ -88,15 +104,15 @@ async fn relative_script_preserves_paths_stdio_environment_and_process_group() -
"#!/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");
let mut command = Command::new("./link/../server");
command
.current_dir(root.path())
.env_clear()
.process_mode(ProcessMode::NewGroup)
.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");
NativeChild::spawn(command.inner.as_std(), command.process_mode)?.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);
@@ -127,13 +143,13 @@ 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);
let mut command = Command::new(program);
command
.current_dir(root.path())
.env_clear()
.process_mode(ProcessMode::NewGroup)
.args(["-c", "printf '%s' \"$0\""]);
let (mut child, stdin, mut stdout, _stderr) =
NativeChild::spawn(&command)?.expect("native child");
NativeChild::spawn(command.inner.as_std(), command.process_mode)?.expect("native child");
drop(stdin);
let mut output = Vec::new();
stdout.read_to_end(&mut output).await?;
@@ -146,10 +162,12 @@ async fn native_executable_preserves_argv0() -> anyhow::Result<()> {
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 command = Command::new("./server");
command
.current_dir(root.path())
.process_mode(ProcessMode::NewGroup);
let (mut child, _stdin, _stdout, _stderr) =
NativeChild::spawn(&command)?.expect("native child");
NativeChild::spawn(command.inner.as_std(), command.process_mode)?.expect("native child");
assert!(
tokio::time::timeout(Duration::from_millis(20), child.wait())
.await
@@ -172,26 +190,30 @@ async fn descriptor_inheritance_matches_command() -> anyhow::Result<()> {
] {
// SAFETY: Duplicate a harmless descriptor with the requested inheritance flag.
let fd = unsafe { libc::fcntl(file.as_raw_fd(), operation, 200) };
cvt_errno(fd)?;
assert!(fd >= 0);
// SAFETY: fcntl returned a new owned descriptor.
let _sentinel = unsafe { OwnedFd::from_raw_fd(fd) };
let mut command = std::process::Command::new("./shell");
let mut command = Command::new("./shell");
command
.current_dir(root.path())
.env_clear()
.process_mode(ProcessMode::NewGroup)
.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");
NativeChild::spawn(command.inner.as_std(), command.process_mode)?
.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());
assert_eq!(
command.inner.as_std_mut().output()?.stdout,
output.as_bytes()
);
}
Ok(())
}
@@ -210,12 +232,10 @@ async fn launch_failures_preserve_os_errors() -> anyhow::Result<()> {
] {
let mut command = Command::new(program);
command
.env_clear()
.process_mode(ProcessMode::NewGroup)
.env("PATH", root.path())
.current_dir(cwd);
let error = crate::spawn_child(command)
.err()
.expect("spawn should fail");
let error = command.spawn().err().expect("spawn should fail");
assert_eq!(error.raw_os_error(), Some(errno));
}
Ok(())
@@ -231,9 +251,9 @@ async fn executable_text_without_shebang_retains_command_fallback() -> anyhow::R
let mut command = Command::new(program);
command
.current_dir(root.path())
.env_clear()
.process_mode(ProcessMode::NewGroup)
.env("PATH", ".");
let mut child = crate::spawn_child(command)?;
let mut child = command.spawn()?;
let mut output = Vec::new();
child
.stdout
@@ -256,9 +276,11 @@ fn dropping_after_runtime_shutdown_kills_and_reaps_child() -> anyhow::Result<()>
.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)
let mut command = Command::new("./server");
command
.current_dir(root.path())
.process_mode(ProcessMode::NewGroup);
NativeChild::spawn(command.inner.as_std(), command.process_mode)
})?
.expect("native child");
let pid = child.id().expect("live PID") as libc::pid_t;
@@ -286,3 +308,26 @@ fn dropping_after_runtime_shutdown_kills_and_reaps_child() -> anyhow::Result<()>
);
Ok(())
}
#[tokio::test]
async fn process_mode_is_preserved_by_both_backends() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
symlink("/bin/cat", root.path().join("server"))?;
for program in ["./server", "/bin/cat"] {
for mode in [ProcessMode::Inherit, ProcessMode::NewGroup] {
let mut command = Command::new(program);
command.current_dir(root.path()).process_mode(mode);
let mut child = command.spawn()?;
let pid = child.id().expect("live PID") as libc::pid_t;
let expected = match mode {
// SAFETY: getpgrp only inspects the current process.
ProcessMode::Inherit => unsafe { libc::getpgrp() },
ProcessMode::NewGroup => pid,
};
// SAFETY: The child is still owned and blocked on its stdin pipe.
assert_eq!(unsafe { libc::getpgid(pid) }, expected);
child.kill().await?;
}
}
Ok(())
}