""" NX Session Manager - Tracks NX sessions to prevent accidental closure of user sessions. This module ensures that Atomizer only closes NX sessions that it explicitly started, never touching sessions that were already running or started by the user. Key Features: - Automatically starts NX if not running - Tracks PID of the NX instance we started - Only closes NX instances we started (never user's sessions) - Waits for NX to fully load before proceeding """ import os import json import time import subprocess from pathlib import Path from typing import Optional, Tuple, List # Session tracking file location SESSION_LOCK_DIR = Path(os.environ.get('TEMP', '/tmp')) / 'atomizer_nx_sessions' # Default NX installation paths (in order of preference) DEFAULT_NX_PATHS = [ Path(r"C:\Program Files\Siemens\NX2506\NXBIN\ugraf.exe"), Path(r"C:\Program Files\Siemens\NX2412\NXBIN\ugraf.exe"), Path(r"C:\Program Files\Siemens\Simcenter3D_2506\NXBIN\ugraf.exe"), Path(r"C:\Program Files\Siemens\Simcenter3D_2412\NXBIN\ugraf.exe"), ] class NXSessionManager: """ Manages NX sessions to ensure we only close sessions we started. Usage: manager = NXSessionManager() # Check if NX is already running before starting optimization was_running = manager.is_nx_running() # Start NX if needed (records that we started it) if not was_running: manager.start_nx() # ... run optimization ... # Only close NX if we started it if manager.can_close_nx(): manager.close_nx() """ def __init__(self, session_id: Optional[str] = None, nx_exe_path: Optional[Path] = None): """ Initialize the session manager. Args: session_id: Unique identifier for this optimization session. If None, uses process ID. nx_exe_path: Path to ugraf.exe. If None, auto-detects from common locations. """ self.session_id = session_id or f"atomizer_{os.getpid()}" self.lock_file = SESSION_LOCK_DIR / f"{self.session_id}.lock" self._nx_was_running_before_start = None self._started_nx_pid = None # Find NX executable self.nx_exe_path = nx_exe_path or self._find_nx_executable() # Ensure lock directory exists SESSION_LOCK_DIR.mkdir(parents=True, exist_ok=True) def _find_nx_executable(self) -> Optional[Path]: """Find NX executable from common installation paths.""" for path in DEFAULT_NX_PATHS: if path.exists(): return path return None def is_nx_running(self) -> bool: """Check if NX (ugraf.exe) is currently running.""" try: result = subprocess.run( ['tasklist', '/FI', 'IMAGENAME eq ugraf.exe'], capture_output=True, text=True, timeout=10 ) return 'ugraf.exe' in result.stdout.lower() except Exception: return False def get_nx_pids(self) -> list: """Get list of all NX process IDs currently running.""" pids = [] try: result = subprocess.run( ['tasklist', '/FI', 'IMAGENAME eq ugraf.exe', '/FO', 'CSV'], capture_output=True, text=True, timeout=10 ) for line in result.stdout.strip().split('\n')[1:]: # Skip header if 'ugraf.exe' in line.lower(): parts = line.strip('"').split('","') if len(parts) >= 2: try: pids.append(int(parts[1])) except ValueError: pass except Exception: pass return pids def start_nx(self, wait_for_ready: bool = True, timeout_seconds: int = 60) -> Optional[int]: """ Start a new NX instance and track its PID. Args: wait_for_ready: If True, wait for NX to fully load before returning timeout_seconds: Maximum time to wait for NX to start (default: 60s) Returns: PID of the started NX process, or None if failed Raises: RuntimeError: If NX executable not found or failed to start """ if not self.nx_exe_path or not self.nx_exe_path.exists(): raise RuntimeError( f"NX executable not found. Checked paths:\n" f"{chr(10).join(str(p) for p in DEFAULT_NX_PATHS)}\n" f"Please install NX or specify the path manually." ) # Get PIDs before starting pids_before = set(self.get_nx_pids()) print(f"[NX SESSION] Starting NX from: {self.nx_exe_path}") print(f"[NX SESSION] Existing NX PIDs: {pids_before or 'none'}") try: # Start NX as a detached process (won't block) # Using subprocess.Popen with CREATE_NEW_PROCESS_GROUP process = subprocess.Popen( [str(self.nx_exe_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS ) # The Popen PID might be a launcher, not ugraf.exe itself # We need to wait and detect the actual ugraf.exe PID if wait_for_ready: print(f"[NX SESSION] Waiting for NX to start (up to {timeout_seconds}s)...") start_time = time.time() while (time.time() - start_time) < timeout_seconds: current_pids = set(self.get_nx_pids()) new_pids = current_pids - pids_before if new_pids: # Found a new NX process! self._started_nx_pid = list(new_pids)[0] elapsed = time.time() - start_time print(f"[NX SESSION] NX started! PID: {self._started_nx_pid} (took {elapsed:.1f}s)") return self._started_nx_pid # Show progress elapsed = time.time() - start_time if int(elapsed) % 5 == 0 and elapsed > 0: print(f"[NX SESSION] Still waiting... ({elapsed:.0f}s)") time.sleep(1) # Timeout reached print(f"[NX SESSION] WARNING: Timeout waiting for NX to start") return None else: # Don't wait - just return the Popen PID (may not be ugraf.exe) self._started_nx_pid = process.pid return process.pid except Exception as e: print(f"[NX SESSION] ERROR starting NX: {e}") raise RuntimeError(f"Failed to start NX: {e}") def record_session_start(self, nx_was_running: bool, started_pid: Optional[int] = None): """ Record that we're starting an optimization session. Args: nx_was_running: Whether NX was already running before we started started_pid: PID of NX process we started (if any) """ self._nx_was_running_before_start = nx_was_running session_data = { 'session_id': self.session_id, 'start_time': time.time(), 'nx_was_running_before': nx_was_running, 'started_nx_pid': started_pid, 'original_nx_pids': self.get_nx_pids() if nx_was_running else [] } with open(self.lock_file, 'w') as f: json.dump(session_data, f, indent=2) print(f"[NX SESSION] Recorded session start:") print(f" Session ID: {self.session_id}") print(f" NX was already running: {nx_was_running}") if started_pid: print(f" Started NX PID: {started_pid}") def load_session_data(self) -> Optional[dict]: """Load session data from lock file.""" if self.lock_file.exists(): try: with open(self.lock_file, 'r') as f: return json.load(f) except Exception: pass return None def can_close_nx(self) -> bool: """ Determine if we're allowed to close NX. Returns True only if: 1. We have a valid session record 2. NX was NOT running before we started 3. We explicitly started NX for this session """ session_data = self.load_session_data() if not session_data: print("[NX SESSION] WARNING: No session data found - refusing to close NX") return False if session_data.get('nx_was_running_before', True): print("[NX SESSION] NX was running before optimization - NOT closing") return False if session_data.get('started_nx_pid') is None: print("[NX SESSION] We didn't start NX - NOT closing") return False print("[NX SESSION] We started NX for this session - safe to close") return True def get_pids_we_can_close(self) -> list: """ Get list of NX PIDs that we're allowed to close. Only returns PIDs that: 1. Are currently running 2. Were started by us (not in original_nx_pids) """ session_data = self.load_session_data() if not session_data: return [] original_pids = set(session_data.get('original_nx_pids', [])) current_pids = set(self.get_nx_pids()) # PIDs we can close = current PIDs minus original PIDs closeable = list(current_pids - original_pids) if closeable: print(f"[NX SESSION] PIDs we can close: {closeable}") else: print(f"[NX SESSION] No PIDs to close (original: {original_pids}, current: {current_pids})") return closeable def close_nx_if_allowed(self) -> bool: """ Close NX only if we're allowed to. Returns: True if NX was closed, False if not allowed or already closed """ if not self.can_close_nx(): return False pids_to_close = self.get_pids_we_can_close() if not pids_to_close: print("[NX SESSION] No NX processes to close") return False for pid in pids_to_close: try: print(f"[NX SESSION] Closing NX PID {pid}...") subprocess.run( ['taskkill', '/F', '/PID', str(pid)], capture_output=True, timeout=30 ) except Exception as e: print(f"[NX SESSION] Failed to close PID {pid}: {e}") return True def cleanup(self): """Remove session lock file.""" if self.lock_file.exists(): try: self.lock_file.unlink() print(f"[NX SESSION] Cleaned up session lock: {self.lock_file}") except Exception: pass def ensure_nx_running( session_id: Optional[str] = None, auto_start: bool = True, start_timeout: int = 60 ) -> Tuple[NXSessionManager, bool]: """ Ensure NX is running for optimization. Starts NX automatically if needed. This is the main entry point for optimization scripts. It: 1. Checks if NX is already running 2. If not, starts a fresh NX instance 3. Records the session so we know which instance to close later Args: session_id: Unique session identifier (defaults to process ID) auto_start: If True, automatically start NX if not running start_timeout: Seconds to wait for NX to start (default: 60) Returns: Tuple of (session_manager, nx_was_started) - session_manager: Use this to close NX when done - nx_was_started: True if we started NX, False if it was already running Example: manager, we_started_nx = ensure_nx_running() # ... run optimization ... # This will only close NX if we started it manager.close_nx_if_allowed() manager.cleanup() """ manager = NXSessionManager(session_id) was_running = manager.is_nx_running() started_pid = None if was_running: print("[NX SESSION] NX is already running - will NOT close after optimization") # Record existing PIDs so we don't close them original_pids = manager.get_nx_pids() print(f"[NX SESSION] Existing PIDs: {original_pids}") else: if auto_start: print("[NX SESSION] NX is not running - starting fresh instance...") started_pid = manager.start_nx(wait_for_ready=True, timeout_seconds=start_timeout) if started_pid: print(f"[NX SESSION] Successfully started NX (PID: {started_pid})") print(f"[NX SESSION] Will close this instance when optimization completes") else: raise RuntimeError( "Failed to start NX. Please ensure NX is installed and try again.\n" f"Checked paths: {', '.join(str(p) for p in DEFAULT_NX_PATHS)}" ) else: raise RuntimeError( "NX is not running and auto_start=False.\n" "Please start NX manually before running optimization." ) manager.record_session_start( nx_was_running=was_running, started_pid=started_pid ) return manager, not was_running # Global session manager for module-level usage _global_session_manager: Optional[NXSessionManager] = None def get_session_manager() -> Optional[NXSessionManager]: """Get the global session manager if one exists.""" return _global_session_manager def set_session_manager(manager: NXSessionManager): """Set the global session manager.""" global _global_session_manager _global_session_manager = manager def validate_nx_running( require_nx: bool = True, timeout_seconds: int = 0, auto_prompt: bool = True ) -> bool: """ Validate that NX is running before starting optimization. This function checks if NX (ugraf.exe) is running and provides clear feedback to the user if it's not. Args: require_nx: If True, raise an error if NX is not running. If False, just return the status. timeout_seconds: If > 0, wait this many seconds for user to open NX. Check every 5 seconds with a countdown. auto_prompt: If True, print helpful message when NX is not running. Returns: True if NX is running, False otherwise. Raises: RuntimeError: If require_nx=True and NX is not running (after timeout). Example: # At the start of optimization: validate_nx_running(require_nx=True, timeout_seconds=60) # Will wait up to 60 seconds for user to open NX """ manager = NXSessionManager("validation_check") # Initial check if manager.is_nx_running(): print("[NX VALIDATION] ✓ NX is running") return True # NX is not running if auto_prompt: print("\n" + "="*70) print(" NX IS NOT RUNNING") print("="*70) print("\n Atomizer requires NX to be open to run FEA simulations.") print(" Please open NX (Simcenter 3D) and try again.") print("\n Note: You can leave NX open - Atomizer will not close it") print(" since it was already running before optimization started.") print("="*70 + "\n") # If timeout specified, wait for user to open NX if timeout_seconds > 0: print(f"[NX VALIDATION] Waiting up to {timeout_seconds}s for NX to start...") start_time = time.time() check_interval = 5 # seconds while (time.time() - start_time) < timeout_seconds: remaining = int(timeout_seconds - (time.time() - start_time)) if manager.is_nx_running(): print(f"\n[NX VALIDATION] ✓ NX detected! Proceeding with optimization...") return True print(f" Waiting for NX... ({remaining}s remaining)") time.sleep(check_interval) print(f"\n[NX VALIDATION] Timeout reached. NX was not started.") # NX is still not running if require_nx: raise RuntimeError( "NX is not running. Please open NX (Simcenter 3D) before starting optimization.\n" "Atomizer requires NX to execute FEA simulations via journal scripts." ) return False def require_nx_or_exit( auto_start: bool = True, start_timeout: int = 60 ) -> NXSessionManager: """ Convenience function: Ensure NX is running, starting it if needed. This is the recommended function to call at the start of any optimization. It will: 1. Check if NX is already running 2. If not, automatically start NX (takes ~10-15 seconds) 3. Track which NX instance we started 4. Only close that specific instance when done (never user's sessions) Args: auto_start: If True, start NX automatically if not running (default: True) start_timeout: Seconds to wait for NX to start (default: 60) Returns: NXSessionManager configured with session tracking Example: # At start of run_optimization.py: from optimization_engine.utils import require_nx_or_exit session_manager = require_nx_or_exit() # ... run optimization ... # At end - only closes NX if we started it! session_manager.close_nx_if_allowed() session_manager.cleanup() """ # Use ensure_nx_running which handles auto-start manager, nx_was_started = ensure_nx_running( auto_start=auto_start, start_timeout=start_timeout ) return manager if __name__ == '__main__': # Test the session manager print("Testing NX Session Manager...") manager = NXSessionManager("test_session") print(f"NX running: {manager.is_nx_running()}") print(f"NX PIDs: {manager.get_nx_pids()}") # Simulate session start manager.record_session_start( nx_was_running=manager.is_nx_running(), started_pid=None ) print(f"Can close NX: {manager.can_close_nx()}") print(f"PIDs we can close: {manager.get_pids_we_can_close()}") # Cleanup manager.cleanup() print("Test complete.")