-
Notifications
You must be signed in to change notification settings - Fork 498
Server: Robust shutdown on stdio detach (signals, stdin/parent monitor, forced exit) #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2b5f77e
e586145
dfd1238
32b35c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,9 @@ | |
| from logging.handlers import RotatingFileHandler | ||
| import os | ||
| from contextlib import asynccontextmanager | ||
| import sys | ||
| import signal | ||
| import threading | ||
| from typing import AsyncIterator, Dict, Any | ||
| from config import config | ||
| from tools import register_all_tools | ||
|
|
@@ -64,6 +67,10 @@ | |
| # Global connection state | ||
| _unity_connection: UnityConnection = None | ||
|
|
||
| # Global shutdown coordination | ||
| _shutdown_flag = threading.Event() | ||
| _exit_timer_scheduled = threading.Event() | ||
|
|
||
|
|
||
| @asynccontextmanager | ||
| async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: | ||
|
|
@@ -186,9 +193,98 @@ def _emit_startup(): | |
| register_all_resources(mcp) | ||
|
|
||
|
|
||
| def _force_exit(code: int = 0): | ||
| """Force process exit, bypassing any background threads that might linger.""" | ||
| os._exit(code) | ||
|
|
||
|
|
||
| def _signal_handler(signum, frame): | ||
| logger.info(f"Received signal {signum}, initiating shutdown...") | ||
| _shutdown_flag.set() | ||
| if not _exit_timer_scheduled.is_set(): | ||
| _exit_timer_scheduled.set() | ||
| threading.Timer(1.0, _force_exit, args=(0,)).start() | ||
|
|
||
|
|
||
| def _monitor_stdin(): | ||
| """Background thread to detect stdio detach (stdin EOF) or parent exit.""" | ||
| try: | ||
| parent_pid = os.getppid() if hasattr(os, "getppid") else None | ||
| while not _shutdown_flag.is_set(): | ||
| if _shutdown_flag.wait(0.5): | ||
| break | ||
|
|
||
| if parent_pid is not None: | ||
| try: | ||
| os.kill(parent_pid, 0) | ||
| except ValueError: | ||
| # Signal 0 unsupported on this platform (e.g., Windows); disable parent probing | ||
| parent_pid = None | ||
| except (ProcessLookupError, OSError): | ||
| logger.info(f"Parent process {parent_pid} no longer exists; shutting down") | ||
| break | ||
|
|
||
| try: | ||
| if sys.stdin.closed: | ||
| logger.info("stdin.closed is True; client disconnected") | ||
| break | ||
| fd = sys.stdin.fileno() | ||
| if fd < 0: | ||
| logger.info("stdin fd invalid; client disconnected") | ||
| break | ||
| except (ValueError, OSError, AttributeError): | ||
| # Closed pipe or unavailable stdin | ||
|
Comment on lines
+217
to
+236
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against unsupported signal 0 on Windows. Here too, @@
-import signal
-import sys
-import threading
+import errno
+import signal
+import sys
+import threading
@@
- except ValueError:
- # Signal 0 unsupported on this platform (e.g., Windows); disable parent probing
- parent_pid = None
- except (ProcessLookupError, OSError):
+ except ValueError:
+ parent_pid = None
+ except ProcessLookupError:
logger.info(f"Parent process {parent_pid} no longer exists; shutting down")
break
+ except OSError as exc:
+ if exc.errno in (errno.EPERM, errno.EACCES, errno.EINVAL, errno.ENOSYS):
+ logger.debug("Parent probe unsupported; disabling parent monitoring", exc_info=True)
+ parent_pid = None
+ continue
+ logger.info(f"Parent process {parent_pid} no longer exists; shutting down")
+ break
🤖 Prompt for AI Agents |
||
| break | ||
| except Exception: | ||
| # Ignore transient errors | ||
| logger.debug("Transient error checking stdin", exc_info=True) | ||
|
|
||
| if not _shutdown_flag.is_set(): | ||
| logger.info("Client disconnected (stdin or parent), initiating shutdown...") | ||
| _shutdown_flag.set() | ||
| if not _exit_timer_scheduled.is_set(): | ||
| _exit_timer_scheduled.set() | ||
| threading.Timer(0.5, _force_exit, args=(0,)).start() | ||
| except Exception: | ||
| # Never let monitor thread crash the process | ||
| logger.debug("Monitor thread error", exc_info=True) | ||
|
|
||
|
|
||
| def main(): | ||
| """Entry point for uvx and console scripts.""" | ||
| mcp.run(transport='stdio') | ||
| try: | ||
| signal.signal(signal.SIGTERM, _signal_handler) | ||
| signal.signal(signal.SIGINT, _signal_handler) | ||
| if hasattr(signal, "SIGPIPE"): | ||
| signal.signal(signal.SIGPIPE, signal.SIG_IGN) | ||
| if hasattr(signal, "SIGBREAK"): | ||
| signal.signal(signal.SIGBREAK, _signal_handler) | ||
| except Exception: | ||
| # Signals can fail in some environments | ||
| pass | ||
|
|
||
| t = threading.Thread(target=_monitor_stdin, daemon=True) | ||
| t.start() | ||
|
|
||
| try: | ||
| mcp.run(transport='stdio') | ||
| logger.info("FastMCP run() returned (stdin EOF or disconnect)") | ||
| except (KeyboardInterrupt, SystemExit): | ||
| logger.info("Server interrupted; shutting down") | ||
| _shutdown_flag.set() | ||
| except BrokenPipeError: | ||
| logger.info("Broken pipe; shutting down") | ||
| _shutdown_flag.set() | ||
| except Exception as e: | ||
| logger.error(f"Server error: {e}", exc_info=True) | ||
| _shutdown_flag.set() | ||
| _force_exit(1) | ||
| finally: | ||
| _shutdown_flag.set() | ||
| logger.info("Server main loop exited") | ||
| if not _exit_timer_scheduled.is_set(): | ||
| _exit_timer_scheduled.set() | ||
| threading.Timer(0.5, _force_exit, args=(0,)).start() | ||
|
|
||
|
|
||
| # Run the server | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent false parent-death detection on Windows.
Identical to the bridge variant:
os.kill(parent_pid, 0)throwsPermissionError/EINVALon Windows when signal 0 isn’t supported, yet this code treats everyOSErroras “parent vanished” and forces shutdown. Any Windows run will therefore exit immediately. Please gate on the errno, disable probing for the unsupported cases, and keep exiting only when we actually getProcessLookupError.📝 Committable suggestion
🤖 Prompt for AI Agents