Update backend manager and instance management logic
- Modified backend_manager.status to reflect updated timestamps and status. - Refactored backmanager.py for improved readability and consistency in logging. - Enhanced instance_manager.py to ensure robust backend instance checks and process management. - Updated main.py to streamline backend instance verification and improve error handling. - Adjusted main.spec to consolidate analysis for the main application and backend manager. - Added a new spec file for comprehensive build configuration. - Updated system_state.json with new timestamps and dataset order. - Improved rotating_logger.py to enhance log file cleanup messages.
This commit is contained in:
parent
082f8b1790
commit
6302acfc0f
File diff suppressed because it is too large
Load Diff
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"timestamp": "2025-08-22T14:55:09.660532",
|
||||
"status": "healthy",
|
||||
"timestamp": "2025-08-22T15:14:03.883875",
|
||||
"status": "stopped",
|
||||
"restart_count": 0,
|
||||
"last_restart": 0,
|
||||
"backend_pid": 33120,
|
||||
"manager_pid": 1488,
|
||||
"backend_pid": 33676,
|
||||
"manager_pid": 25004,
|
||||
"details": {}
|
||||
}
|
185
backmanager.py
185
backmanager.py
|
@ -27,16 +27,18 @@ from typing import Optional, Dict, Any
|
|||
|
||||
class BackendManager:
|
||||
"""Manages backend lifecycle and health monitoring"""
|
||||
|
||||
def __init__(self,
|
||||
check_interval: int = 30,
|
||||
health_timeout: float = 5.0,
|
||||
restart_delay: int = 10,
|
||||
max_restart_attempts: int = 3,
|
||||
restart_cooldown: int = 300):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
check_interval: int = 30,
|
||||
health_timeout: float = 5.0,
|
||||
restart_delay: int = 10,
|
||||
max_restart_attempts: int = 3,
|
||||
restart_cooldown: int = 300,
|
||||
):
|
||||
"""
|
||||
Initialize the backend manager
|
||||
|
||||
|
||||
Args:
|
||||
check_interval: Health check interval in seconds (default: 30)
|
||||
health_timeout: HTTP request timeout in seconds (default: 5.0)
|
||||
|
@ -49,50 +51,52 @@ class BackendManager:
|
|||
self.restart_delay = restart_delay
|
||||
self.max_restart_attempts = max_restart_attempts
|
||||
self.restart_cooldown = restart_cooldown
|
||||
|
||||
|
||||
# Configuration
|
||||
self.backend_port = 5050
|
||||
self.health_endpoint = "/api/health"
|
||||
self.base_url = f"http://localhost:{self.backend_port}"
|
||||
self.lock_file = "plc_streamer.lock"
|
||||
self.status_file = "backend_manager.status"
|
||||
|
||||
|
||||
# State tracking
|
||||
self.restart_count = 0
|
||||
self.last_restart_time = 0
|
||||
self.backend_process = None
|
||||
self.running = True
|
||||
|
||||
|
||||
# Setup logging
|
||||
self.setup_logging()
|
||||
|
||||
|
||||
# Detect environment
|
||||
self.is_packaged = getattr(sys, 'frozen', False)
|
||||
|
||||
self.is_packaged = getattr(sys, "frozen", False)
|
||||
|
||||
self.log(f"[MAIN] Backend Manager initialized")
|
||||
self.log(f"[CONFIG] Check interval: {check_interval}s")
|
||||
self.log(f"[CONFIG] Environment: {'Packaged' if self.is_packaged else 'Development'}")
|
||||
self.log(
|
||||
f"[CONFIG] Environment: {'Packaged' if self.is_packaged else 'Development'}"
|
||||
)
|
||||
self.log(f"[CONFIG] Process separation: Independent cmd windows")
|
||||
|
||||
|
||||
def setup_logging(self):
|
||||
"""Setup logging configuration"""
|
||||
log_format = '%(asctime)s [%(levelname)s] %(message)s'
|
||||
|
||||
log_format = "%(asctime)s [%(levelname)s] %(message)s"
|
||||
|
||||
# Configure file handler with UTF-8 encoding
|
||||
file_handler = logging.FileHandler('backend_manager.log', encoding='utf-8')
|
||||
file_handler = logging.FileHandler("backend_manager.log", encoding="utf-8")
|
||||
file_handler.setFormatter(logging.Formatter(log_format))
|
||||
|
||||
|
||||
# Configure console handler with UTF-8 encoding
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(logging.Formatter(log_format))
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format=log_format,
|
||||
handlers=[file_handler, console_handler]
|
||||
handlers=[file_handler, console_handler],
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log(self, message: str, level: str = "INFO"):
|
||||
"""Log message with appropriate level"""
|
||||
if level == "ERROR":
|
||||
|
@ -101,12 +105,14 @@ class BackendManager:
|
|||
self.logger.warning(message)
|
||||
else:
|
||||
self.logger.info(message)
|
||||
|
||||
|
||||
def get_backend_command(self) -> list:
|
||||
"""Get the appropriate backend command for current environment (legacy - kept for compatibility)"""
|
||||
if self.is_packaged:
|
||||
# In packaged environment, look for the exe
|
||||
exe_path = os.path.join(os.path.dirname(sys.executable), "S7_Streamer_Logger.exe")
|
||||
exe_path = os.path.join(
|
||||
os.path.dirname(sys.executable), "S7_Streamer_Logger.exe"
|
||||
)
|
||||
if os.path.exists(exe_path):
|
||||
return [exe_path]
|
||||
else:
|
||||
|
@ -125,56 +131,59 @@ class BackendManager:
|
|||
python_exe = sys.executable
|
||||
main_script = os.path.join(os.path.dirname(__file__), "main.py")
|
||||
return [python_exe, main_script]
|
||||
|
||||
|
||||
def is_backend_alive(self) -> bool:
|
||||
"""Check if backend is responding to health checks"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}{self.health_endpoint}",
|
||||
timeout=self.health_timeout
|
||||
f"{self.base_url}{self.health_endpoint}", timeout=self.health_timeout
|
||||
)
|
||||
return 200 <= response.status_code < 300
|
||||
except (requests.RequestException, requests.ConnectionError,
|
||||
requests.Timeout, requests.ConnectTimeout):
|
||||
except (
|
||||
requests.RequestException,
|
||||
requests.ConnectionError,
|
||||
requests.Timeout,
|
||||
requests.ConnectTimeout,
|
||||
):
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log(f"[ERROR] Unexpected error during health check: {e}", "ERROR")
|
||||
return False
|
||||
|
||||
|
||||
def get_backend_pid(self) -> Optional[int]:
|
||||
"""Get backend PID from lock file"""
|
||||
try:
|
||||
if os.path.exists(self.lock_file):
|
||||
with open(self.lock_file, 'r') as f:
|
||||
with open(self.lock_file, "r") as f:
|
||||
return int(f.read().strip())
|
||||
except (ValueError, FileNotFoundError, IOError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def is_backend_process_running(self, pid: int) -> bool:
|
||||
"""Check if backend process is actually running"""
|
||||
try:
|
||||
if not psutil.pid_exists(pid):
|
||||
return False
|
||||
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
cmdline = " ".join(proc.cmdline()).lower()
|
||||
|
||||
|
||||
# Check for backend signatures
|
||||
signatures = ["main.py", "s7_streamer_logger", "plc_streamer"]
|
||||
return any(sig in cmdline for sig in signatures)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_zombie_process(self, pid: int) -> bool:
|
||||
"""Terminate zombie backend process"""
|
||||
try:
|
||||
if not psutil.pid_exists(pid):
|
||||
return True
|
||||
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
self.log(f"[STOP] Terminating zombie process {pid} ({proc.name()})")
|
||||
|
||||
|
||||
# Try graceful termination
|
||||
proc.terminate()
|
||||
try:
|
||||
|
@ -188,13 +197,13 @@ class BackendManager:
|
|||
proc.wait(timeout=5)
|
||||
self.log(f"[KILL] Process {pid} force killed")
|
||||
return True
|
||||
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log(f"[ERROR] Error terminating process {pid}: {e}", "ERROR")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_lock_file(self):
|
||||
"""Remove stale lock file"""
|
||||
try:
|
||||
|
@ -203,12 +212,14 @@ class BackendManager:
|
|||
self.log(f"[OK] Removed lock file: {self.lock_file}")
|
||||
except Exception as e:
|
||||
self.log(f"[ERROR] Error removing lock file: {e}", "ERROR")
|
||||
|
||||
|
||||
def get_cmd_command(self) -> str:
|
||||
"""Get Windows cmd command to launch backend in separate console window"""
|
||||
if self.is_packaged:
|
||||
# In packaged environment, launch exe in new cmd window
|
||||
exe_path = os.path.join(os.path.dirname(sys.executable), "S7_Streamer_Logger.exe")
|
||||
exe_path = os.path.join(
|
||||
os.path.dirname(sys.executable), "S7_Streamer_Logger.exe"
|
||||
)
|
||||
if os.path.exists(exe_path):
|
||||
return f'start "S7_Streamer_Logger" "{exe_path}"'
|
||||
else:
|
||||
|
@ -225,74 +236,86 @@ class BackendManager:
|
|||
python_exe = sys.executable
|
||||
main_script = os.path.join(os.path.dirname(__file__), "main.py")
|
||||
return f'start "PLC_Backend" "{python_exe}" "{main_script}"'
|
||||
|
||||
|
||||
def start_backend(self) -> bool:
|
||||
"""Start the backend process in a separate Windows cmd console"""
|
||||
try:
|
||||
cmd_command = self.get_cmd_command()
|
||||
self.log(f"[START] Starting backend in separate cmd window: {cmd_command}")
|
||||
|
||||
|
||||
# Launch backend in completely separate cmd window using shell command
|
||||
self.backend_process = subprocess.Popen(
|
||||
cmd_command,
|
||||
cwd=os.path.dirname(__file__) if not self.is_packaged else None,
|
||||
shell=True # Use shell to properly handle the start command
|
||||
shell=True, # Use shell to properly handle the start command
|
||||
)
|
||||
|
||||
self.log(f"[START] Backend launch command executed with PID: {self.backend_process.pid}")
|
||||
|
||||
|
||||
self.log(
|
||||
f"[START] Backend launch command executed with PID: {self.backend_process.pid}"
|
||||
)
|
||||
|
||||
# Wait a moment for the actual backend to start in its new window
|
||||
self.log(f"[WAIT] Waiting 10 seconds for backend to initialize in separate window...")
|
||||
self.log(
|
||||
f"[WAIT] Waiting 10 seconds for backend to initialize in separate window..."
|
||||
)
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
# The subprocess.Popen PID is just the cmd launcher, not the actual backend
|
||||
# We'll verify health via HTTP instead of process tracking
|
||||
self.log(f"[OK] Backend launch completed, will verify via health check")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"[ERROR] Error starting backend: {e}", "ERROR")
|
||||
return False
|
||||
|
||||
|
||||
def handle_backend_failure(self) -> bool:
|
||||
"""Handle backend failure and attempt restart"""
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
# Check if we're in cooldown period
|
||||
if (current_time - self.last_restart_time) < self.restart_cooldown:
|
||||
time_left = self.restart_cooldown - (current_time - self.last_restart_time)
|
||||
self.log(f"[WAIT] In cooldown period, {int(time_left)}s remaining")
|
||||
return False
|
||||
|
||||
|
||||
# Check restart attempt limit
|
||||
if self.restart_count >= self.max_restart_attempts:
|
||||
self.log(f"[FAIL] Maximum restart attempts ({self.max_restart_attempts}) reached")
|
||||
self.log(
|
||||
f"[FAIL] Maximum restart attempts ({self.max_restart_attempts}) reached"
|
||||
)
|
||||
self.restart_count = 0
|
||||
self.last_restart_time = current_time
|
||||
return False
|
||||
|
||||
|
||||
# Cleanup existing processes
|
||||
backend_pid = self.get_backend_pid()
|
||||
if backend_pid and self.is_backend_process_running(backend_pid):
|
||||
self.log(f"[STOP] Cleaning up zombie backend process: {backend_pid}")
|
||||
self.cleanup_zombie_process(backend_pid)
|
||||
|
||||
|
||||
self.cleanup_lock_file()
|
||||
|
||||
|
||||
# Wait before restart
|
||||
self.log(f"[WAIT] Waiting {self.restart_delay}s before restart attempt {self.restart_count + 1}")
|
||||
self.log(
|
||||
f"[WAIT] Waiting {self.restart_delay}s before restart attempt {self.restart_count + 1}"
|
||||
)
|
||||
time.sleep(self.restart_delay)
|
||||
|
||||
|
||||
# Attempt restart
|
||||
self.restart_count += 1
|
||||
if self.start_backend():
|
||||
self.log(f"[OK] Backend restarted successfully (attempt {self.restart_count})")
|
||||
self.log(
|
||||
f"[OK] Backend restarted successfully (attempt {self.restart_count})"
|
||||
)
|
||||
self.restart_count = 0 # Reset counter on success
|
||||
return True
|
||||
else:
|
||||
self.log(f"[FAIL] Backend restart failed (attempt {self.restart_count})", "ERROR")
|
||||
self.log(
|
||||
f"[FAIL] Backend restart failed (attempt {self.restart_count})", "ERROR"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def update_status(self, status: str, details: Dict[str, Any] = None):
|
||||
"""Update status file with current state"""
|
||||
try:
|
||||
|
@ -303,40 +326,42 @@ class BackendManager:
|
|||
"last_restart": self.last_restart_time,
|
||||
"backend_pid": self.get_backend_pid(),
|
||||
"manager_pid": os.getpid(),
|
||||
"details": details or {}
|
||||
"details": details or {},
|
||||
}
|
||||
|
||||
with open(self.status_file, 'w') as f:
|
||||
|
||||
with open(self.status_file, "w") as f:
|
||||
json.dump(status_data, f, indent=2)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"[ERROR] Error updating status file: {e}", "ERROR")
|
||||
|
||||
|
||||
def run(self):
|
||||
"""Main monitoring loop"""
|
||||
self.log(f"[START] Backend Manager started (PID: {os.getpid()})")
|
||||
self.update_status("starting")
|
||||
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Check backend health
|
||||
if self.is_backend_alive():
|
||||
self.log(f"[OK] Backend is healthy")
|
||||
self.update_status("healthy")
|
||||
self.restart_count = 0 # Reset restart counter on successful health check
|
||||
self.restart_count = (
|
||||
0 # Reset restart counter on successful health check
|
||||
)
|
||||
else:
|
||||
self.log(f"[WARN] Backend health check failed", "WARN")
|
||||
self.update_status("unhealthy")
|
||||
|
||||
|
||||
# Attempt to handle the failure
|
||||
if self.handle_backend_failure():
|
||||
self.update_status("restarted")
|
||||
else:
|
||||
self.update_status("failed")
|
||||
|
||||
|
||||
# Wait for next check
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.log(f"[SHUTDOWN] Received interrupt signal")
|
||||
self.running = False
|
||||
|
@ -345,17 +370,19 @@ class BackendManager:
|
|||
self.log(f"[ERROR] Unexpected error in main loop: {e}", "ERROR")
|
||||
self.update_status("error", {"error": str(e)})
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
|
||||
self.shutdown()
|
||||
|
||||
|
||||
def shutdown(self):
|
||||
"""Cleanup and shutdown"""
|
||||
self.log(f"[SHUTDOWN] Backend Manager shutting down")
|
||||
self.update_status("shutting_down")
|
||||
|
||||
|
||||
# Don't terminate any backend processes - they run independently in their own cmd windows
|
||||
# The manager only monitors health, doesn't control the backend lifecycle directly
|
||||
self.log(f"[OK] Backend Manager stopped - backend continues running independently")
|
||||
self.log(
|
||||
f"[OK] Backend Manager stopped - backend continues running independently"
|
||||
)
|
||||
self.update_status("stopped")
|
||||
|
||||
|
||||
|
@ -363,7 +390,7 @@ def main():
|
|||
"""Main entry point"""
|
||||
print("Backend Manager - PLC S7-315 Streamer Watchdog")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
try:
|
||||
manager = BackendManager()
|
||||
manager.run()
|
||||
|
@ -372,7 +399,7 @@ def main():
|
|||
except Exception as e:
|
||||
print(f"[ERROR] Critical error: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
@ -76,7 +76,9 @@ class RotatingFileHandler(logging.Handler):
|
|||
oldest_file = log_files.pop(0)
|
||||
try:
|
||||
os.remove(oldest_file)
|
||||
print(f"[CLEANUP] Removed old log file: {os.path.basename(oldest_file)}")
|
||||
print(
|
||||
f"[CLEANUP] Removed old log file: {os.path.basename(oldest_file)}"
|
||||
)
|
||||
except OSError as e:
|
||||
print(f"[WARNING] Could not remove {oldest_file}: {e}")
|
||||
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Analysis for main application (backend)
|
||||
a_main = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[
|
||||
# Include snap7.dll - now confirmed to be in project root
|
||||
('snap7.dll', '.'),
|
||||
],
|
||||
datas=[
|
||||
# Include the entire frontend build
|
||||
('frontend/dist', 'frontend/dist'),
|
||||
|
||||
# Include configuration directories and schemas
|
||||
('config', 'config'),
|
||||
|
||||
# Include core modules
|
||||
('core', 'core'),
|
||||
|
||||
# Include utils
|
||||
('utils', 'utils'),
|
||||
|
||||
# Include translation files
|
||||
('translation.json', '.'),
|
||||
('i18n.js', '.'),
|
||||
|
||||
],
|
||||
hiddenimports=[
|
||||
# Flask and web dependencies
|
||||
'jinja2.ext',
|
||||
'flask',
|
||||
'flask_cors',
|
||||
'flask_socketio',
|
||||
'socketio',
|
||||
'werkzeug',
|
||||
|
||||
# JSON Schema validation
|
||||
'jsonschema',
|
||||
'jsonschema.validators',
|
||||
'jsonschema._format',
|
||||
'jsonschema._types',
|
||||
|
||||
# PLC and system dependencies
|
||||
'snap7',
|
||||
'psutil._pswindows',
|
||||
'psutil._psutil_windows',
|
||||
|
||||
# Data processing
|
||||
'pandas',
|
||||
'numpy',
|
||||
|
||||
# Threading and networking
|
||||
'threading',
|
||||
'socket',
|
||||
'json',
|
||||
'csv',
|
||||
'datetime',
|
||||
'pathlib',
|
||||
|
||||
# Core modules (explicit imports)
|
||||
'core.config_manager',
|
||||
'core.plc_client',
|
||||
'core.plc_data_streamer',
|
||||
'core.event_logger',
|
||||
'core.instance_manager',
|
||||
'core.schema_manager',
|
||||
'core.streamer',
|
||||
'core.plot_manager',
|
||||
'core.historical_cache',
|
||||
'core.performance_monitor',
|
||||
'core.priority_manager',
|
||||
'core.rotating_logger',
|
||||
|
||||
# Utils modules
|
||||
'utils.csv_validator',
|
||||
'utils.json_manager',
|
||||
'utils.symbol_loader',
|
||||
'utils.symbol_processor',
|
||||
'utils.instance_manager',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Exclude unnecessary packages to reduce size
|
||||
'matplotlib',
|
||||
'scipy',
|
||||
'IPython',
|
||||
'notebook',
|
||||
'jupyter',
|
||||
'tests',
|
||||
'unittest',
|
||||
'pydoc',
|
||||
'doctest',
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
# Analysis for backend manager (watchdog)
|
||||
a_manager = Analysis(
|
||||
['backmanager.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[
|
||||
# Include utils for instance management
|
||||
('utils', 'utils'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# System and monitoring dependencies
|
||||
'psutil',
|
||||
'psutil._pswindows',
|
||||
'psutil._psutil_windows',
|
||||
'requests',
|
||||
'subprocess',
|
||||
'logging',
|
||||
'json',
|
||||
|
||||
# Utils modules needed by manager
|
||||
'utils.instance_manager',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Exclude heavy packages not needed by manager
|
||||
'matplotlib',
|
||||
'scipy',
|
||||
'IPython',
|
||||
'notebook',
|
||||
'jupyter',
|
||||
'flask',
|
||||
'snap7',
|
||||
'pandas',
|
||||
'numpy',
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
# Build PYZ files
|
||||
pyz_main = PYZ(a_main.pure, a_main.zipped_data, cipher=block_cipher)
|
||||
pyz_manager = PYZ(a_manager.pure, a_manager.zipped_data, cipher=block_cipher)
|
||||
|
||||
# Build main backend executable
|
||||
exe_main = EXE(
|
||||
pyz_main,
|
||||
a_main.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='S7_Streamer_Logger',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True, # True para ver los logs del servidor en una consola.
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# Build backend manager executable
|
||||
exe_manager = EXE(
|
||||
pyz_manager,
|
||||
a_manager.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='Backend_Manager',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# Collect all files together - Only include executables and shared dependencies
|
||||
coll = COLLECT(
|
||||
exe_main,
|
||||
exe_manager,
|
||||
a_main.binaries,
|
||||
a_main.zipfiles,
|
||||
a_main.datas,
|
||||
# Don't duplicate manager dependencies since they're minimal
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='main'
|
||||
)
|
||||
|
||||
# Post-build: Copy config directory to the same level as the executable
|
||||
import shutil
|
||||
import os
|
||||
|
||||
def copy_config_external():
|
||||
"""Copy config directory to external location for runtime access"""
|
||||
try:
|
||||
# Get absolute paths
|
||||
current_dir = os.path.abspath('.')
|
||||
source_config = os.path.join(current_dir, 'config')
|
||||
dist_main_dir = os.path.join(current_dir, 'dist', 'main')
|
||||
dest_config = os.path.join(dist_main_dir, 'config')
|
||||
|
||||
print(f"Current directory: {current_dir}")
|
||||
print(f"Source config: {source_config}")
|
||||
print(f"Destination config: {dest_config}")
|
||||
|
||||
# Ensure dist/main directory exists
|
||||
os.makedirs(dist_main_dir, exist_ok=True)
|
||||
|
||||
# Remove existing config if present
|
||||
if os.path.exists(dest_config):
|
||||
shutil.rmtree(dest_config)
|
||||
print(f"Removed existing config at: {dest_config}")
|
||||
|
||||
# Copy config directory to dist/main/config
|
||||
if os.path.exists(source_config):
|
||||
shutil.copytree(source_config, dest_config)
|
||||
print(f"✓ Config directory copied to: {dest_config}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Source config directory not found: {source_config}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error copying config directory: {e}")
|
||||
return False
|
||||
|
||||
# Execute the copy operation
|
||||
copy_config_external()
|
||||
|
||||
def config_path(relative_path):
|
||||
"""Get path to config file, checking external location first when running as executable"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# Running as executable - config should be at same level as executable
|
||||
executable_dir = os.path.dirname(sys.executable)
|
||||
external_config = os.path.join(executable_dir, 'config', relative_path)
|
||||
|
||||
if os.path.exists(external_config):
|
||||
return external_config
|
||||
|
||||
# Fallback to internal config within _internal
|
||||
internal_config = os.path.join(executable_dir, '_internal', 'config', relative_path)
|
||||
if os.path.exists(internal_config):
|
||||
return internal_config
|
||||
|
||||
raise FileNotFoundError(f"Configuration file not found: {relative_path}")
|
||||
else:
|
||||
# Running as script - use standard path
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(base_dir)
|
||||
return os.path.join(project_root, 'config', relative_path)
|
29
main.py
29
main.py
|
@ -54,46 +54,48 @@ from utils.symbol_processor import SymbolProcessor
|
|||
from utils.instance_manager import InstanceManager
|
||||
|
||||
|
||||
def check_backend_instance_robust(port: int = 5050, lock_file: str = "plc_streamer.lock"):
|
||||
def check_backend_instance_robust(
|
||||
port: int = 5050, lock_file: str = "plc_streamer.lock"
|
||||
):
|
||||
"""
|
||||
🔒 ROBUST INSTANCE CHECK - HTTP + PID based verification
|
||||
|
||||
|
||||
This function provides a more reliable way to detect existing backend instances:
|
||||
1. Double HTTP health check with 5-second interval
|
||||
2. PID verification and zombie process cleanup
|
||||
3. Automatic lock file management
|
||||
|
||||
|
||||
Args:
|
||||
port: Backend server port (default: 5050)
|
||||
lock_file: Lock file path (default: "plc_streamer.lock")
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (can_proceed, message)
|
||||
- can_proceed: True if this instance can start safely
|
||||
- message: Detailed status message
|
||||
"""
|
||||
print("🔍 Starting robust backend instance verification...")
|
||||
|
||||
|
||||
try:
|
||||
# Initialize instance manager
|
||||
instance_manager = InstanceManager(port=port, lock_file=lock_file)
|
||||
|
||||
|
||||
# Perform comprehensive instance check
|
||||
can_proceed, message = instance_manager.check_and_handle_existing_instance()
|
||||
|
||||
|
||||
if can_proceed:
|
||||
print(f"✅ {message}")
|
||||
print("🔒 Initializing new backend instance...")
|
||||
|
||||
|
||||
# Create lock file for this instance
|
||||
if not instance_manager.initialize_instance():
|
||||
return False, "❌ Failed to create instance lock file"
|
||||
|
||||
|
||||
return True, "✅ Backend instance ready to start"
|
||||
else:
|
||||
print(f"🚫 {message}")
|
||||
return False, message
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Error during instance verification: {e}"
|
||||
print(error_msg)
|
||||
|
@ -3234,6 +3236,7 @@ def graceful_shutdown():
|
|||
# Fallback to direct file removal
|
||||
try:
|
||||
import os
|
||||
|
||||
lock_file = "plc_streamer.lock"
|
||||
if os.path.exists(lock_file):
|
||||
os.remove(lock_file)
|
||||
|
@ -3950,10 +3953,12 @@ if __name__ == "__main__":
|
|||
print("=" * 60)
|
||||
can_proceed, check_message = check_backend_instance_robust(port=5050)
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if not can_proceed:
|
||||
print(f"❌ Startup aborted: {check_message}")
|
||||
print("💡 Tip: If you believe this is an error, check Task Manager for python.exe processes")
|
||||
print(
|
||||
"💡 Tip: If you believe this is an error, check Task Manager for python.exe processes"
|
||||
)
|
||||
# input("\nPress Enter to exit...")
|
||||
sys.exit(1)
|
||||
|
||||
|
|
56
main.spec
56
main.spec
|
@ -4,8 +4,8 @@ import sys
|
|||
|
||||
block_cipher = None
|
||||
|
||||
# Analysis for main application (backend)
|
||||
a_main = Analysis(
|
||||
# Analysis for main application
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[
|
||||
|
@ -81,7 +81,6 @@ a_main = Analysis(
|
|||
'utils.json_manager',
|
||||
'utils.symbol_loader',
|
||||
'utils.symbol_processor',
|
||||
'utils.instance_manager',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
@ -104,42 +103,38 @@ a_main = Analysis(
|
|||
noarchive=False,
|
||||
)
|
||||
|
||||
# Analysis for backend manager (watchdog)
|
||||
# Analysis for backend manager
|
||||
a_manager = Analysis(
|
||||
['backmanager.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[
|
||||
# Include utils for instance management
|
||||
('utils', 'utils'),
|
||||
],
|
||||
datas=[],
|
||||
hiddenimports=[
|
||||
# System and monitoring dependencies
|
||||
# Backend manager dependencies
|
||||
'psutil',
|
||||
'psutil._pswindows',
|
||||
'psutil._psutil_windows',
|
||||
'requests',
|
||||
'json',
|
||||
'datetime',
|
||||
'threading',
|
||||
'subprocess',
|
||||
'logging',
|
||||
'json',
|
||||
|
||||
# Utils modules needed by manager
|
||||
'utils.instance_manager',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Exclude heavy packages not needed by manager
|
||||
# Exclude unnecessary packages to reduce size
|
||||
'matplotlib',
|
||||
'scipy',
|
||||
'IPython',
|
||||
'notebook',
|
||||
'jupyter',
|
||||
'flask',
|
||||
'snap7',
|
||||
'pandas',
|
||||
'numpy',
|
||||
'tests',
|
||||
'unittest',
|
||||
'pydoc',
|
||||
'doctest',
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
|
@ -147,14 +142,12 @@ a_manager = Analysis(
|
|||
noarchive=False,
|
||||
)
|
||||
|
||||
# Build PYZ files
|
||||
pyz_main = PYZ(a_main.pure, a_main.zipped_data, cipher=block_cipher)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
pyz_manager = PYZ(a_manager.pure, a_manager.zipped_data, cipher=block_cipher)
|
||||
|
||||
# Build main backend executable
|
||||
exe_main = EXE(
|
||||
pyz_main,
|
||||
a_main.scripts,
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='S7_Streamer_Logger',
|
||||
|
@ -170,7 +163,7 @@ exe_main = EXE(
|
|||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# Build backend manager executable
|
||||
# Executable for backend manager
|
||||
exe_manager = EXE(
|
||||
pyz_manager,
|
||||
a_manager.scripts,
|
||||
|
@ -181,7 +174,7 @@ exe_manager = EXE(
|
|||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
console=True, # True para ver los logs del manager en una consola.
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
|
@ -189,10 +182,15 @@ exe_manager = EXE(
|
|||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# Collect all files together
|
||||
coll = COLLECT(
|
||||
exe_main, a_main.binaries, a_main.zipfiles, a_main.datas,
|
||||
exe_manager, a_manager.binaries, a_manager.zipfiles, a_manager.datas,
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
exe_manager,
|
||||
a_manager.binaries,
|
||||
a_manager.zipfiles,
|
||||
a_manager.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
|
|
|
@ -3,11 +3,11 @@
|
|||
"should_connect": true,
|
||||
"should_stream": false,
|
||||
"active_datasets": [
|
||||
"DAR",
|
||||
"Fast",
|
||||
"Test"
|
||||
"Test",
|
||||
"DAR"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-22T14:54:15.476402"
|
||||
"last_update": "2025-08-22T15:01:09.609437"
|
||||
}
|
|
@ -24,16 +24,18 @@ from typing import Optional, Tuple
|
|||
|
||||
class InstanceManager:
|
||||
"""Manages backend instance lifecycle and prevents duplicate executions"""
|
||||
|
||||
def __init__(self,
|
||||
port: int = 5050,
|
||||
lock_file: str = "plc_streamer.lock",
|
||||
health_endpoint: str = "/api/health",
|
||||
check_timeout: float = 3.0,
|
||||
check_interval: float = 5.0):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: int = 5050,
|
||||
lock_file: str = "plc_streamer.lock",
|
||||
health_endpoint: str = "/api/health",
|
||||
check_timeout: float = 3.0,
|
||||
check_interval: float = 5.0,
|
||||
):
|
||||
"""
|
||||
Initialize the instance manager
|
||||
|
||||
|
||||
Args:
|
||||
port: Backend server port to check
|
||||
lock_file: Path to the PID lock file
|
||||
|
@ -47,99 +49,102 @@ class InstanceManager:
|
|||
self.check_timeout = check_timeout
|
||||
self.check_interval = check_interval
|
||||
self.base_url = f"http://localhost:{port}"
|
||||
|
||||
|
||||
def is_backend_alive_http(self) -> bool:
|
||||
"""
|
||||
Check if backend is alive via HTTP health check
|
||||
|
||||
|
||||
Returns:
|
||||
True if backend responds to health check, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}{self.health_endpoint}",
|
||||
timeout=self.check_timeout
|
||||
f"{self.base_url}{self.health_endpoint}", timeout=self.check_timeout
|
||||
)
|
||||
# Accept any successful HTTP response (200-299)
|
||||
return 200 <= response.status_code < 300
|
||||
|
||||
except (requests.RequestException, requests.ConnectionError,
|
||||
requests.Timeout, requests.ConnectTimeout):
|
||||
|
||||
except (
|
||||
requests.RequestException,
|
||||
requests.ConnectionError,
|
||||
requests.Timeout,
|
||||
requests.ConnectTimeout,
|
||||
):
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ Unexpected error during health check: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_lock_file_pid(self) -> Optional[int]:
|
||||
"""
|
||||
Read PID from lock file
|
||||
|
||||
|
||||
Returns:
|
||||
PID if lock file exists and is valid, None otherwise
|
||||
"""
|
||||
if not os.path.exists(self.lock_file):
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
with open(self.lock_file, "r") as f:
|
||||
content = f.read().strip()
|
||||
return int(content) if content else None
|
||||
except (ValueError, FileNotFoundError, IOError):
|
||||
return None
|
||||
|
||||
|
||||
def is_process_our_backend(self, pid: int) -> bool:
|
||||
"""
|
||||
Verify if the process with given PID is our backend application
|
||||
|
||||
|
||||
Args:
|
||||
pid: Process ID to check
|
||||
|
||||
|
||||
Returns:
|
||||
True if it's our backend process, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not psutil.pid_exists(pid):
|
||||
return False
|
||||
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
cmdline = " ".join(proc.cmdline()).lower()
|
||||
|
||||
|
||||
# Check for our application signatures
|
||||
backend_signatures = [
|
||||
"main.py",
|
||||
"s7_snap7_streamer_n_log",
|
||||
"plc_streamer",
|
||||
"plcdatastreamer"
|
||||
"plcdatastreamer",
|
||||
]
|
||||
|
||||
|
||||
return any(sig in cmdline for sig in backend_signatures)
|
||||
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error checking process {pid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def terminate_process_safely(self, pid: int) -> bool:
|
||||
"""
|
||||
Safely terminate a process
|
||||
|
||||
|
||||
Args:
|
||||
pid: Process ID to terminate
|
||||
|
||||
|
||||
Returns:
|
||||
True if process was terminated successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not psutil.pid_exists(pid):
|
||||
return True # Already gone
|
||||
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
print(f"🛑 Attempting to terminate process {pid} ({proc.name()})...")
|
||||
|
||||
|
||||
# Try graceful termination first
|
||||
proc.terminate()
|
||||
|
||||
|
||||
# Wait up to 10 seconds for graceful shutdown
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
|
@ -152,17 +157,17 @@ class InstanceManager:
|
|||
proc.wait(timeout=5)
|
||||
print(f"💥 Process {pid} force killed")
|
||||
return True
|
||||
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return True # Process already gone or no permission
|
||||
except Exception as e:
|
||||
print(f"❌ Error terminating process {pid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_lock_file(self) -> bool:
|
||||
"""
|
||||
Remove the lock file
|
||||
|
||||
|
||||
Returns:
|
||||
True if lock file was removed or didn't exist, False on error
|
||||
"""
|
||||
|
@ -174,11 +179,11 @@ class InstanceManager:
|
|||
except Exception as e:
|
||||
print(f"❌ Error removing lock file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_lock_file(self) -> bool:
|
||||
"""
|
||||
Create lock file with current process PID
|
||||
|
||||
|
||||
Returns:
|
||||
True if lock file was created successfully, False otherwise
|
||||
"""
|
||||
|
@ -190,47 +195,47 @@ class InstanceManager:
|
|||
except Exception as e:
|
||||
print(f"❌ Error creating lock file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_and_handle_existing_instance(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Main method: Check for existing instances and handle them
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (can_proceed, message)
|
||||
- can_proceed: True if this instance can start, False if should exit
|
||||
- message: Description of what happened
|
||||
"""
|
||||
print("🔍 Checking for existing backend instances...")
|
||||
|
||||
|
||||
# Step 1: First HTTP health check
|
||||
print("📡 Performing first health check...")
|
||||
if self.is_backend_alive_http():
|
||||
return False, f"❌ Another backend is already running on port {self.port}"
|
||||
|
||||
|
||||
print(f"⏳ Waiting {self.check_interval} seconds for double-check...")
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
|
||||
# Step 2: Second HTTP health check (double verification)
|
||||
print("📡 Performing second health check...")
|
||||
if self.is_backend_alive_http():
|
||||
return False, f"❌ Another backend is confirmed running on port {self.port}"
|
||||
|
||||
|
||||
print("✅ No active backend detected via HTTP")
|
||||
|
||||
|
||||
# Step 3: Check lock file and handle zombie processes
|
||||
lock_pid = self.get_lock_file_pid()
|
||||
if lock_pid is None:
|
||||
print("📝 No lock file found")
|
||||
return True, "✅ No existing instances detected"
|
||||
|
||||
|
||||
print(f"📋 Found lock file with PID: {lock_pid}")
|
||||
|
||||
|
||||
# Step 4: Verify if the process is actually our backend
|
||||
if not self.is_process_our_backend(lock_pid):
|
||||
print(f"🧹 PID {lock_pid} is not our backend process")
|
||||
self.cleanup_lock_file()
|
||||
return True, "✅ Cleaned up stale lock file"
|
||||
|
||||
|
||||
# Step 5: We have a zombie backend process - terminate it
|
||||
print(f"🧟 Found zombie backend process (PID: {lock_pid})")
|
||||
if self.terminate_process_safely(lock_pid):
|
||||
|
@ -239,35 +244,36 @@ class InstanceManager:
|
|||
return True, "✅ Cleaned up zombie backend process"
|
||||
else:
|
||||
return False, f"❌ Failed to cleanup zombie process (PID: {lock_pid})"
|
||||
|
||||
|
||||
def initialize_instance(self) -> bool:
|
||||
"""
|
||||
Initialize this instance (create lock file)
|
||||
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
return self.create_lock_file()
|
||||
|
||||
|
||||
def cleanup_instance(self) -> bool:
|
||||
"""
|
||||
Cleanup this instance (remove lock file)
|
||||
|
||||
|
||||
Returns:
|
||||
True if cleanup successful, False otherwise
|
||||
"""
|
||||
return self.cleanup_lock_file()
|
||||
|
||||
|
||||
def check_backend_instance(port: int = 5050,
|
||||
lock_file: str = "plc_streamer.lock") -> Tuple[bool, str]:
|
||||
def check_backend_instance(
|
||||
port: int = 5050, lock_file: str = "plc_streamer.lock"
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Convenience function to check and handle backend instances
|
||||
|
||||
|
||||
Args:
|
||||
port: Backend server port
|
||||
lock_file: Lock file path
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (can_proceed, message)
|
||||
"""
|
||||
|
|
Loading…
Reference in New Issue