Skip to content

Commit 5176db2

Browse files
its-mashclaude
andcommitted
fix: use tokio ChildStderr for stdio server log capture
TokioChildProcess::new() overrides stderr set via .configure() with Stdio::inherit(), so process stderr was never captured. Switch to the builder pattern (TokioChildProcess::builder().stderr(Stdio::piped()) .spawn()) which correctly returns a ChildStderr handle. Replace blocking os_pipe + spawn_blocking with async tokio BufReader + lines() for idiomatic async stderr reading. Remove os_pipe dep from gateway crate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent e52bf07 commit 5176db2

3 files changed

Lines changed: 92 additions & 121 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/mcpmux-gateway/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ zeroize = "1.8"
5252
which = "7.0"
5353
open = "5.3"
5454
dirs = "5.0"
55-
os_pipe = { workspace = true }
5655

5756
# MCP SDK
5857
rmcp.workspace = true

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

Lines changed: 92 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
//! Handles connecting to MCP servers that run as child processes
44
//! communicating over stdin/stdout.
55
//!
6-
//! Process stderr is captured via an OS pipe and streamed to the server
7-
//! log manager, making terminal output visible in the desktop log viewer.
8-
//! These logs are internal to the desktop app and are never exposed
6+
//! Process stderr is captured via tokio's piped stderr and streamed to the
7+
//! server log manager, making terminal output visible in the desktop log
8+
//! viewer. This works generically for any runtime (npx, node, docker, python,
9+
//! etc.). These logs are internal to the desktop app and are never exposed
910
//! externally via the HTTP gateway.
1011
1112
use std::collections::HashMap;
@@ -17,7 +18,8 @@ use async_trait::async_trait;
1718
use mcpmux_core::{LogLevel, LogSource, ServerLog, ServerLogManager};
1819
use rmcp::transport::{ConfigureCommandExt, TokioChildProcess};
1920
use rmcp::ServiceExt;
20-
use tokio::process::Command;
21+
use tokio::io::AsyncBufReadExt;
22+
use tokio::process::{ChildStderr, Command};
2123
use tracing::{debug, error, info, warn};
2224
use uuid::Uuid;
2325

@@ -56,23 +58,13 @@ fn command_hint(command: &str) -> &'static str {
5658
}
5759
}
5860

