From 0790b5dd9f83574224e718c7f0f87a40dc1045d1 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 19 Sep 2026 11:05:33 +0800 Subject: [PATCH] runtime/mcp: spawn servers in their own process group A terminal Ctrl+C is delivered to every process in the foreground process group, so a server spawned in our group died on any Ctrl+C during a turn - killing in-flight tool calls and leaving the connection dead until /mcp reconnect. start_new_session (POSIX) / CREATE_NEW_PROCESS_GROUP (Windows) puts each server in its own session/group instead. Tests assert the spawned server's process group and session differ from the CLI's (they used to be the same group). --- src/plyngent/runtime/mcp_client.py | 18 ++++++++++++++++++ tests/test_runtime/test_mcp_client.py | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/plyngent/runtime/mcp_client.py b/src/plyngent/runtime/mcp_client.py index 771370d..d0252bb 100644 --- a/src/plyngent/runtime/mcp_client.py +++ b/src/plyngent/runtime/mcp_client.py @@ -11,11 +11,15 @@ import asyncio import importlib.metadata import json import os +import subprocess +import sys from collections import deque from dataclasses import dataclass from typing import TYPE_CHECKING, cast if TYPE_CHECKING: + from typing import Any + from plyngent.config.models import McpConfig, McpServerConfig MCP_PROTOCOL_VERSION = "2025-06-18" @@ -45,6 +49,19 @@ def _client_version() -> str: return "0.0.0" +def _isolated_spawn_kwargs() -> dict[str, Any]: + """Spawn kwargs that keep a server out of our process/console group. + + Ctrl+C in the terminal is delivered to every process in the foreground + process group (POSIX) / console process group (Windows), so without this a + single Ctrl+C during a turn also killed every MCP server — killing in-flight + tool calls and leaving the connection dead until ``/mcp reconnect``. + """ + if sys.platform == "win32": + return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + return {"start_new_session": True} + + class McpConnectionError(RuntimeError): """Server process failed to spawn, handshake, or answer in time.""" @@ -143,6 +160,7 @@ class McpServerConnection: stderr=asyncio.subprocess.PIPE, env=os.environ | self._config.env, cwd=self._config.cwd or None, + **_isolated_spawn_kwargs(), ) except (OSError, ValueError) as exc: self.error = f"spawn {self._config.command!r}: {exc}" diff --git a/tests/test_runtime/test_mcp_client.py b/tests/test_runtime/test_mcp_client.py index aeb1525..10caa3e 100644 --- a/tests/test_runtime/test_mcp_client.py +++ b/tests/test_runtime/test_mcp_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import sys from pathlib import Path @@ -97,6 +98,26 @@ async def test_close_terminates_subprocess() -> None: assert proc.returncode is not None +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups only") +async def test_server_spawned_outside_our_process_group() -> None: + """Regression: a terminal Ctrl+C must not reach the MCP server. + + The tty delivers SIGINT to every process in the foreground process group, so + a server spawned in *our* group died on any Ctrl+C during a turn (killing + in-flight tool calls and leaving the connection dead). ``start_new_session`` + moves the server into its own session/group. + """ + connection = McpServerConnection("fake", _server_config()) + await connection.start() + try: + proc = connection._proc + assert proc is not None + assert os.getpgid(proc.pid) != os.getpgid(0) + assert os.getsid(proc.pid) != os.getsid(0) + finally: + await connection.aclose() + + async def test_manager_start_status_and_call() -> None: manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[])) await manager.ensure_started()