From 952e68868c4ee7a0039313130711db01dc63e28d Mon Sep 17 00:00:00 2001 From: zookiart Date: Wed, 24 Jun 2026 21:43:34 -0700 Subject: [PATCH] Reliable shutdown on Windows + `lelab --stop` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, stopping LeLab (Ctrl-C / closing the terminal) often leaves the uvicorn and Vite child processes alive, which keeps port :8000 — and any open camera handles — held. The next `lelab` then fails to bind, or a camera won't open because the previous run never released it. - `lelab --stop`: find and terminate a running LeLab and its child process tree, freeing the port. - Clean process-tree teardown on exit (psutil, with a Windows `taskkill /T` fallback) so the uvicorn/Vite children and their handles are actually released. - Pre-flight port checks with an actionable message ("…run `lelab --stop` to free it") instead of an opaque bind error, plus a readiness wait before opening the browser. Tests in tests/test_scripts_lelab.py cover the stop / teardown / port-check paths with mocked psutil + subprocess (no real processes spawned). Co-Authored-By: Claude Opus 4.8 --- lelab/scripts/lelab.py | 503 ++++++++++++++++++++++++++++-------- tests/test_scripts_lelab.py | 380 ++++++++++++++++++++++++++- 2 files changed, 758 insertions(+), 125 deletions(-) diff --git a/lelab/scripts/lelab.py b/lelab/scripts/lelab.py index f8e4a69..0291cf6 100644 --- a/lelab/scripts/lelab.py +++ b/lelab/scripts/lelab.py @@ -15,16 +15,18 @@ """ LeLab launcher. -Default mode: starts the FastAPI backend on :8000, which serves the -pre-built frontend at /. Opens the user's browser to the local app. - ---dev mode: spawns the Vite dev server (frontend/, port 8080) for HMR -and starts uvicorn with --reload. Opens the browser to :8080. +Default mode starts FastAPI on :8000 and serves the committed frontend/dist +bundle from the same process. Dev mode starts Vite on :8080 and uvicorn +--reload on :8000. """ +from __future__ import annotations + import argparse +import contextlib import logging import os +import shutil import signal import socket import subprocess @@ -32,168 +34,443 @@ import threading import time import webbrowser +from collections.abc import Sequence from pathlib import Path +import psutil import uvicorn -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.parent FRONTEND_PATH = PROJECT_ROOT / "frontend" FRONTEND_DIST = FRONTEND_PATH / "dist" +FRONTEND_NODE_MODULES = FRONTEND_PATH / "node_modules" +HOST = "127.0.0.1" BACKEND_PORT = 8000 FRONTEND_DEV_PORT = 8080 -def _wait_for_port(port: int, timeout: int = 30) -> bool: - for _ in range(timeout): - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +def _fail(message: str) -> None: + logger.error(message) + raise SystemExit(1) + + +def _is_port_open(port: int, host: str = HOST) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(1) - result = sock.connect_ex(("localhost", port)) - sock.close() - if result == 0: + return sock.connect_ex((host, port)) == 0 + + +def _wait_for_port(port: int, timeout: int = 30, host: str = HOST) -> bool: + for _ in range(timeout): + if _is_port_open(port, host): return True time.sleep(1) return False -def _open_browser_when_ready(): - """Background-thread helper: poll the port, open the browser when up.""" - for _ in range(60): - try: - with socket.create_connection(("127.0.0.1", BACKEND_PORT), timeout=0.5): - pass - except OSError: - time.sleep(0.5) +def _ensure_port_available(name: str, port: int, host: str = HOST) -> None: + if not _is_port_open(port, host): + return + _fail( + f"{name} port {port} is already in use on {host}. " + "If a previous LeLab run is still holding it, run `lelab --stop` to free it, " + "then run the command again." + ) + + +def _find_lelab_pids() -> dict[int, str]: + """PIDs that look like a running LeLab dev backend/frontend, with a reason. + + On Windows the uvicorn --reload worker inherits the listening socket from + its supervisor, so the OS attributes :8000 to a PID that may already be + gone — neither the global connection table nor `taskkill` finds the live + holder. So match three independent signals and stay scoped to LeLab so we + never touch unrelated dev servers: + 1. cmdline runs `uvicorn ... lelab.server` (the reload supervisor / prod) + 2. an orphaned reload worker (`multiprocessing.spawn`) whose cwd is this + project + 3. anything actually LISTENING on :8000 / :8080 (per-process scan, which + is more reliable than the global table on Windows) + """ + me = os.getpid() + ports = {BACKEND_PORT, FRONTEND_DEV_PORT} + targets: dict[int, str] = {} + for proc in psutil.process_iter(["pid", "cmdline"]): + pid = proc.info["pid"] + if pid == me: continue - logger.info("🌐 Opening browser...") - webbrowser.open(f"http://localhost:{BACKEND_PORT}/") + cmdline = " ".join(proc.info.get("cmdline") or []) + if "lelab.server" in cmdline: + targets[pid] = "uvicorn (lelab.server)" + continue + if "multiprocessing.spawn" in cmdline: + try: + if Path(proc.cwd()) == PROJECT_ROOT: + targets[pid] = "orphaned reload worker" + continue + except (psutil.AccessDenied, psutil.NoSuchProcess): + pass + try: + get_conns = getattr(proc, "net_connections", None) or proc.connections + for conn in get_conns(kind="inet"): + if conn.laddr and conn.laddr.port in ports and conn.status == psutil.CONN_LISTEN: + targets[pid] = f"listening on :{conn.laddr.port}" + break + except (psutil.AccessDenied, psutil.NoSuchProcess): + pass + return targets + + +def _run_stop() -> None: + """Stop a running LeLab and free :8000 / :8080. + + The escape hatch for when a previous run left an orphaned Vite or uvicorn + process holding the ports. + """ + targets = _find_lelab_pids() + if not targets: + logger.info("Nothing to stop: no LeLab process found on :%d / :%d.", BACKEND_PORT, FRONTEND_DEV_PORT) return + for pid, reason in targets.items(): + logger.info("Stopping pid %d (%s)...", pid, reason) + _terminate_tree(pid) + logger.info("LeLab stopped.") + + +def _require_command(command: str) -> str: + resolved = _resolve_command(command) + if resolved: + return resolved + _fail( + f"`{command}` was not found on PATH. Install Node.js LTS from https://nodejs.org/, " + "restart your terminal, then run LeLab again." + ) + raise AssertionError("unreachable") -def _run_prod(): - """Serve built frontend from backend on a single port.""" - if not FRONTEND_DIST.exists(): - logger.error(f"❌ Built frontend not found at {FRONTEND_DIST}") - logger.error(" Run `npm run build` in frontend/ first, or use `lelab --dev`.") - sys.exit(1) +def _resolve_command(command: str) -> str | None: + if os.name == "nt" and not Path(command).suffix: + for suffix in (".cmd", ".exe", ".bat"): + resolved = shutil.which(f"{command}{suffix}") + if resolved: + return resolved + return shutil.which(command) - logger.info("🚀 Starting LeLab on http://localhost:%d ...", BACKEND_PORT) - threading.Thread(target=_open_browser_when_ready, daemon=True).start() +def _ensure_frontend_path() -> None: + if FRONTEND_PATH.exists(): + return + _fail(f"Frontend source not found at {FRONTEND_PATH}. Run LeLab from a complete checkout or reinstall it.") - # Run uvicorn in the main thread so its native SIGINT handler works, - # and bound graceful shutdown so a stuck WebSocket can't hang Ctrl+C. - uvicorn.run( - "lelab.server:app", - host="127.0.0.1", - port=BACKEND_PORT, - log_level="info", - reload=False, - timeout_graceful_shutdown=2, + +def _ensure_frontend_dist() -> None: + index_html = FRONTEND_DIST / "index.html" + if index_html.exists(): + return + _fail( + f"Built frontend not found at {index_html}. Run `lelab --rebuild`, " + "or run `cd frontend && npm run build`, then start LeLab again." ) -def _run_dev(): - """Vite dev server (HMR) + uvicorn --reload.""" - if not FRONTEND_PATH.exists(): - logger.error(f"❌ Frontend not found at {FRONTEND_PATH}") - sys.exit(1) +def _frontend_deps_installed() -> bool: + return FRONTEND_NODE_MODULES.exists() - logger.info("📦 Installing frontend deps...") - subprocess.run(["npm", "install"], check=True, cwd=FRONTEND_PATH) - logger.info("🎨 Starting Vite dev server (port %d)...", FRONTEND_DEV_PORT) - frontend_process = subprocess.Popen( - ["npm", "run", "dev"], - cwd=FRONTEND_PATH, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, +def _run_checked(command: Sequence[str], cwd: Path, failure_hint: str) -> None: + executable = _require_command(command[0]) + resolved_command = [executable, *command[1:]] + try: + subprocess.run(resolved_command, cwd=cwd, check=True) + except FileNotFoundError as exc: + _fail(f"Could not run `{command[0]}`: {exc}. {failure_hint}") + except subprocess.CalledProcessError as exc: + _fail(f"`{' '.join(command)}` failed with exit code {exc.returncode}. {failure_hint}") + + +def _ensure_frontend_deps() -> None: + _ensure_frontend_path() + _require_command("node") + _require_command("npm") + if _frontend_deps_installed(): + logger.info("Frontend dependencies found; skipping npm install.") + return + logger.info("Installing frontend dependencies...") + _run_checked( + ["npm", "install"], + FRONTEND_PATH, + "Run `cd frontend && npm install` to inspect the npm error.", ) - if not _wait_for_port(FRONTEND_DEV_PORT): - logger.error("❌ Frontend never came up") - frontend_process.terminate() - sys.exit(1) - - logger.info("🚀 Starting backend (port %d) with --reload...", BACKEND_PORT) - backend_process = subprocess.Popen( - [ - sys.executable, - "-m", - "uvicorn", - "lelab.server:app", - "--host", - "127.0.0.1", - "--port", - str(BACKEND_PORT), - "--reload", - ], - cwd=PROJECT_ROOT, - env=os.environ.copy(), - start_new_session=True, + +def _run_frontend_build() -> None: + _ensure_frontend_deps() + logger.info("Building frontend/dist...") + _run_checked( + ["npm", "run", "build"], + FRONTEND_PATH, + "Run `cd frontend && npm run build` to inspect the build error.", ) - if not _wait_for_port(BACKEND_PORT, timeout=15): - logger.error("❌ Backend never came up") - for p in (backend_process, frontend_process): - try: - os.killpg(os.getpgid(p.pid), signal.SIGTERM) - except Exception: - p.terminate() - sys.exit(1) - logger.info("🌐 Opening browser...") - webbrowser.open(f"http://localhost:{FRONTEND_DEV_PORT}/") +def _child_process_kwargs(cwd: Path, env: dict[str, str] | None = None) -> dict[str, object]: + kwargs: dict[str, object] = {"cwd": cwd} + if env is not None: + kwargs["env"] = env - logger.info("✅ Dev mode running — Ctrl+C to stop") - logger.info(" Frontend: http://localhost:%d", FRONTEND_DEV_PORT) - logger.info(" Backend: http://localhost:%d", BACKEND_PORT) + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + if creationflags: + kwargs["creationflags"] = creationflags + else: + kwargs["start_new_session"] = True + return kwargs + + +def _start_process( + name: str, + command: Sequence[str], + cwd: Path, + env: dict[str, str] | None = None, +) -> subprocess.Popen: + executable = _require_command(command[0]) + resolved_command = [executable, *command[1:]] + logger.info("Starting %s: %s", name, " ".join(command)) + try: + return subprocess.Popen(resolved_command, **_child_process_kwargs(cwd, env)) + except FileNotFoundError as exc: + _fail(f"Could not start {name}: {exc}. Check that `{command[0]}` is installed and on PATH.") + raise AssertionError("unreachable") + + +def _terminate_tree(pid: int, timeout: int = 5) -> None: + """Terminate a process and every descendant. + + Dev mode's children are themselves process trees (npm.cmd -> node -> vite, + and uvicorn --reload -> reloader -> worker). Signalling only the direct + child leaves the grandchildren orphaned on Windows, where they keep holding + ports 8000/8080 and block the next launch. Walk the whole tree instead. + """ + try: + parent = psutil.Process(pid) + except psutil.NoSuchProcess: + return + procs = parent.children(recursive=True) + procs.append(parent) + for proc in procs: + with contextlib.suppress(psutil.NoSuchProcess): + proc.terminate() + _gone, alive = psutil.wait_procs(procs, timeout=timeout) + for proc in alive: + with contextlib.suppress(psutil.NoSuchProcess): + proc.kill() + + +def _stop_process(name: str, process: subprocess.Popen, timeout: int = 5) -> None: + if process.poll() is not None: + logger.info("%s already stopped.", name) + return + + logger.info("Stopping %s...", name) + _terminate_tree(process.pid, timeout) + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + logger.info("%s stopped.", name) + + +def _shutdown_processes(processes: Sequence[tuple[str, subprocess.Popen]]) -> None: + for name, process in reversed(processes): + _stop_process(name, process) + + +def _open_browser_url(url: str, no_open: bool) -> None: + if no_open: + logger.info("Browser launch disabled. Open %s when ready.", url) + return + logger.info("Opening browser: %s", url) + webbrowser.open(url) - def shutdown(signum, frame): - logger.info("🛑 Shutting down...") - for name, p in [("backend", backend_process), ("frontend", frontend_process)]: - try: - os.killpg(os.getpgid(p.pid), signal.SIGTERM) - p.wait(timeout=5) - except subprocess.TimeoutExpired: - try: - os.killpg(os.getpgid(p.pid), signal.SIGKILL) - except Exception: - p.kill() - except Exception: - pass - logger.info(f" ✅ {name} stopped") - sys.exit(0) + +def _open_browser_when_ready(port: int, no_open: bool) -> None: + if no_open: + logger.info("Browser launch disabled. Open http://localhost:%d/ when ready.", port) + return + + for _ in range(60): + if _is_port_open(port): + _open_browser_url(f"http://localhost:{port}/", no_open=False) + return + time.sleep(0.5) + + +def _install_signal_handlers(processes: Sequence[tuple[str, subprocess.Popen]]) -> None: + def shutdown(_signum, _frame) -> None: + logger.info("Shutting down LeLab...") + _shutdown_processes(processes) + raise SystemExit(0) signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGTERM, shutdown) + +def _monitor_processes(processes: Sequence[tuple[str, subprocess.Popen]]) -> None: while True: time.sleep(2) - if backend_process.poll() is not None: - logger.error("❌ Backend died") - shutdown(None, None) - if frontend_process.poll() is not None: - logger.error("❌ Frontend died") - shutdown(None, None) + for name, process in processes: + returncode = process.poll() + if returncode is not None: + logger.error("%s stopped with exit code %s.", name, returncode) + _shutdown_processes(processes) + raise SystemExit(returncode or 1) -def main(): +def _run_prod(*, no_open: bool = False, rebuild: bool = False) -> None: + """Serve built frontend from backend on a single port.""" + _ensure_port_available("Backend", BACKEND_PORT) + if rebuild: + _run_frontend_build() + _ensure_frontend_dist() + + logger.info("Starting LeLab on http://localhost:%d ...", BACKEND_PORT) + threading.Thread(target=_open_browser_when_ready, args=(BACKEND_PORT, no_open), daemon=True).start() + + config = uvicorn.Config( + "lelab.server:app", + host=HOST, + port=BACKEND_PORT, + log_level="info", + reload=False, + ) + server = uvicorn.Server(config) + # On Windows, uvicorn's graceful shutdown frequently hangs on Ctrl+C (the + # asyncio Proactor loop doesn't wind down cleanly), leaving the terminal + # stuck. Take over signal handling: stop hard and reap any child + # subprocesses (training/recording/inference) so the prompt always returns. + server.install_signal_handlers = lambda: None + + def _shutdown(_signum, _frame) -> None: + logger.info("Shutting down LeLab...") + try: + for child in psutil.Process().children(recursive=True): + with contextlib.suppress(psutil.NoSuchProcess): + child.terminate() + except Exception: + pass + os._exit(0) + + signal.signal(signal.SIGINT, _shutdown) + for _name in ("SIGTERM", "SIGBREAK"): + _sig = getattr(signal, _name, None) + if _sig is not None: + with contextlib.suppress(ValueError, OSError): + signal.signal(_sig, _shutdown) + + server.run() + + +def _run_dev(*, no_open: bool = False) -> None: + """Start Vite HMR plus uvicorn reload.""" + _ensure_frontend_path() + _require_command("node") + _require_command("npm") + _ensure_port_available("Backend", BACKEND_PORT) + _ensure_port_available("Frontend", FRONTEND_DEV_PORT) + _ensure_frontend_deps() + + processes: list[tuple[str, subprocess.Popen]] = [] + frontend_url = f"http://localhost:{FRONTEND_DEV_PORT}/?api=http://localhost:{BACKEND_PORT}" + + try: + frontend_process = _start_process( + "frontend", + ["npm", "run", "dev", "--", "--host", HOST, "--port", str(FRONTEND_DEV_PORT)], + FRONTEND_PATH, + ) + processes.append(("frontend", frontend_process)) + if not _wait_for_port(FRONTEND_DEV_PORT): + if frontend_process.poll() is not None: + _fail(f"Frontend exited early with code {frontend_process.returncode}. Check the Vite output above.") + _fail(f"Frontend never became ready on http://localhost:{FRONTEND_DEV_PORT}.") + + backend_process = _start_process( + "backend", + [ + sys.executable, + "-m", + "uvicorn", + "lelab.server:app", + "--host", + HOST, + "--port", + str(BACKEND_PORT), + "--reload", + ], + PROJECT_ROOT, + env=os.environ.copy(), + ) + processes.append(("backend", backend_process)) + if not _wait_for_port(BACKEND_PORT, timeout=15): + if backend_process.poll() is not None: + _fail(f"Backend exited early with code {backend_process.returncode}. Check the uvicorn output above.") + _fail(f"Backend never became ready on http://localhost:{BACKEND_PORT}.") + + _open_browser_url(frontend_url, no_open=no_open) + logger.info("Dev mode running. Press Ctrl+C to stop.") + logger.info("Frontend: http://localhost:%d", FRONTEND_DEV_PORT) + logger.info("Backend: http://localhost:%d", BACKEND_PORT) + _install_signal_handlers(processes) + _monitor_processes(processes) + except BaseException: + if processes: + _shutdown_processes(processes) + raise + + +def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="lelab", description="Run LeLab") parser.add_argument( "--dev", action="store_true", - help="Dev mode: Vite HMR + uvicorn --reload (requires Node.js)", + help="Start Vite hot reload on :8080 plus uvicorn --reload on :8000.", + ) + parser.add_argument( + "--rebuild", + action="store_true", + help="Rebuild frontend/dist before starting production mode.", + ) + parser.add_argument( + "--no-open", + action="store_true", + help="Do not open a browser automatically.", ) - args = parser.parse_args() + parser.add_argument( + "--stop", + action="store_true", + help="Stop a running LeLab (free ports 8000/8080) and exit.", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> None: + parser = _build_parser() + args = parser.parse_args(argv) + + if args.stop: + _run_stop() + return + + if args.dev and args.rebuild: + parser.error("--rebuild is for production mode; dev mode serves from Vite.") if args.dev: - _run_dev() + _run_dev(no_open=args.no_open) else: - _run_prod() + _run_prod(no_open=args.no_open, rebuild=args.rebuild) if __name__ == "__main__": diff --git a/tests/test_scripts_lelab.py b/tests/test_scripts_lelab.py index 7e05b0e..b5f1213 100644 --- a/tests/test_scripts_lelab.py +++ b/tests/test_scripts_lelab.py @@ -11,22 +11,68 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for lelab.scripts.lelab — covers `_wait_for_port`. The launcher's -`_run_prod` / `_run_dev` / `main` functions are CLI/process glue (they call -uvicorn.run, spawn npm, install SIGINT handlers) and have no unit-testable -seam without rewriting them; they are left to manual smoke testing.""" +"""Tests for the LeLab launcher. + +The launcher owns local process startup, so these tests mock child processes +instead of running Vite or uvicorn. +""" from __future__ import annotations import socket import threading +from pathlib import Path +from unittest.mock import MagicMock import pytest +class FakeProcess: + def __init__(self, returncode: int | None = None) -> None: + self.pid = 1234 + self.returncode = returncode + self.signals: list[int] = [] + self.terminated = False + self.killed = False + self.wait_calls: list[int] = [] + + def poll(self) -> int | None: + return self.returncode + + def send_signal(self, sig: int) -> None: + self.signals.append(sig) + self.returncode = 0 + + def terminate(self) -> None: + self.terminated = True + self.returncode = 0 + + def kill(self) -> None: + self.killed = True + self.returncode = -9 + + def wait(self, timeout: int | None = None) -> int | None: + if timeout is not None: + self.wait_calls.append(timeout) + return self.returncode + + +class _ImmediateThread: + """Runs the target synchronously on .start() so launcher threads are testable. + + Accepts the same kwargs the launcher passes to threading.Thread (including + ``name=``) so it can stand in for it without arg-mismatch errors. + """ + + def __init__(self, target, args=(), daemon=False, name=None) -> None: + self._target = target + self._args = args + + def start(self) -> None: + self._target(*self._args) + + def _bind_listener() -> tuple[socket.socket, int]: - """Bind a real TCP listener on an ephemeral localhost port. Returns the - socket (caller must close) and its actual port number.""" server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(("127.0.0.1", 0)) server.listen(1) @@ -46,12 +92,9 @@ def test_wait_for_port_returns_true_when_port_is_open() -> None: def test_wait_for_port_returns_false_when_port_never_opens( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Patch sleep so we don't actually block for `timeout` seconds — the - function's whole loop body is fast otherwise.""" from lelab.scripts.lelab import _wait_for_port monkeypatch.setattr("lelab.scripts.lelab.time.sleep", lambda _s: None) - # Pick an ephemeral port from the OS, then close it so it's not bound. probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) probe.bind(("127.0.0.1", 0)) port = probe.getsockname()[1] @@ -63,15 +106,12 @@ def test_wait_for_port_returns_false_when_port_never_opens( def test_wait_for_port_returns_true_immediately_for_already_open_port( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Sanity check that the success path doesn't sleep at all — guards - against accidentally adding a leading delay.""" from lelab.scripts.lelab import _wait_for_port sleep_calls = [] monkeypatch.setattr("lelab.scripts.lelab.time.sleep", lambda s: sleep_calls.append(s)) server, port = _bind_listener() - # Drain any incoming connection so the listener stays healthy. accept_thread = threading.Thread(target=lambda: server.accept() if server else None, daemon=True) accept_thread.start() @@ -80,3 +120,319 @@ def test_wait_for_port_returns_true_immediately_for_already_open_port( assert sleep_calls == [] finally: server.close() + + +def test_ensure_port_available_exits_with_actionable_message( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + import lelab.scripts.lelab as launcher + + monkeypatch.setattr(launcher, "_is_port_open", lambda _port, _host=launcher.HOST: True) + + with pytest.raises(SystemExit): + launcher._ensure_port_available("Backend", 8000) + + assert "Backend port 8000 is already in use" in caplog.text + assert "lelab --stop" in caplog.text + + +def test_frontend_install_is_skipped_when_node_modules_exists( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + frontend = tmp_path / "frontend" + node_modules = frontend / "node_modules" + node_modules.mkdir(parents=True) + run_checked = MagicMock() + + monkeypatch.setattr(launcher, "FRONTEND_PATH", frontend) + monkeypatch.setattr(launcher, "FRONTEND_NODE_MODULES", node_modules) + monkeypatch.setattr(launcher, "_require_command", lambda _command: _command) + monkeypatch.setattr(launcher, "_run_checked", run_checked) + + launcher._ensure_frontend_deps() + + run_checked.assert_not_called() + + +def test_frontend_install_runs_when_node_modules_is_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + frontend = tmp_path / "frontend" + frontend.mkdir() + node_modules = frontend / "node_modules" + run_checked = MagicMock() + + monkeypatch.setattr(launcher, "FRONTEND_PATH", frontend) + monkeypatch.setattr(launcher, "FRONTEND_NODE_MODULES", node_modules) + monkeypatch.setattr(launcher, "_require_command", lambda _command: _command) + monkeypatch.setattr(launcher, "_run_checked", run_checked) + + launcher._ensure_frontend_deps() + + run_checked.assert_called_once_with( + ["npm", "install"], + frontend, + "Run `cd frontend && npm install` to inspect the npm error.", + ) + + +def test_browser_can_be_suppressed(monkeypatch: pytest.MonkeyPatch) -> None: + import lelab.scripts.lelab as launcher + + browser_open = MagicMock() + monkeypatch.setattr(launcher.webbrowser, "open", browser_open) + + launcher._open_browser_url("http://localhost:8000/", no_open=True) + + browser_open.assert_not_called() + + +def test_run_prod_no_open_reaches_uvicorn_without_browser( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("") + browser_open = MagicMock() + server = MagicMock() + captured: dict[str, object] = {} + + def fake_config(_app, **kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr(launcher, "FRONTEND_DIST", dist) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(launcher.uvicorn, "Config", fake_config) + monkeypatch.setattr(launcher.uvicorn, "Server", lambda _config: server) + monkeypatch.setattr(launcher.signal, "signal", lambda *_a, **_k: None) + monkeypatch.setattr(launcher.webbrowser, "open", browser_open) + + launcher._run_prod(no_open=True) + + server.run.assert_called_once() + assert captured["host"] == "127.0.0.1" + assert captured["port"] == 8000 + browser_open.assert_not_called() + + +def test_run_prod_rebuilds_before_starting_uvicorn( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("") + order: list[str] = [] + server = MagicMock() + server.run.side_effect = lambda: order.append("uvicorn") + + monkeypatch.setattr(launcher, "FRONTEND_DIST", dist) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher, "_run_frontend_build", lambda: order.append("build")) + monkeypatch.setattr(launcher.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(launcher.uvicorn, "Config", lambda *_a, **_k: MagicMock()) + monkeypatch.setattr(launcher.uvicorn, "Server", lambda _config: server) + monkeypatch.setattr(launcher.signal, "signal", lambda *_a, **_k: None) + + launcher._run_prod(no_open=True, rebuild=True) + + assert order == ["build", "uvicorn"] + + +def test_dev_launcher_builds_expected_subprocess_commands( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + frontend = tmp_path / "frontend" + frontend.mkdir() + started: list[tuple[str, list[str], Path]] = [] + browser_open = MagicMock() + + def fake_start_process(name, command, cwd, env=None): + started.append((name, list(command), cwd)) + return FakeProcess() + + def stop_after_start(_processes): + raise SystemExit(0) + + monkeypatch.setattr(launcher, "FRONTEND_PATH", frontend) + monkeypatch.setattr(launcher, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(launcher, "_require_command", lambda _command: _command) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher, "_ensure_frontend_deps", lambda: None) + monkeypatch.setattr(launcher, "_wait_for_port", lambda _port, timeout=30: True) + monkeypatch.setattr(launcher, "_start_process", fake_start_process) + monkeypatch.setattr(launcher, "_install_signal_handlers", lambda _processes: None) + monkeypatch.setattr(launcher, "_monitor_processes", stop_after_start) + monkeypatch.setattr(launcher.webbrowser, "open", browser_open) + + with pytest.raises(SystemExit) as exc: + launcher._run_dev(no_open=True) + + assert exc.value.code == 0 + assert started[0] == ( + "frontend", + ["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", "8080"], + frontend, + ) + assert started[1][0] == "backend" + assert started[1][1][:4] == [launcher.sys.executable, "-m", "uvicorn", "lelab.server:app"] + assert "--reload" in started[1][1] + assert started[1][2] == tmp_path + browser_open.assert_not_called() + + +def test_child_process_kwargs_use_windows_creation_group( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + monkeypatch.setattr(launcher.os, "name", "nt") + monkeypatch.setattr(launcher.subprocess, "CREATE_NEW_PROCESS_GROUP", 512, raising=False) + + kwargs = launcher._child_process_kwargs(tmp_path) + + assert kwargs["creationflags"] == 512 + assert "start_new_session" not in kwargs + + +def test_stop_process_terminates_tree_without_unix_process_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import lelab.scripts.lelab as launcher + + fake = FakeProcess() # poll() -> None, so it is treated as still running + killpg = MagicMock() + terminated: list[int] = [] + + class _FakeProc: + def __init__(self, pid: int) -> None: + self.pid = pid + + def children(self, recursive: bool = False) -> list: + return [] + + def terminate(self) -> None: + terminated.append(self.pid) + + def kill(self) -> None: # pragma: no cover - alive list is empty here + terminated.append(-self.pid) + + monkeypatch.setattr(launcher.os, "killpg", killpg, raising=False) + monkeypatch.setattr(launcher.psutil, "Process", lambda pid: _FakeProc(pid)) + monkeypatch.setattr(launcher.psutil, "wait_procs", lambda procs, timeout=None: ([], [])) + + launcher._stop_process("frontend", fake, timeout=5) + + # The whole process tree is terminated via psutil (cross-platform), not a + # Unix process group and not a Ctrl-Break signal to the child handle. + assert terminated == [fake.pid] + assert fake.signals == [] + assert fake.wait_calls == [5] + killpg.assert_not_called() + + +def test_missing_command_reports_install_hint( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + import lelab.scripts.lelab as launcher + + monkeypatch.setattr(launcher.shutil, "which", lambda _command: None) + + with pytest.raises(SystemExit): + launcher._require_command("npm") + + assert "`npm` was not found on PATH" in caplog.text + assert "Install Node.js LTS" in caplog.text + + +def test_resolve_command_prefers_cmd_shim_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import lelab.scripts.lelab as launcher + + def fake_which(command: str) -> str | None: + if command == "npm.cmd": + return r"C:\Program Files\nodejs\npm.cmd" + if command == "npm": + return r"C:\Program Files\nodejs\npm" + return None + + monkeypatch.setattr(launcher.os, "name", "nt") + monkeypatch.setattr(launcher.shutil, "which", fake_which) + + assert launcher._resolve_command("npm") == r"C:\Program Files\nodejs\npm.cmd" + + +def test_start_process_uses_resolved_executable( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + popen = MagicMock(return_value=FakeProcess()) + monkeypatch.setattr(launcher, "_resolve_command", lambda _command: r"C:\Program Files\nodejs\npm.cmd") + monkeypatch.setattr(launcher.subprocess, "Popen", popen) + + launcher._start_process("frontend", ["npm", "run", "dev"], tmp_path) + + popen.assert_called_once() + assert popen.call_args.args[0] == [r"C:\Program Files\nodejs\npm.cmd", "run", "dev"] + + +def test_missing_frontend_dist_reports_rebuild_hint( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + import lelab.scripts.lelab as launcher + + monkeypatch.setattr(launcher, "FRONTEND_DIST", tmp_path / "dist") + + with pytest.raises(SystemExit): + launcher._ensure_frontend_dist() + + assert "Built frontend not found" in caplog.text + assert "Run `lelab --rebuild`" in caplog.text + + +def test_dev_launcher_reports_frontend_exit_before_ready( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + import lelab.scripts.lelab as launcher + + frontend = tmp_path / "frontend" + frontend.mkdir() + + monkeypatch.setattr(launcher, "FRONTEND_PATH", frontend) + monkeypatch.setattr(launcher, "_require_command", lambda _command: _command) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher, "_ensure_frontend_deps", lambda: None) + monkeypatch.setattr(launcher, "_wait_for_port", lambda _port, timeout=30: False) + monkeypatch.setattr(launcher, "_start_process", lambda *_args, **_kwargs: FakeProcess(returncode=7)) + + with pytest.raises(SystemExit): + launcher._run_dev(no_open=True) + + assert "Frontend exited early with code 7" in caplog.text + assert "Check the Vite output above" in caplog.text