59-
/// Create an OS pipe for stderr capture.
60-
///
61-
/// Returns `(reader, write_stdio)` where:
62-
/// - `reader` is a blocking `PipeReader` for the read end
63-
/// - `write_stdio` is a `Stdio` for the child process's stderr
64-
fn create_stderr_pipe() -> std::io::Result<(os_pipe::PipeReader, Stdio)> {
65-
let (reader, writer) = os_pipe::pipe()?;
66-
Ok((reader, writer.into()))
67-
}
68-
69-
/// Spawn a background task that reads lines from the process stderr pipe
61+
/// Spawn an async task that reads lines from the child process stderr
7062
/// and logs them to the server log manager.
7163
///
72-
/// The task runs on the blocking thread pool until the pipe is closed
73-
/// (child process exits) or an I/O error occurs.
64+
/// The task runs until the stderr stream is closed (child process exits)
65+
/// or an I/O error occurs.
7466
fn spawn_stderr_reader(
75-
stderr_file: os_pipe::PipeReader,
67+
stderr: ChildStderr,
7668
log_manager: Option<Arc<ServerLogManager>>,
7769
space_id: Uuid,
7870
server_id: String,
@@ -83,23 +75,22 @@ fn spawn_stderr_reader(
8375

8476
let space_id_str = space_id.to_string();
8577

86-
tokio::task::spawn_blocking(move || {
87-
use std::io::BufRead;
78+
tokio::spawn(async move {
79+
let reader = tokio::io::BufReader::new(stderr);
80+
let mut lines = reader.lines();
8881

89-
let rt = match tokio::runtime::Handle::try_current() {
90-
Ok(h) => h,
91-
Err(_) => return,
92-
};
93-
94-
let reader = std::io::BufReader::new(stderr_file);
95-
96-
for line_result in reader.lines() {
97-
match line_result {
98-
Ok(line) if line.is_empty() => continue,
99-
Ok(line) => {
82+
loop {
83+
match lines.next_line().await {
84+
Ok(Some(line)) if line.is_empty() => continue,
85+
Ok(Some(line)) => {
10086
let level = classify_stderr_line(&line);
10187
let log = ServerLog::new(level, LogSource::Stderr, &line);
102-
let _ = rt.block_on(log_manager.append(&space_id_str, &server_id, log));
88+
let _ = log_manager.append(&space_id_str, &server_id, log).await;
89+
}
90+
Ok(None) => {
91+
// EOF - child process closed stderr
92+
debug!(server_id = %server_id, "Stderr reader finished (stream closed)");
93+
break;
10394
}
10495
Err(e) => {
10596
debug!(
@@ -111,8 +102,6 @@ fn spawn_stderr_reader(
111102
}
112103
}
113104
}
114-
115-
debug!(server_id = %server_id, "Stderr reader finished (pipe closed)");
116105
});
117106
}
118107

@@ -178,26 +167,65 @@ impl StdioTransport {
178167
}
179168
}
180169
}
170+
}
171+
172+
#[async_trait]
173+
impl Transport for StdioTransport {
174+
async fn connect(&self) -> TransportConnectResult {
175+
info!(
176+
server_id = %self.server_id,
177+
command = %self.command,
178+
"Connecting to STDIO server"
179+
);
180+
181+
// Log connection attempt
182+
self.log(
183+
LogLevel::Info,
184+
LogSource::Connection,
185+
format!("Connecting to server: {} {:?}", self.command, self.args),
186+
)
187+
.await;
181188

182-
/// Internal helper: attempt connection with a given stderr Stdio target.
183-
async fn connect_with_stderr(
184-
&self,
185-
command_path: &std::path::Path,
186-
stderr_config: Stdio,
187-
) -> TransportConnectResult {
189+
// Validate command exists
190+
let command_path = match which::which(&self.command)
191+
.or_else(|_| which::which(format!("{}.exe", &self.command)))
192+
{
193+
Ok(path) => path,
194+
Err(_) => {
195+
let hint = command_hint(&self.command);
196+
let err = format!(
197+
"Command not found: {}. Ensure it's installed and in PATH.{hint}",
198+
self.command
199+
);
200+
error!(server_id = %self.server_id, "{}", err);
201+
self.log(LogLevel::Error, LogSource::Connection, err.clone())
202+
.await;
203+
return TransportConnectResult::Failed(err);
204+
}
205+
};
206+
207+
debug!(
208+
server_id = %self.server_id,
209+
path = ?command_path,
210+
"Found command"
211+
);
212+
213+
// Spawn the child process using the builder pattern so that stderr
214+
// is configured through the builder (not overridden).
215+
// TokioChildProcess::new() would override our stderr setting with
216+
// Stdio::inherit(), so we must use builder().stderr().spawn().
188217
let args = self.args.clone();
189218
let env = self.env.clone();
190219

191-
let transport =
192-
match TokioChildProcess::new(Command::new(command_path).configure(move |cmd| {
193-
cmd.args(&args)
194-
.envs(&env)
195-
.stderr(stderr_config)
196-
.kill_on_drop(true);
197-
220+
let (transport, child_stderr) =
221+
match TokioChildProcess::builder(Command::new(&command_path).configure(move |cmd| {
222+
cmd.args(&args).envs(&env).kill_on_drop(true);
198223
configure_child_process_platform(cmd);
199-
})) {
200-
Ok(t) => t,
224+
}))
225+
.stderr(Stdio::piped())
226+
.spawn()
227+
{
228+
Ok(result) => result,
201229
Err(e) => {
202230
let hint = command_hint(&self.command);
203231
let err = format!("Failed to spawn process: {e}.{hint}");
@@ -208,6 +236,21 @@ impl StdioTransport {
208236
}
209237
};
210238

239+
// Start the async stderr reader if we got a handle
240+
if let Some(stderr) = child_stderr {
241+
spawn_stderr_reader(
242+
stderr,
243+
self.log_manager.clone(),
244+
self.space_id,
245+
self.server_id.clone(),
246+
);
247+
} else {
248+
warn!(
249+
server_id = %self.server_id,
250+
"No stderr handle available - process logs will not be captured"
251+
);
252+
}
253+
211254
// Create client handler
212255
let client_handler = create_client_handler(
213256
&self.server_id,
@@ -252,76 +295,6 @@ impl StdioTransport {
252295

253296
TransportConnectResult::Connected(client)
254297
}
255-
}
256-
257-
#[async_trait]
258-
impl Transport for StdioTransport {
259-
async fn connect(&self) -> TransportConnectResult {
260-
info!(
261-
server_id = %self.server_id,
262-
command = %self.command,
263-
"Connecting to STDIO server"
264-
);
265-
266-
// Log connection attempt
267-
self.log(
268-
LogLevel::Info,
269-
LogSource::Connection,
270-
format!("Connecting to server: {} {:?}", self.command, self.args),
271-
)
272-
.await;
273-
274-
// Validate command exists
275-
let command_path = match which::which(&self.command)
276-
.or_else(|_| which::which(format!("{}.exe", &self.command)))
277-
{
278-
Ok(path) => path,
279-
Err(_) => {
280-
let hint = command_hint(&self.command);
281-
let err = format!(
282-
"Command not found: {}. Ensure it's installed and in PATH.{hint}",
283-
self.command
284-
);
285-
error!(server_id = %self.server_id, "{}", err);
286-
self.log(LogLevel::Error, LogSource::Connection, err.clone())
287-
.await;
288-
return TransportConnectResult::Failed(err);
289-
}
290-
};
291-
292-
debug!(
293-
server_id = %self.server_id,
294-
path = ?command_path,
295-
"Found command"
296-
);
297-
298-
// Create an OS pipe for stderr capture.
299-
// The write end goes to the child process, the read end stays with us
300-
// for streaming process output into the log viewer.
301-
let (stderr_read, stderr_write) = match create_stderr_pipe() {
302-
Ok(pair) => pair,
303-
Err(e) => {
304-
warn!(
305-
server_id = %self.server_id,
306-
error = %e,
307-
"Failed to create stderr pipe, falling back to null"
308-
);
309-
// Connection still works, just without process log capture
310-
return self.connect_with_stderr(&command_path, Stdio::null()).await;
311-
}
312-
};
313-
314-
// Spawn the background stderr reader before connecting.
315-
// It blocks on the read end until the child writes to stderr.
316-
spawn_stderr_reader(
317-
stderr_read,
318-
self.log_manager.clone(),
319-
self.space_id,
320-
self.server_id.clone(),
321-
);
322-
323-
self.connect_with_stderr(&command_path, stderr_write).await
324-
}
325298

326299
fn transport_type(&self) -> TransportType {
327300
TransportType::Stdio

0 commit comments

Comments
 (0)