diff --git a/parent_watchdog.py b/parent_watchdog.py new file mode 100644 index 0000000..870c65b --- /dev/null +++ b/parent_watchdog.py @@ -0,0 +1,72 @@ +"""Parent-liveness watchdog for stdio MCP servers. + +When an intermediate wrapper keeps stdin open after the client dies, the +Python MCP stdio loop never sees EOF and the process leaks forever (swap +thrash). This watchdog exits once os.getppid() changes from the original +parent (POSIX reparenting). + +Soft stdin EOF is intentional: do NOT hard-exit on EOF alone. One-shot +pipe clients must be able to finish in-flight handlers. Hard exit is +reserved for parent-watchdog reparent (and OS signals installed by the +host process). +""" + +from __future__ import annotations + +import os +import threading +import time +from collections.abc import Callable + +DEFAULT_PARENT_WATCHDOG_S = 30.0 +MIN_PARENT_WATCHDOG_S = 0.1 + + +def parse_watchdog_interval_s(raw: str | None) -> float: + if raw is None or raw.strip() == "": + return DEFAULT_PARENT_WATCHDOG_S + try: + value = float(raw) + except ValueError: + return DEFAULT_PARENT_WATCHDOG_S + if value <= 0: + return DEFAULT_PARENT_WATCHDOG_S + return max(value, MIN_PARENT_WATCHDOG_S) + + +def start_parent_watchdog( + parent_pid: int, + interval_s: float, + on_dead: Callable[[], None], + *, + is_parent_gone: Callable[[int], bool] | None = None, +) -> threading.Thread: + probe = is_parent_gone or (lambda pid: os.getppid() != pid) + fired = {"value": False} + + def _loop() -> None: + while not fired["value"]: + if probe(parent_pid): + fired["value"] = True + on_dead() + return + time.sleep(interval_s) + + thread = threading.Thread(target=_loop, name="mcp-parent-watchdog", daemon=True) + thread.start() + return thread + + +def install_stdio_parent_watchdog( + env_name: str, + *, + parent_pid: int | None = None, +) -> None: + # Prefer a pid captured before any await in main — getppid() is dynamic. + pinned = os.getppid() if parent_pid is None else parent_pid + interval = parse_watchdog_interval_s(os.environ.get(env_name)) + + def _exit() -> None: + os._exit(0) + + start_parent_watchdog(pinned, interval, _exit) diff --git a/profile_server.py b/profile_server.py index b4841c8..eaa41d9 100644 --- a/profile_server.py +++ b/profile_server.py @@ -26,6 +26,7 @@ Tool, ) +from parent_watchdog import install_stdio_parent_watchdog from profile_manager import ( PageNotFoundError, ProfileManager, @@ -953,6 +954,7 @@ async def main() -> None: """Run the profile writer MCP server.""" logger.info("Starting Stream Deck Profile Writer MCP server") + install_stdio_parent_watchdog("STREAMDECK_PARENT_WATCHDOG_S") async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, server.create_initialization_options()) diff --git a/pyproject.toml b/pyproject.toml index 16291a2..4396698 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ Issues = "https://github.com/verygoodplugins/streamdeck-mcp/issues" name = "io.github.verygoodplugins/streamdeck-mcp" [tool.setuptools] -py-modules = ["profile_manager", "profile_server", "server", "mdi_icons", "install_skill"] +py-modules = ["profile_manager", "profile_server", "server", "mdi_icons", "install_skill", "parent_watchdog"] packages = ["streamdeck_assets", "streamdeck_plugin"] [tool.setuptools.package-data] @@ -75,6 +75,9 @@ testpaths = ["tests"] [tool.ruff] line-length = 100 target-version = "py311" +# Skill docs contain intentional example formatting; ruff format rewrites fenced +# Python snippets incorrectly (e.g. kwargs → tuples). Keep assets out of format/lint. +extend-exclude = ["streamdeck_assets"] [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] diff --git a/server.py b/server.py index 8ddfe85..bb98056 100644 --- a/server.py +++ b/server.py @@ -9,6 +9,7 @@ import asyncio import json import logging +import os import re import shlex import subprocess @@ -20,6 +21,8 @@ from mcp.server.stdio import stdio_server from mcp.types import TextContent, Tool +from parent_watchdog import install_stdio_parent_watchdog + # Configure logging logging.basicConfig( level=logging.INFO, @@ -1324,7 +1327,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: async def main() -> None: """Run the MCP server.""" + # Capture before any await — os.getppid() is dynamic. + parent_pid = os.getppid() logger.info("Starting Stream Deck MCP server") + install_stdio_parent_watchdog("STREAMDECK_PARENT_WATCHDOG_S", parent_pid=parent_pid) async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, server.create_initialization_options()) diff --git a/tests/test_parent_watchdog.py b/tests/test_parent_watchdog.py new file mode 100644 index 0000000..5316c75 --- /dev/null +++ b/tests/test_parent_watchdog.py @@ -0,0 +1,27 @@ +import time + +from parent_watchdog import ( + DEFAULT_PARENT_WATCHDOG_S, + parse_watchdog_interval_s, + start_parent_watchdog, +) + + +def test_parse_defaults(): + assert parse_watchdog_interval_s(None) == DEFAULT_PARENT_WATCHDOG_S + assert parse_watchdog_interval_s("") == DEFAULT_PARENT_WATCHDOG_S + assert parse_watchdog_interval_s("nope") == DEFAULT_PARENT_WATCHDOG_S + assert parse_watchdog_interval_s("0") == DEFAULT_PARENT_WATCHDOG_S + assert parse_watchdog_interval_s("-1") == DEFAULT_PARENT_WATCHDOG_S + + +def test_parse_positive_floor(): + assert parse_watchdog_interval_s("2.5") == 2.5 + assert parse_watchdog_interval_s("0.01") == 0.1 + + +def test_watchdog_fires_once(): + hits = [] + start_parent_watchdog(1, 0.05, lambda: hits.append(1), is_parent_gone=lambda _pid: True) + time.sleep(0.2) + assert hits == [1]