309 lines
10 KiB
Python
309 lines
10 KiB
Python
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()
|