Skip to content

Commit c6d7cc8

Browse files
committed
own Child in MCP take_child_pipes so the impossible-None branch can't leak a zombie
1 parent a818faf commit c6d7cc8

2 files changed

Lines changed: 26 additions & 35 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ Gitignored scratchpad for helper files the user asks to be created there — typ
436436
- `cargo clippy --all-targets -- -D warnings`
437437
- `cargo test --all`
438438
- Before finishing, review the change for bugs and corner cases.
439-
- After each final modification, suggest a clear one-line commit message.
439+
- After each final modification, provide a clear, human-readable one-line commit message.
440440

441441
---
442442

src/mcp/client.rs

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -104,26 +104,26 @@ impl McpClient {
104104
}
105105
}
106106

107-
/// Take the stdin and (buffered) stdout out of a freshly-spawned child.
108-
/// Returning `Err` here is essentially unreachable because
109-
/// `Command::spawn` with `Stdio::piped()` on both ends always populates
110-
/// `Child::stdin` / `Child::stdout`, but we still handle it — leaking
111-
/// a live `Child` out to `Drop` would fail to reap it (the std type
112-
/// doesn't wait).
113-
fn take_child_pipes(process: &mut Child, server_name: &str) -> Result<(ChildStdin, ChildStdout)> {
114-
let stdin = process.stdin.take().ok_or_else(|| {
115-
SofosError::McpError(format!(
116-
"Failed to get stdin for MCP server '{}'",
117-
server_name
118-
))
119-
})?;
120-
let stdout = process.stdout.take().ok_or_else(|| {
121-
SofosError::McpError(format!(
122-
"Failed to get stdout for MCP server '{}'",
123-
server_name
124-
))
125-
})?;
126-
Ok((stdin, stdout))
107+
/// Take stdin/stdout from a freshly-spawned MCP child. Reaching the
108+
/// `Err` branch is practically unreachable — `Command::spawn` with
109+
/// `Stdio::piped()` on both ends always populates the pipes — but the
110+
/// type system doesn't enforce it. Owning the `Child` lets us reap it
111+
/// internally on the impossible branch, so a `?` at the call site
112+
/// can't accidentally leak a zombie via `Child::drop` (which doesn't
113+
/// wait).
114+
fn take_child_pipes(
115+
mut process: Child,
116+
server_name: &str,
117+
) -> Result<(Child, ChildStdin, ChildStdout)> {
118+
if let (Some(stdin), Some(stdout)) = (process.stdin.take(), process.stdout.take()) {
119+
return Ok((process, stdin, stdout));
120+
}
121+
let _ = process.kill();
122+
let _ = process.wait();
123+
Err(SofosError::McpError(format!(
124+
"Failed to acquire stdin/stdout for MCP server '{}'",
125+
server_name
126+
)))
127127
}
128128

129129
/// Write a single stdio message to the server (JSON-RPC request *or*
@@ -239,26 +239,17 @@ impl StdioClient {
239239
cmd.env(key, value);
240240
}
241241

242-
let mut process = cmd.spawn().map_err(|e| {
242+
let process = cmd.spawn().map_err(|e| {
243243
SofosError::McpError(format!(
244244
"Failed to start MCP server '{}': {}",
245245
server_name, e
246246
))
247247
})?;
248248

249-
// Take the pipes through a helper that reaps the child on
250-
// error, so a (practically-impossible but not-type-proven)
251-
// `None` from `take()` doesn't leak a zombie into the OS
252-
// process table. Once the child is wrapped in `Self`, the
253-
// `Drop` impl takes over.
254-
let (stdin, stdout) = match take_child_pipes(&mut process, &server_name) {
255-
Ok(pair) => pair,
256-
Err(e) => {
257-
let _ = process.kill();
258-
let _ = process.wait();
259-
return Err(e);
260-
}
261-
};
249+
// `take_child_pipes` reaps the child if either pipe is missing,
250+
// so the `?` here can't leak a zombie. Once the child is
251+
// wrapped in `Self`, the `Drop` impl takes over.
252+
let (process, stdin, stdout) = take_child_pipes(process, &server_name)?;
262253

263254
let client = Self {
264255
server_name: server_name.clone(),

0 commit comments

Comments
 (0)