import subprocess import threading import time import sys import os import shutil import webbrowser from pathlib import Path from urllib.request import urlopen from urllib.error import URLError from typing import Optional try: import webview except Exception: webview = None # Add project root to sys.path to allow for absolute imports sys.path.append(str(Path(__file__).resolve().parent.parent)) from journal.core.config import PID_FILE, SERVER_CONTROL_FILE # Global variable to store the NiceGUI server process nicegui_process = None _process_lock = threading.Lock() _watchdog_stop = threading.Event() _WATCHDOG_INTERVAL_SECONDS = 10.0 _WATCHDOG_MAX_HEALTH_FAILURES = 3 _WATCHDOG_MIN_RESTART_INTERVAL_SECONDS = 5.0 _WATCHDOG_MAX_FAILED_RESTARTS = 5 _watchdog_failed_restarts = 0 _last_restart_monotonic = 0.0 SERVER_URL = "http://localhost:8080" HEALTHCHECK_URL = f"{SERVER_URL}/_health" VALID_SERVER_ACTIONS = {"restart", "shutdown"} def wait_for_server(url: str, timeout_seconds: float = 20.0) -> bool: """Polls the local server until it responds or timeout is hit.""" deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: try: with urlopen(url, timeout=1.0) as response: if 200 <= response.status < 300: return True except URLError: pass except Exception: pass time.sleep(0.2) return False def _read_server_action(clear: bool = True) -> str | None: if not SERVER_CONTROL_FILE.exists(): return None action = None try: action = SERVER_CONTROL_FILE.read_text(encoding="utf-8").strip().lower() except OSError: action = None finally: if clear: SERVER_CONTROL_FILE.unlink(missing_ok=True) if action in VALID_SERVER_ACTIONS: return action return None def _clear_server_action() -> None: SERVER_CONTROL_FILE.unlink(missing_ok=True) def get_python_executable() -> str: """ Determines the correct Python executable path by searching the system's PATH. This function uses `shutil.which('python')` to locate the `python` executable. When a virtual environment is active, its `bin` directory is at the front of the PATH, so this will correctly return the path to the venv's interpreter. Returns: The absolute path to the Python executable. """ python_executable = shutil.which('python') if python_executable: return python_executable # Fallback to sys.executable if shutil.which fails for some reason. return sys.executable def is_server_running(): """Checks if a server process is running based on the PID file.""" if not PID_FILE.exists(): return False try: pid = int(PID_FILE.read_text()) if sys.platform == "win32": # On Windows, check if the process is in the task list result = subprocess.run( ["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True, ) return str(pid) in result.stdout else: # On Unix-like systems, os.kill(pid, 0) checks for process existence os.kill(pid, 0) return True # Process exists except (ValueError, ProcessLookupError, subprocess.CalledProcessError): print("Stale PID file found. Removing it.") PID_FILE.unlink() return False except PermissionError: # We don't have permission to signal the process, but it exists. return True def start_nicegui(): global nicegui_process project_root = Path(__file__).resolve().parent.parent # The UI app is now at journal/ui/main.py ui_app_path = project_root / "journal" / "ui" / "main.py" # Use the same Python interpreter that is running this script. # This is more robust and portable than a hardcoded path. venv_python = get_python_executable() command = [str(venv_python), str(ui_app_path)] print(f"Starting NiceGUI server with command: {' '.join(command)}") # Use Popen to run in the background and store the process object. process = subprocess.Popen(command, cwd=project_root) with _process_lock: nicegui_process = process # Create the PID file to signal that the server is running PID_FILE.parent.mkdir(exist_ok=True) _ = PID_FILE.write_text(str(process.pid)) def _safe_get_process() -> Optional[subprocess.Popen]: with _process_lock: return nicegui_process def _safe_set_process(process: Optional[subprocess.Popen]) -> None: global nicegui_process with _process_lock: nicegui_process = process def _cleanup_pid_if_matches(process: Optional[subprocess.Popen]) -> None: if process is None or not PID_FILE.exists(): return try: pid_in_file = int(PID_FILE.read_text()) except (ValueError, OSError): pid_in_file = None if pid_in_file == process.pid: PID_FILE.unlink(missing_ok=True) def _stop_process(process: Optional[subprocess.Popen], timeout_seconds: float = 5.0) -> None: if process is None: return if process.poll() is not None: _cleanup_pid_if_matches(process) return print(f"Stopping NiceGUI server process {process.pid}...") process.terminate() try: process.wait(timeout=timeout_seconds) except subprocess.TimeoutExpired: print("NiceGUI server did not terminate gracefully, killing...") process.kill() process.wait(timeout=timeout_seconds) finally: _cleanup_pid_if_matches(process) def _restart_nicegui(reason: str) -> None: global _watchdog_failed_restarts, _last_restart_monotonic if _watchdog_stop.is_set(): return elapsed = time.monotonic() - _last_restart_monotonic if elapsed < _WATCHDOG_MIN_RESTART_INTERVAL_SECONDS: time.sleep(_WATCHDOG_MIN_RESTART_INTERVAL_SECONDS - elapsed) print(f"Watchdog restart triggered: {reason}") current = _safe_get_process() _stop_process(current) _safe_set_process(None) _last_restart_monotonic = time.monotonic() start_nicegui() if wait_for_server(HEALTHCHECK_URL, timeout_seconds=20.0): _watchdog_failed_restarts = 0 print("Watchdog restart completed: server is healthy.") else: _watchdog_failed_restarts += 1 print("Watchdog restart warning: server did not become healthy in time.") if _watchdog_failed_restarts >= _WATCHDOG_MAX_FAILED_RESTARTS: print( "Watchdog giving up after repeated failed restarts. " "Stopping wrapper so it does not run forever without a healthy server." ) _watchdog_stop.set() def _watchdog_loop() -> None: consecutive_health_failures = 0 while not _watchdog_stop.wait(_WATCHDOG_INTERVAL_SECONDS): process = _safe_get_process() if process is None: continue exit_code = process.poll() if exit_code is not None: _cleanup_pid_if_matches(process) _safe_set_process(None) action = _read_server_action(clear=True) if action == "shutdown": print("Server shutdown requested from UI. Stopping wrapper.") _watchdog_stop.set() break if action == "restart": _restart_nicegui("server restart requested from UI") else: _restart_nicegui(f"server process exited with code {exit_code}") consecutive_health_failures = 0 continue is_healthy = wait_for_server(HEALTHCHECK_URL, timeout_seconds=1.0) if is_healthy: consecutive_health_failures = 0 continue consecutive_health_failures += 1 print( f"Watchdog health check failed ({consecutive_health_failures}/{_WATCHDOG_MAX_HEALTH_FAILURES})." ) if consecutive_health_failures >= _WATCHDOG_MAX_HEALTH_FAILURES: _restart_nicegui("health endpoint was unresponsive repeatedly") consecutive_health_failures = 0 def run(): started_by_wrapper = False watchdog_thread: Optional[threading.Thread] = None _clear_server_action() # Check if the server is already running from another process (e.g., CLI) if is_server_running(): print("NiceGUI server is already running. Connecting to it.") else: # Start NiceGUI server managed by this wrapper. print("Starting NiceGUI server...") start_nicegui() started_by_wrapper = True if started_by_wrapper: _watchdog_stop.clear() watchdog_thread = threading.Thread(target=_watchdog_loop, daemon=True) watchdog_thread.start() if not wait_for_server(HEALTHCHECK_URL): print("Warning: server readiness check timed out; opening webview anyway.") try: # Open desktop shell if available; otherwise use browser fallback. if webview is not None: try: print("Opening webview window...") _ = webview.create_window("Project Journal", SERVER_URL) webview.start() # Blocks until the window is closed except Exception as exc: print(f"Webview failed ({exc}). Falling back to system browser...") _ = webbrowser.open(SERVER_URL) print(f"Browser opened at {SERVER_URL}. Press Ctrl+C to stop.") try: while True: time.sleep(1) except KeyboardInterrupt: pass else: print("pywebview not available; opening system browser instead.") _ = webbrowser.open(SERVER_URL) print(f"Browser opened at {SERVER_URL}. Press Ctrl+C to stop.") try: while True: if started_by_wrapper and _watchdog_stop.is_set(): print( "Server watchdog stopped (repeated recovery failures). " "Exiting wrapper loop." ) break time.sleep(1) except KeyboardInterrupt: pass finally: _watchdog_stop.set() if watchdog_thread is not None: watchdog_thread.join(timeout=2.0) # Stop child server only if this wrapper started/managed it. if started_by_wrapper: _stop_process(_safe_get_process()) _safe_set_process(None) if __name__ == "__main__": run()