Enforce single instance Local Agent supervisor worker and dashboard
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.22.2"
|
||||
__version__ = "1.22.3"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
|
||||
|
||||
def _mutex_name(scope: str, root: Path) -> str:
|
||||
normalized = str(root.resolve()).casefold().encode("utf-8", errors="ignore")
|
||||
suffix = hashlib.sha256(normalized).hexdigest()[:20]
|
||||
return f"Local\\ARRR_ERP_Local_Agent_{scope}_{suffix}"
|
||||
|
||||
|
||||
def acquire_named_mutex(scope: str, root: Path):
|
||||
"""Return an owned Windows mutex handle, or None when another instance owns it."""
|
||||
if os.name != "nt":
|
||||
return object()
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.CreateMutexW.argtypes = (ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR)
|
||||
kernel32.CreateMutexW.restype = wintypes.HANDLE
|
||||
ctypes.set_last_error(0)
|
||||
handle = kernel32.CreateMutexW(None, False, _mutex_name(scope, root))
|
||||
if not handle:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
|
||||
kernel32.CloseHandle(handle)
|
||||
return None
|
||||
return handle
|
||||
|
||||
|
||||
def close_named_mutex(handle) -> None:
|
||||
if os.name != "nt" or handle is None or not isinstance(handle, int):
|
||||
return
|
||||
try:
|
||||
ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(handle)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def sync_desktop_helpers(root: Path, logger=None) -> bool:
|
||||
"""Promote canonical desktop launchers shipped inside the worker package."""
|
||||
source_root = Path(__file__).resolve().parent / "_desktop_runtime"
|
||||
changed = False
|
||||
if not source_root.is_dir():
|
||||
return False
|
||||
for name in ("ERPAgentSupervisor.pyw", "ERPAgentDashboard.pyw"):
|
||||
source = source_root / name
|
||||
target = root / name
|
||||
if not source.is_file():
|
||||
continue
|
||||
source_bytes = source.read_bytes()
|
||||
try:
|
||||
if target.is_file() and target.read_bytes() == source_bytes:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
tmp = target.with_suffix(target.suffix + ".new")
|
||||
tmp.write_bytes(source_bytes)
|
||||
os.replace(tmp, target)
|
||||
changed = True
|
||||
if logger:
|
||||
logger.info("Refreshed Local Agent desktop helper: %s", name)
|
||||
return changed
|
||||
|
||||
|
||||
def _run_powershell(script: str) -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def cleanup_legacy_duplicates(root: Path, *, keep_supervisor_pid: int, close_dashboards: bool) -> None:
|
||||
"""Remove duplicates left behind by pre-1.22.3 launchers on this installation only."""
|
||||
if os.name != "nt":
|
||||
return
|
||||
root_text = str(root.resolve()).replace("'", "''")
|
||||
dashboard_clause = ""
|
||||
if close_dashboards:
|
||||
dashboard_clause = " -or ($cmd -match 'ERPAgentDashboard\\.pyw')"
|
||||
script = rf"""
|
||||
$root='{root_text}'
|
||||
$keepSupervisor={int(keep_supervisor_pid or 0)}
|
||||
$selfPid={os.getpid()}
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{
|
||||
$cmd = [string]$_.CommandLine
|
||||
if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }}
|
||||
$kill = $false
|
||||
if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $keepSupervisor)) {{ $kill = $true }}
|
||||
if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $selfPid)) {{ $kill = $true }}
|
||||
if (($cmd -match 'ERPAgentDashboard\.pyw') -and {str(bool(close_dashboards)).lower()}) {{ $kill = $true }}
|
||||
if ($kill) {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
|
||||
}}
|
||||
"""
|
||||
_run_powershell(script)
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import threading
|
||||
@@ -12,6 +13,7 @@ from .client import ERPClient
|
||||
from .config import load_config
|
||||
from .dashboard import AgentDashboard
|
||||
from .db import LocalDB
|
||||
from .desktop_runtime import acquire_named_mutex, cleanup_legacy_duplicates, close_named_mutex, sync_desktop_helpers
|
||||
from .logger import setup_logger
|
||||
from .sync import StorageAgent
|
||||
from .tunnel import StorageAgentTunnel
|
||||
@@ -29,9 +31,19 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
root = Path.cwd()
|
||||
worker_mutex = acquire_named_mutex("WORKER", root)
|
||||
if worker_mutex is None:
|
||||
# A supervisor already owns the one permitted worker for this install.
|
||||
return 0
|
||||
logger = setup_logger(root)
|
||||
db = None
|
||||
try:
|
||||
helpers_changed = sync_desktop_helpers(root, logger=logger)
|
||||
cleanup_legacy_duplicates(
|
||||
root,
|
||||
keep_supervisor_pid=os.getppid(),
|
||||
close_dashboards=helpers_changed,
|
||||
)
|
||||
config = load_config(args.env)
|
||||
db = LocalDB(root / "data" / "agent.db")
|
||||
db.prune_history()
|
||||
@@ -84,6 +96,8 @@ def main() -> int:
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
finally:
|
||||
close_named_mutex(worker_mutex)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user