Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion PSCAD/pscad_mcp/core/connection_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import psutil
import logging
from typing import Optional, TYPE_CHECKING
Expand Down Expand Up @@ -54,6 +55,15 @@ def is_process_running(self) -> bool:

async def attach_local(self) -> str:
"""Robustly attach to any local PSCAD instance or launch a new one."""
# PSCAD's generated EMTDC run batch launches the compiled solver by a
# bare name from the build directory (``pushd <dir>`` then
# ``<project>.exe``). If ``NoDefaultCurrentDirectoryInExePath`` is set in
# our environment (some launchers set it), PSCAD inherits it and Windows
# refuses to find the exe in the current directory -- the run dies
# immediately with "'<project>.exe' is not recognized as an internal or
# external command". Scrub it so the PSCAD instance we launch, and the
# EMTDC processes it spawns, can run normally.
os.environ.pop("NoDefaultCurrentDirectoryInExePath", None)
try:
import mhi.pscad
except ImportError as e:
Expand All @@ -63,7 +73,17 @@ async def attach_local(self) -> str:
"Original import error: " + repr(e)
)
try:
self._pscad = await robust_executor.run_safe(mhi.pscad.application)
# Cold-launching PSCAD can take well over the default 30 s watchdog;
# give the attach/launch a generous timeout.
try:
self._pscad = await robust_executor.run_safe(mhi.pscad.application, _timeout=120)
except Exception as attach_err:
# application() only catches ConnectionRefusedError before
# launching; a stale PSCAD process with no automation listener
# raises ProcessLookupError instead, so it never falls back to
# launching. Start a fresh instance explicitly in that case.
logger.info("Could not attach to a running PSCAD (%s); launching a new instance.", attach_err)
self._pscad = await robust_executor.run_safe(mhi.pscad.launch, _timeout=180)
return f"Successfully attached to PSCAD {self._pscad.version} (Local)."
except Exception as e:
logger.error(f"Attach failed: {str(e)}")
Expand Down
63 changes: 40 additions & 23 deletions PSCAD/pscad_mcp/core/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@

logger = logging.getLogger("pscad-mcp.executor")


def _init_worker_com():
"""Initialize COM once for the single executor worker thread.

PSCAD's automation discovery uses WMI (SWbemServices), which caches a COM
object on first use. Initializing and uninitializing COM around every call
tears that cached object down, so the next WMI-using call fails with
"object invoked has disconnected from its clients". Because the executor
uses a single long-lived worker, COM is initialized exactly once here (when
the thread starts) and left initialized for the thread's lifetime.
"""
try:
import pythoncom
pythoncom.CoInitialize()
except ImportError:
pass


class RobustExecutor:
"""
Implements the Command/Proxy pattern to wrap PSCAD calls
Expand All @@ -14,39 +32,38 @@ class RobustExecutor:
def __init__(self, timeout: float = 30.0):
self.timeout = timeout
self.lock = threading.Lock()
# PSCAD is single-threaded via COM; use a single worker executor
self.executor = ThreadPoolExecutor(max_workers=1)
# PSCAD is single-threaded via COM; use a single worker whose COM
# apartment is initialized once for its lifetime (see _init_worker_com).
self.executor = ThreadPoolExecutor(max_workers=1, initializer=_init_worker_com)

async def run_safe(self, func: Callable, *args, _timeout: float | None = None, **kwargs) -> Any:
"""Execute a PSCAD call in a separate thread with a watchdog timeout.

async def run_safe(self, func: Callable, *args, **kwargs) -> Any:
"""Execute a PSCAD call in a separate thread with a watchdog timeout."""
The watchdog defaults to ``self.timeout`` (30 s) so a frozen PSCAD or a
modal dialog cannot hang the server. Long-running calls (loading a large
project, a clean build) can override it via the ``_timeout`` keyword:
a larger value extends the watchdog, while ``0``/``None`` disables it
entirely. The leading underscore keeps the name from colliding with any
keyword argument forwarded to ``func``.
"""
loop = asyncio.get_running_loop()
func_name = getattr(func, "__name__", str(func))
effective_timeout = self.timeout if _timeout is None else _timeout
wait_timeout = None if not effective_timeout or effective_timeout <= 0 else effective_timeout

def wrapped_call():
# Initialize COM for this thread (required for WMI/PSCAD on Windows)
try:
import pythoncom
pythoncom.CoInitialize()
except ImportError:
pass

try:
with self.lock:
return func(*args, **kwargs)
finally:
try:
import pythoncom
pythoncom.CoUninitialize()
except ImportError:
pass
# COM is initialized once for the worker thread (see _init_worker_com);
# do not re-init/uninit per call, or cached WMI/COM objects break.
with self.lock:
return func(*args, **kwargs)

try:
return await asyncio.wait_for(
loop.run_in_executor(self.executor, wrapped_call),
timeout=self.timeout
loop.run_in_executor(self.executor, wrapped_call),
timeout=wait_timeout
)
except asyncio.TimeoutError:
logger.error(f"PSCAD Command {func_name} timed out after {self.timeout}s.")
logger.error(f"PSCAD Command {func_name} timed out after {wait_timeout}s.")
raise RuntimeError(f"PSCAD timed out during {func_name}. It might be frozen or showing a dialog.")
except Exception as e:
logger.error(f"Error in {func_name}: {str(e)}")
Expand Down
Loading
Loading