From d0dccd99009ea73c5466e08f9cce9f85d8fa44ba Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 11:07:11 +0200 Subject: [PATCH 1/6] fix: exit orphaned stdio MCP servers on parent death Clients can abandon sessions while a wrapper keeps stdin open, so EOF never arrives and leaked servers thrash swap. Add a parent-liveness watchdog (same approach as mcp-automem). --- parent_watchdog.py | 63 +++++++++++++++++++++++++++++++++++ profile_server.py | 2 ++ server.py | 2 ++ tests/test_parent_watchdog.py | 27 +++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 parent_watchdog.py create mode 100644 tests/test_parent_watchdog.py diff --git a/parent_watchdog.py b/parent_watchdog.py new file mode 100644 index 0000000..e232039 --- /dev/null +++ b/parent_watchdog.py @@ -0,0 +1,63 @@ +"""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). +""" + +from __future__ import annotations + +import os +import threading +import time +from typing 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) -> None: + parent_pid = os.getppid() + interval = parse_watchdog_interval_s(os.environ.get(env_name)) + + def _exit() -> None: + os._exit(0) + + start_parent_watchdog(parent_pid, interval, _exit) diff --git a/profile_server.py b/profile_server.py index b4841c8..889d952 100644 --- a/profile_server.py +++ b/profile_server.py @@ -16,6 +16,7 @@ from mcp.server import Server from mcp.server.stdio import stdio_server +from parent_watchdog import install_stdio_parent_watchdog from mcp.types import ( CallToolResult, GetPromptResult, @@ -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/server.py b/server.py index 8ddfe85..5969526 100644 --- a/server.py +++ b/server.py @@ -18,6 +18,7 @@ from mcp.server import Server from mcp.server.stdio import stdio_server +from parent_watchdog import install_stdio_parent_watchdog from mcp.types import TextContent, Tool # Configure logging @@ -1325,6 +1326,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: async def main() -> None: """Run the MCP server.""" logger.info("Starting Stream Deck 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/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] From f59cb909b5840166bf5a65569e6f62524200ee78 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 13:36:19 +0200 Subject: [PATCH 2/6] fix: pin parent pid early; document soft stdin EOF Capture os.getppid() before any await and pass it into the watchdog. Soft stdin EOF remains intentional (no hard-exit on EOF alone); hard exit stays on parent reparent / OS signals. --- parent_watchdog.py | 16 +++++++++++++--- server.py | 5 ++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/parent_watchdog.py b/parent_watchdog.py index e232039..b9553e6 100644 --- a/parent_watchdog.py +++ b/parent_watchdog.py @@ -4,6 +4,11 @@ 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 @@ -53,11 +58,16 @@ def _loop() -> None: return thread -def install_stdio_parent_watchdog(env_name: str) -> None: - parent_pid = os.getppid() +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(parent_pid, interval, _exit) + start_parent_watchdog(pinned, interval, _exit) diff --git a/server.py b/server.py index 5969526..7769044 100644 --- a/server.py +++ b/server.py @@ -7,6 +7,7 @@ """ import asyncio +import os import json import logging import re @@ -1325,8 +1326,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') + 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()) From c79c0a795a0c22405c8555e0b81baaccdeeb7d3c Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 13:38:11 +0200 Subject: [PATCH 3/6] fix: ship parent_watchdog in setuptools py-modules Installed wheels omitted the watchdog module, so entrypoints failed with ModuleNotFoundError. Add it to py-modules alongside the other top-level modules. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 16291a2..e7cee1b 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] From aba55b0eca4f8602a5778d3a821bd7a2212dab22 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 17:40:06 +0200 Subject: [PATCH 4/6] fix: satisfy ruff import sorting for parent watchdog CI Python lint failed on I001 for server/profile_server/parent_watchdog imports. --- parent_watchdog.py | 3 +-- profile_server.py | 2 +- server.py | 5 +++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/parent_watchdog.py b/parent_watchdog.py index b9553e6..870c65b 100644 --- a/parent_watchdog.py +++ b/parent_watchdog.py @@ -16,8 +16,7 @@ import os import threading import time -from typing import Callable - +from collections.abc import Callable DEFAULT_PARENT_WATCHDOG_S = 30.0 MIN_PARENT_WATCHDOG_S = 0.1 diff --git a/profile_server.py b/profile_server.py index 889d952..a39fc74 100644 --- a/profile_server.py +++ b/profile_server.py @@ -16,7 +16,6 @@ from mcp.server import Server from mcp.server.stdio import stdio_server -from parent_watchdog import install_stdio_parent_watchdog from mcp.types import ( CallToolResult, GetPromptResult, @@ -27,6 +26,7 @@ Tool, ) +from parent_watchdog import install_stdio_parent_watchdog from profile_manager import ( PageNotFoundError, ProfileManager, diff --git a/server.py b/server.py index 7769044..686c520 100644 --- a/server.py +++ b/server.py @@ -7,9 +7,9 @@ """ import asyncio -import os import json import logging +import os import re import shlex import subprocess @@ -19,9 +19,10 @@ from mcp.server import Server from mcp.server.stdio import stdio_server -from parent_watchdog import install_stdio_parent_watchdog from mcp.types import TextContent, Tool +from parent_watchdog import install_stdio_parent_watchdog + # Configure logging logging.basicConfig( level=logging.INFO, From 31be7d6a8c5a1431018555f684e5a3d1de329789 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 17:44:08 +0200 Subject: [PATCH 5/6] fix: ruff-format parent watchdog call sites CI runs ruff format --check; quote style must match project defaults. --- profile_server.py | 2 +- server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/profile_server.py b/profile_server.py index a39fc74..eaa41d9 100644 --- a/profile_server.py +++ b/profile_server.py @@ -954,7 +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') + 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/server.py b/server.py index 686c520..bb98056 100644 --- a/server.py +++ b/server.py @@ -1330,7 +1330,7 @@ async def main() -> None: # 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) + 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()) From 713085193d842d2a2944b424767ffed6b2bdbfa0 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 29 Jul 2026 17:44:58 +0200 Subject: [PATCH 6/6] fix: exclude streamdeck_assets from ruff format/lint ruff format rewrites fenced Python examples in skill markdown incorrectly (e.g. keyword args become tuples), and those docs were already failing format --check on main. Keep assets out of the CI formatter. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e7cee1b..4396698 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]