Fix MCPConfigTransport subprocess resource leak

Test `test_multi_client_lifespan` was timing out because MCPConfigTransport leaked subprocess resources.

**Root cause:** MCPConfigTransport.connect_session() never cleaned up underlying StdioTransport objects. When the client context exited, only the main FastMCPTransport session was closed, but the underlying stdio connections to actual MCP server subprocesses were left open. With keep_alive=True (default), these transports don't auto-disconnect, so subprocess stdin was never closed and processes stayed alive indefinitely.

**The fix:** Added finally block to MCPConfigTransport.connect_session() that explicitly calls close() on all underlying transports. This ensures subprocess stdin is closed and processes terminate cleanly.

**Evidence:**
- Before: 100% timeout rate (processes status='sleeping' after context exit)
- After: 15/15 consecutive passes, processes terminate immediately
- Test time: 5.2s (timeout) → 3.2s (clean exit)

**Secondary fix:** Simplified test logic from infinite polling loop to direct psutil.Process(pid).status() check. The old while-True loop never raised NoSuchProcess because the constructor succeeds for existing processes.

All 3271 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Guidry 2025-11-21 10:05:28 -05:00
commit 5d84a3773e
2 changed files with 10 additions and 10 deletions

View file

@ -974,8 +974,13 @@ class MCPConfigTransport(ClientTransport):
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
async with self.transport.connect_session(**session_kwargs) as session:
yield session
try:
async with self.transport.connect_session(**session_kwargs) as session:
yield session
finally:
# Clean up underlying transports to ensure subprocesses terminate
for transport in self._underlying_transports:
await transport.close()
async def close(self):
for transport in self._underlying_transports:

View file

@ -340,17 +340,12 @@ async def test_multi_client_lifespan(tmp_path: Path):
gc_collect_harder()
# This test will fail while debugging because the debugger holds a reference to the underlying transport
# Verify processes have terminated
with pytest.raises(psutil.NoSuchProcess):
psutil.Process(pid_1).status()
with pytest.raises(psutil.NoSuchProcess):
while True:
psutil.Process(pid_1)
await asyncio.sleep(0.01)
with pytest.raises(psutil.NoSuchProcess):
while True:
psutil.Process(pid_2)
await asyncio.sleep(0.01)
psutil.Process(pid_2).status()
@pytest.mark.skipif(