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:
Miguel 2025-08-22 15:22:30 +02:00
parent 082f8b1790
commit 6302acfc0f
9 changed files with 1490 additions and 1334 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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": {}
}

View File

@ -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

View File

@ -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}")

267
main copy.spec Normal file
View File

@ -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
View File

@ -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)

View File

@ -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=[],

View File

@ -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"
}

View File

@ -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)
"""