2026-02-23 20:12:10 -06:00

373 lines
13 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_HEALTHCHECK_TIMEOUT_SECONDS = 2.5
_WATCHDOG_MIN_RESTART_INTERVAL_SECONDS = 5.0
_WATCHDOG_MAX_FAILED_RESTARTS = 5
_WATCHDOG_MAX_CONSECUTIVE_CRASH_RESTARTS = 3
_WATCHDOG_STARTUP_GRACE_SECONDS = 45.0
_WATCHDOG_RESTART_GRACE_SECONDS = 30.0
_watchdog_failed_restarts = 0
_last_restart_monotonic = 0.0
_watchdog_grace_until_monotonic = 0.0
SERVER_URL = "http://localhost:8080"
HEALTHCHECK_URL = f"{SERVER_URL}/_health"
VALID_SERVER_ACTIONS = {"restart", "shutdown"}
DISABLE_WEBVIEW = os.getenv("JOURNAL_DISABLE_WEBVIEW", "").strip().lower() in {
"1",
"true",
"yes",
}
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:
"""
Returns the current interpreter path for child server startup.
Prefer `sys.executable` so wrapper and child use the same runtime (e.g., 3.14t).
Fall back to PATH lookup only if `sys.executable` is missing/unusable.
Returns:
The absolute path to the Python executable.
"""
if sys.executable:
return sys.executable
python_executable = shutil.which("python")
if python_executable:
return python_executable
raise RuntimeError("Could not determine Python executable path.")
def can_use_webview() -> bool:
"""
Returns True when pywebview backend is available on this host.
On Windows, pywebview requires pythonnet (`clr`) for WinForms backend.
"""
if DISABLE_WEBVIEW:
print("Webview disabled via JOURNAL_DISABLE_WEBVIEW; using browser mode.")
return False
if webview is None:
return False
if sys.platform != "win32":
return True
try:
import clr # type: ignore # noqa: F401
return True
except Exception:
print("pywebview backend unavailable on Windows (pythonnet `clr` is missing).")
return False
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, _watchdog_grace_until_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()
_watchdog_grace_until_monotonic = (
time.monotonic() + _WATCHDOG_RESTART_GRACE_SECONDS
)
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:
global _watchdog_grace_until_monotonic
consecutive_health_failures = 0
consecutive_crash_restarts = 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":
consecutive_crash_restarts = 0
_restart_nicegui("server restart requested from UI")
else:
if exit_code != 0:
consecutive_crash_restarts += 1
else:
consecutive_crash_restarts = 0
if consecutive_crash_restarts >= _WATCHDOG_MAX_CONSECUTIVE_CRASH_RESTARTS:
print(
"Server crashed repeatedly during startup. "
"Common cause: launching with free-threaded Python (python3.14t) while "
"binary wheels (orjson/pydantic_core) are installed for regular CPython. "
"Use python.exe for this project runtime."
)
_watchdog_stop.set()
break
_restart_nicegui(f"server process exited with code {exit_code}")
consecutive_health_failures = 0
continue
if time.monotonic() < _watchdog_grace_until_monotonic:
consecutive_health_failures = 0
continue
is_healthy = wait_for_server(
HEALTHCHECK_URL, timeout_seconds=_WATCHDOG_HEALTHCHECK_TIMEOUT_SECONDS
)
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():
global _watchdog_grace_until_monotonic
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()
_watchdog_grace_until_monotonic = (
time.monotonic() + _WATCHDOG_STARTUP_GRACE_SECONDS
)
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 can_use_webview():
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()