Skip to content

Commit 1d13da1

Browse files
its-mashclaude
andcommitted
test: add cross-platform stdio process isolation and tests
Extract configure_child_process_platform() as a shared, testable function in the gateway transport module. On Windows it sets CREATE_NO_WINDOW to suppress console windows in release builds; on Unix it calls process_group(0) to isolate MCP server child processes from the parent's terminal signals. Add 6 new integration tests verifying that platform flags: - do not break basic child process execution - preserve bidirectional stdio communication (used by MCP protocol) - are idempotent when applied multiple times - preserve environment variable passthrough - produce correct transport description and type metadata - return proper errors for nonexistent commands Also adds the same Unix process_group(0) handling to mcpmux-mcp's transport for cross-platform parity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 470fe4e commit 1d13da1

5 files changed

Lines changed: 245 additions & 11 deletions

File tree

crates/mcpmux-gateway/src/pool/transport/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use mcpmux_core::{CredentialRepository, OutboundOAuthRepository, ServerLogManage
1616
use uuid::Uuid;
1717

1818
pub use http::HttpTransport;
19-
pub use stdio::StdioTransport;
19+
pub use stdio::{configure_child_process_platform, StdioTransport};
2020

2121
// Re-export TransportType from mcpmux-core as the single source of truth
2222
pub use mcpmux_core::TransportType;

crates/mcpmux-gateway/src/pool/transport/stdio.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,29 @@ use uuid::Uuid;
1919
use super::TransportType;
2020
use super::{create_client_handler, Transport, TransportConnectResult};
2121

22+
/// Apply platform-specific flags to a child process command.
23+
///
24+
/// - **Windows**: Sets `CREATE_NO_WINDOW` (`0x08000000`) so the child process does not
25+
/// allocate a visible console window. Required because release builds use
26+
/// `windows_subsystem = "windows"` (GUI subsystem) and Windows would otherwise create
27+
/// a new console for every spawned console-subsystem child.
28+
///
29+
/// - **Unix (macOS / Linux)**: Calls `process_group(0)` to place the child in its own
30+
/// process group, preventing terminal signals (`SIGINT`, `SIGTSTP`) sent to the parent
31+
/// from propagating to MCP server child processes.
32+
pub fn configure_child_process_platform(cmd: &mut Command) {
33+
#[cfg(windows)]
34+
{
35+
const CREATE_NO_WINDOW: u32 = 0x08000000;
36+
cmd.creation_flags(CREATE_NO_WINDOW);
37+
}
38+
#[cfg(unix)]
39+
{
40+
use std::os::unix::process::CommandExt;
41+
cmd.process_group(0);
42+
}
43+
}
44+
2245
/// STDIO transport for child process MCP servers
2346
pub struct StdioTransport {
2447
command: String,
@@ -125,15 +148,7 @@ impl Transport for StdioTransport {
125148
.stderr(Stdio::piped()) // Capture stderr for logging
126149
.kill_on_drop(true);
127150

128-
// On Windows, prevent console window from appearing for child processes.
129-
// In release builds the app uses `windows_subsystem = "windows"` (GUI subsystem),
130-
// which causes Windows to allocate a new visible console for any spawned
131-
// console-subsystem child process. CREATE_NO_WINDOW suppresses this.
132-
#[cfg(windows)]
133-
{
134-
const CREATE_NO_WINDOW: u32 = 0x08000000;
135-
cmd.creation_flags(CREATE_NO_WINDOW);
136-
}
151+
configure_child_process_platform(cmd);
137152

138153
// Note: We can't easily access stderr after TokioChildProcess wraps it
139154
// This is a limitation of the current rmcp API

crates/mcpmux-mcp/src/transports.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,26 @@ impl McpSession {
145145
.stderr(Stdio::null())
146146
.kill_on_drop(true);
147147

148-
// On Windows, prevent console window from appearing
148+
// Platform-specific child process isolation.
149+
//
150+
// Windows: In release builds the app uses `windows_subsystem = "windows"`
151+
// (GUI subsystem), which causes Windows to allocate a new visible console
152+
// for any spawned console-subsystem child process. CREATE_NO_WINDOW
153+
// suppresses this.
154+
//
155+
// Unix (macOS/Linux): Create a new process group so terminal signals
156+
// (SIGINT, SIGTSTP) sent to the parent don't propagate to MCP server
157+
// child processes.
149158
#[cfg(windows)]
150159
{
151160
const CREATE_NO_WINDOW: u32 = 0x08000000;
152161
cmd.creation_flags(CREATE_NO_WINDOW);
153162
}
163+
#[cfg(unix)]
164+
{
165+
use std::os::unix::process::CommandExt;
166+
cmd.process_group(0);
167+
}
154168
})
155169
).context(format!(
156170
"Failed to spawn child process. Command not found: {}. Ensure it's installed and in PATH.",

tests/rust/tests/gateway/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
//! Tests for ServerManager state machine and connection handling.
44
55
mod server_manager;
6+
mod stdio_transport;
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//! STDIO transport tests
2+
//!
3+
//! Tests for cross-platform child process spawning behavior.
4+
//! Verifies that platform-specific flags (CREATE_NO_WINDOW on Windows,
5+
//! process_group on Unix) are applied correctly and don't break
6+
//! child process communication.
7+
8+
use mcpmux_gateway::pool::transport::configure_child_process_platform;
9+
use std::process::Stdio;
10+
use tokio::process::Command;
11+
12+
/// Verify that `configure_child_process_platform` can be applied to a Command
13+
/// without panicking and the resulting process runs correctly.
14+
#[tokio::test]
15+
async fn test_platform_flags_do_not_break_child_process() {
16+
// Use a cross-platform command that reads stdin and writes to stdout
17+
#[cfg(windows)]
18+
let (program, args) = ("cmd.exe", vec!["/C", "echo", "hello"]);
19+
#[cfg(unix)]
20+
let (program, args) = ("echo", vec!["hello"]);
21+
22+
let mut cmd = Command::new(program);
23+
cmd.args(&args)
24+
.stdin(Stdio::null())
25+
.stdout(Stdio::piped())
26+
.stderr(Stdio::piped())
27+
.kill_on_drop(true);
28+
29+
configure_child_process_platform(&mut cmd);
30+
31+
let output = cmd.output().await.expect("Failed to spawn child process");
32+
assert!(output.status.success(), "Child process exited with error");
33+
34+
let stdout = String::from_utf8_lossy(&output.stdout);
35+
assert!(
36+
stdout.trim().contains("hello"),
37+
"Expected 'hello' in stdout, got: {stdout}"
38+
);
39+
}
40+
41+
/// Verify that a child process with platform flags can do bidirectional I/O
42+
/// (stdin -> stdout), which is the pattern used by stdio MCP transports.
43+
#[tokio::test]
44+
async fn test_platform_flags_preserve_stdio_communication() {
45+
// Use a command that reads from stdin and echoes to stdout
46+
#[cfg(windows)]
47+
let mut cmd = Command::new("cmd.exe");
48+
#[cfg(windows)]
49+
cmd.args(["/C", "findstr", "."]);
50+
51+
#[cfg(unix)]
52+
let mut cmd = Command::new("cat");
53+
54+
cmd.stdin(Stdio::piped())
55+
.stdout(Stdio::piped())
56+
.stderr(Stdio::piped())
57+
.kill_on_drop(true);
58+
59+
configure_child_process_platform(&mut cmd);
60+
61+
let mut child = cmd.spawn().expect("Failed to spawn child process");
62+
63+
// Write to stdin
64+
{
65+
use tokio::io::AsyncWriteExt;
66+
let stdin = child.stdin.as_mut().expect("stdin not available");
67+
stdin
68+
.write_all(b"test message\n")
69+
.await
70+
.expect("Failed to write to stdin");
71+
// Close stdin to signal EOF to the child
72+
stdin.shutdown().await.expect("Failed to close stdin");
73+
}
74+
75+
// Read from stdout
76+
let output = child
77+
.wait_with_output()
78+
.await
79+
.expect("Failed to wait for child");
80+
81+
assert!(output.status.success(), "Child process exited with error");
82+
let stdout = String::from_utf8_lossy(&output.stdout);
83+
assert!(
84+
stdout.contains("test message"),
85+
"Expected stdin->stdout echo, got: {stdout}"
86+
);
87+
}
88+
89+
/// Verify the transport description format
90+
#[test]
91+
fn test_stdio_transport_description() {
92+
use mcpmux_gateway::pool::transport::StdioTransport;
93+
use mcpmux_gateway::pool::Transport;
94+
use std::collections::HashMap;
95+
use std::time::Duration;
96+
use uuid::Uuid;
97+
98+
let transport = StdioTransport::new(
99+
"node".to_string(),
100+
vec!["server.js".to_string()],
101+
HashMap::new(),
102+
Uuid::new_v4(),
103+
"test-server".to_string(),
104+
None,
105+
Duration::from_secs(30),
106+
None,
107+
);
108+
109+
assert_eq!(transport.description(), "stdio:node");
110+
assert_eq!(
111+
transport.transport_type(),
112+
mcpmux_core::TransportType::Stdio
113+
);
114+
}
115+
116+
/// Verify that connect returns Failed for a non-existent command
117+
#[tokio::test]
118+
async fn test_stdio_transport_connect_command_not_found() {
119+
use mcpmux_gateway::pool::transport::StdioTransport;
120+
use mcpmux_gateway::pool::{Transport, TransportConnectResult};
121+
use std::collections::HashMap;
122+
use std::time::Duration;
123+
use uuid::Uuid;
124+
125+
let transport = StdioTransport::new(
126+
"nonexistent_command_that_does_not_exist_abc123".to_string(),
127+
vec![],
128+
HashMap::new(),
129+
Uuid::new_v4(),
130+
"test-server".to_string(),
131+
None,
132+
Duration::from_secs(5),
133+
None,
134+
);
135+
136+
let result = transport.connect().await;
137+
match result {
138+
TransportConnectResult::Failed(msg) => {
139+
assert!(
140+
msg.contains("Command not found"),
141+
"Expected 'Command not found', got: {msg}"
142+
);
143+
}
144+
_ => panic!("Expected TransportConnectResult::Failed for nonexistent command"),
145+
}
146+
}
147+
148+
/// Verify that configure_child_process_platform can be called multiple times
149+
/// without issues (idempotency).
150+
#[tokio::test]
151+
async fn test_platform_flags_idempotent() {
152+
#[cfg(windows)]
153+
let program = "cmd.exe";
154+
#[cfg(unix)]
155+
let program = "true";
156+
157+
let mut cmd = Command::new(program);
158+
#[cfg(windows)]
159+
cmd.args(["/C", "exit", "0"]);
160+
cmd.stdin(Stdio::null())
161+
.stdout(Stdio::null())
162+
.stderr(Stdio::null())
163+
.kill_on_drop(true);
164+
165+
// Apply twice - should not panic or cause issues
166+
configure_child_process_platform(&mut cmd);
167+
configure_child_process_platform(&mut cmd);
168+
169+
let status = cmd.status().await.expect("Failed to spawn child process");
170+
assert!(status.success(), "Child process should exit successfully");
171+
}
172+
173+
/// Verify that environment variables are passed through correctly
174+
/// when platform flags are applied (important because CREATE_NO_WINDOW
175+
/// is OR'd with CREATE_UNICODE_ENVIRONMENT internally).
176+
#[tokio::test]
177+
async fn test_platform_flags_preserve_env_vars() {
178+
#[cfg(windows)]
179+
let mut cmd = Command::new("cmd.exe");
180+
#[cfg(windows)]
181+
cmd.args(["/C", "echo", "%MCPMUX_TEST_VAR%"]);
182+
183+
#[cfg(unix)]
184+
let mut cmd = Command::new("sh");
185+
#[cfg(unix)]
186+
cmd.args(["-c", "echo $MCPMUX_TEST_VAR"]);
187+
188+
cmd.env("MCPMUX_TEST_VAR", "test_value_42")
189+
.stdin(Stdio::null())
190+
.stdout(Stdio::piped())
191+
.stderr(Stdio::piped())
192+
.kill_on_drop(true);
193+
194+
configure_child_process_platform(&mut cmd);
195+
196+
let output = cmd.output().await.expect("Failed to spawn child process");
197+
assert!(output.status.success());
198+
199+
let stdout = String::from_utf8_lossy(&output.stdout);
200+
assert!(
201+
stdout.contains("test_value_42"),
202+
"Expected env var in output, got: {stdout}"
203+
);
204+
}

0 commit comments

Comments
 (0)