Source code for exlab_wizard.window.main
"""``exlab-wizard-window`` console_scripts entry point. Backend Spec §15.3.2.
Reads ``<state_dir>/server.json`` written by the tray's
:class:`ServerRunner`, validates the recorded PID is alive, and hands
off to :mod:`pywebview_app`.
Stale state file -- prints a one-line message to stderr and exits with
status 2 (the tray's :class:`WindowLauncher` interprets this as "tray
died; restart from scratch"; Backend §4.3.2).
"""
from __future__ import annotations
import json
import sys
from collections.abc import Callable
from pathlib import Path
from exlab_wizard.constants import SERVER_STATE_FILE
from exlab_wizard.logging import get_logger
__all__ = [
"EXIT_OK",
"EXIT_STALE_STATE",
"EXIT_USAGE",
"ServerHandshake",
"main",
"read_server_handshake",
]
_log = get_logger(__name__)
EXIT_OK = 0
EXIT_STALE_STATE = 2
EXIT_USAGE = 64
[docs]
class ServerHandshake:
"""Resolved ``server.json`` contents: ``port`` / ``pid`` / ``started_at``."""
__slots__ = ("pid", "port", "started_at")
def __init__(self, *, port: int, pid: int, started_at: str) -> None:
self.port = int(port)
self.pid = int(pid)
self.started_at = str(started_at)
[docs]
def read_server_handshake(state_dir: Path) -> ServerHandshake | None:
"""Read and validate ``<state_dir>/server.json``.
Returns the handshake on success, ``None`` if the file is missing
or malformed. Validation is intentionally minimal -- if the file
exists, the PID is alive, and the port parses, we proceed; deeper
health checks happen on the first HTTP call inside pywebview.
"""
state_path = Path(state_dir) / SERVER_STATE_FILE
if not state_path.exists():
return None
try:
payload = json.loads(state_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
try:
return ServerHandshake(
port=int(payload["port"]),
pid=int(payload["pid"]),
started_at=str(payload.get("started_at", "")),
)
except (KeyError, TypeError, ValueError):
return None
[docs]
def is_pid_alive(pid: int) -> bool:
"""Cross-platform "is this PID a live process?" check.
Implementation: POSIX ``os.kill(pid, 0)`` raises ``ProcessLookupError``
if the PID is dead and ``PermissionError`` if it's alive but owned
by a different uid (counts as alive for our purposes -- if the
operator started a previous tray that is now owned by another user,
we still don't want to hijack it). Windows uses ``OpenProcess`` via
``ctypes``; we deliberately keep the implementation small and
inline because it has no other call sites.
"""
if pid <= 0:
return False
if sys.platform == "win32":
return _win_is_pid_alive(pid)
return _posix_is_pid_alive(pid)
def _posix_is_pid_alive(pid: int) -> bool:
import os
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
# Different uid; counts as alive (we cannot tell otherwise).
return True
# Win32 process-state constants (named to match the Microsoft API).
_SYNCHRONIZE = 0x00100000
_STILL_ACTIVE = 259
def _win_is_pid_alive(pid: int) -> bool: # pragma: no cover -- non-Windows CI
import ctypes
# ``ctypes.windll`` is Windows-only; mypy on Linux flags it as
# missing, so route the whole branch through one ignored attribute
# access instead of one ignore per call.
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
handle = kernel32.OpenProcess(_SYNCHRONIZE, False, pid)
if not handle:
return False
try:
exit_code = ctypes.c_ulong()
ok = kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
return bool(ok) and exit_code.value == _STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
# ---------------------------------------------------------------------------
# main()
# ---------------------------------------------------------------------------
[docs]
def main(
argv: list[str] | None = None,
*,
state_dir: Path | None = None,
handoff: Callable[[ServerHandshake], int] | None = None,
pid_alive: Callable[[int], bool] | None = None,
) -> int:
"""Entry point. Backend Spec §15.3.2.
``argv`` is ignored at this phase (the spec lists ``--debug`` as a
debug-build-only flag; release artifacts read the ``EXLAB_DEBUG``
env var instead).
``state_dir``, ``handoff``, and ``pid_alive`` are dependency
injection hooks for tests.
"""
_ = argv
if state_dir is None:
from exlab_wizard.paths import ensure_state_dir
state_dir = ensure_state_dir()
handshake = read_server_handshake(state_dir)
if handshake is None:
sys.stderr.write(
"exlab-wizard-window: server.json missing or unreadable; is the tray running?\n"
)
return EXIT_STALE_STATE
alive_check = pid_alive if pid_alive is not None else is_pid_alive
if not alive_check(handshake.pid):
sys.stderr.write(
f"exlab-wizard-window: server.json points at dead PID {handshake.pid}; "
"restart the tray.\n"
)
return EXIT_STALE_STATE
handler = handoff if handoff is not None else _default_handoff
return handler(handshake)
def _default_handoff(handshake: ServerHandshake) -> int:
"""Hand off control to :mod:`pywebview_app`."""
from exlab_wizard.window.pywebview_app import run_window
return run_window(handshake)
if __name__ == "__main__": # pragma: no cover -- script entrypoint
sys.exit(main())