diff --git a/codex-rs/rmcp-client/src/local_stdio_transport.rs b/codex-rs/rmcp-client/src/local_stdio_transport.rs index 840425e36e..381aad1a99 100644 --- a/codex-rs/rmcp-client/src/local_stdio_transport.rs +++ b/codex-rs/rmcp-client/src/local_stdio_transport.rs @@ -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, transport: StdioTransport, } @@ -39,7 +39,7 @@ impl LocalStdioTransport { program_name: String, protocol_mode: McpProtocolMode, ) -> io::Result<(Self, Option)> { - 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 { diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 9ef3b18c4d..2586efe259 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -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) => { diff --git a/codex-rs/utils/pty/src/child.rs b/codex-rs/utils/pty/src/child.rs index bc24665cb4..6abd9fcd22 100644 --- a/codex-rs/utils/pty/src/child.rs +++ b/codex-rs/utils/pty/src/child.rs @@ -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, + pub stdout: Option, + pub stderr: Option, +} -pub fn spawn(mut command: Command) -> io::Result { - 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 { + 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 { + 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, + } } } diff --git a/codex-rs/utils/pty/src/child_command.rs b/codex-rs/utils/pty/src/child_command.rs new file mode 100644 index 0000000000..2f84d83dc7 --- /dev/null +++ b/codex-rs/utils/pty/src/child_command.rs @@ -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) -> 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) -> &mut Self { + self.inner.arg(arg); + self + } + + pub fn args(&mut self, args: impl IntoIterator>) -> &mut Self { + self.inner.args(args); + self + } + + pub fn env(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + self.inner.env(key, value); + self + } + + pub fn envs(&mut self, env: impl IntoIterator) -> &mut Self + where + K: AsRef, + V: AsRef, + { + self.inner.envs(env); + self + } + + pub fn current_dir(&mut self, cwd: impl AsRef) -> &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 { + #[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; diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 1587729da6..732ecf06fc 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -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; diff --git a/codex-rs/utils/pty/src/macos_child.rs b/codex-rs/utils/pty/src/macos_child.rs index 7ed689ee99..782e7262e4 100644 --- a/codex-rs/utils/pty/src/macos_child.rs +++ b/codex-rs/utils/pty/src/macos_child.rs @@ -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, - pub stdout: Option, - pub stderr: Option, -} - -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 { - 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 { - match &self.inner { - ChildKind::Tokio(child) => child.id(), - ChildKind::Native(child) => child.id(), - } - } - - pub async fn wait(&mut self) -> io::Result { - 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, status: Option, 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> { 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 { + pub(crate) fn id(&self) -> Option { 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 { + pub(crate) async fn wait(&mut self) -> io::Result { 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; diff --git a/codex-rs/utils/pty/src/macos_child_tests.rs b/codex-rs/utils/pty/src/macos_child_tests.rs index 39b17e0157..f534460708 100644 --- a/codex-rs/utils/pty/src/macos_child_tests.rs +++ b/codex-rs/utils/pty/src/macos_child_tests.rs @@ -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 { - 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(()) +}