diff --git a/bec_widgets/applications/companion_app.py b/bec_widgets/applications/companion_app.py index 2b4de2fb..977f4c09 100644 --- a/bec_widgets/applications/companion_app.py +++ b/bec_widgets/applications/companion_app.py @@ -1,5 +1,11 @@ from __future__ import annotations +# isort: off +# startup_profiler must load before the heavy third-party imports below so its origin +# timestamp captures this process's real import cost (streamed as the "module imports" +# stage to the launcher banner). The fence keeps isort from reordering it downward. +from bec_widgets.applications.startup_profiler import startup_profiler + import argparse import json import os @@ -14,7 +20,7 @@ from bec_lib.logger import bec_logger from bec_lib.service_config import ServiceConfig from bec_qthemes import apply_theme from qtmonaco.pylsp_provider import pylsp_server -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication @@ -23,10 +29,14 @@ from bec_widgets.applications.launch_window import LaunchWindow from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.rpc_register import RPCRegister +# isort: on + logger = bec_logger.logger MODULE_PATH = os.path.dirname(bec_widgets.__file__) +startup_profiler.mark("module imports") + class SimpleFileLikeFromLogOutputFunc: def __init__(self, log_func): @@ -99,10 +109,12 @@ class GUIServer: """ logger.info("Starting GUIServer", repr(self)) self.app = QApplication(sys.argv) + startup_profiler.mark("QApplication") if darkdetect.isDark(): apply_theme("dark") else: apply_theme("light") + startup_profiler.mark("theme applied") self.app.setApplicationName("BEC") self.app.gui_id = self.gui_id # type: ignore @@ -111,6 +123,8 @@ class GUIServer: service_config = self._get_service_config() self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id) + # Dominant cold-start cost when Redis is remote. + startup_profiler.mark("BEC connection") if self.gui_class: self.launcher_window = LaunchWindow( @@ -121,6 +135,7 @@ class GUIServer: else: self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher") self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore + startup_profiler.mark("launch window built") self.app.aboutToQuit.connect(self.shutdown) self.app.setQuitOnLastWindowClosed(True) @@ -128,8 +143,24 @@ class GUIServer: signal.signal(signal.SIGINT, self.request_shutdown) signal.signal(signal.SIGTERM, self.request_shutdown) + # First event-loop iteration -> the server is up and interactive. + QTimer.singleShot(0, self._notify_server_ready) + sys.exit(self.app.exec()) + def _notify_server_ready(self): + """Mark the final startup stage and resolve the launcher's loading banner. + + This is a safety net so the banner resolves even when no ``gui_class`` window + is auto-launched; :meth:`LaunchWindow.launch` also notifies for a launched + window, and a duplicate ready edge is harmless (the launcher finalises once). + """ + # pylint: disable=import-outside-toplevel + from bec_widgets.utils.launcher_ready import notify_launcher_ready + + startup_profiler.mark("interactive", final=True) + notify_launcher_ready("bec-gui-server", self.launcher_window) + def setup_bec_icon(self): """ Set the BEC icon for the application diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 6bf090e0..1858a6af 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -484,14 +484,24 @@ class LaunchWindow(BECMainWindow): if isinstance(result_widget, BECMainWindow): apply_window_geometry(result_widget, geometry) result_widget.show() + self._notify_launcher_ready(result_widget) else: window = BECMainWindowNoRPC() window.setCentralWidget(result_widget) window.setWindowTitle(f"BEC - {result_widget.objectName()}") apply_window_geometry(window, geometry) window.show() + self._notify_launcher_ready(window) return result_widget + @staticmethod + def _notify_launcher_ready(window: QWidget) -> None: + from qtpy.QtCore import QTimer + + from bec_widgets.utils.launcher_ready import notify_launcher_ready + + QTimer.singleShot(0, lambda: notify_launcher_ready("bec-gui-server", window)) + def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow: """ Load a custom .ui file. If the top-level widget is a MainWindow subclass, diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 3c8c2129..0b2fc9ce 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -1,6 +1,7 @@ from bec_widgets.applications.startup_profiler import startup_profiler # isort: skip from bec_qthemes import material_icon +from qtpy.QtCore import QTimer from qtpy.QtGui import QAction # type: ignore from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget @@ -14,6 +15,7 @@ from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAre from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.guided_tour import GuidedTour +from bec_widgets.utils.launcher_ready import notify_launcher_ready from bec_widgets.utils.name_utils import sanitize_namespace from bec_widgets.utils.screen_utils import ( apply_centered_size, @@ -41,6 +43,7 @@ class BECMainApp(BECMainWindow): super().__init__(parent=parent, *args, **kwargs) startup_profiler.mark("BEC connection + base window") self._show_examples = bool(show_examples) + self._launcher_ready_notified = False # --- Compose central UI (sidebar + stack) self.sidebar = SideBar(parent=self, anim_duration=anim_duration) @@ -67,6 +70,13 @@ class BECMainApp(BECMainWindow): self._setup_guided_tour() startup_profiler.mark("guided tour") + def showEvent(self, event): + super().showEvent(event) + if self._launcher_ready_notified: + return + self._launcher_ready_notified = True + QTimer.singleShot(0, lambda: notify_launcher_ready("bec-app", self)) + def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.dock_area = DockAreaView(self) diff --git a/bec_widgets/applications/startup_profiler.py b/bec_widgets/applications/startup_profiler.py index d2ebedde..e160d374 100644 --- a/bec_widgets/applications/startup_profiler.py +++ b/bec_widgets/applications/startup_profiler.py @@ -38,6 +38,13 @@ try: except Exception: # pragma: no cover - logging must never break startup _logger = None +try: + # Stdlib-only client: streams the marks below to bec_launcher's loading banner + # when this process was started by the launcher. A no-op otherwise. + from bec_widgets.utils.launch_progress import launch_progress +except Exception: # pragma: no cover - progress streaming must never break startup + launch_progress = None + def _emit(msg: str) -> None: if _logger is not None: @@ -76,6 +83,9 @@ class StartupProfiler: _emit(f"[startup] {stage:<26} +{delta:6.2f}s (total {total:6.2f}s)") if final: _emit(f"[startup] ---- bec-app interactive after {total:.2f}s ----") + if launch_progress is not None: + # Best-effort; the client swallows all socket errors internally. + launch_progress.emit_stage(stage, delta * 1000.0, total * 1000.0) return total diff --git a/bec_widgets/utils/launch_progress.py b/bec_widgets/utils/launch_progress.py new file mode 100644 index 00000000..a1919a8c --- /dev/null +++ b/bec_widgets/utils/launch_progress.py @@ -0,0 +1,137 @@ +"""Client side of the BEC launch-progress handshake. + +When ``bec_launcher`` starts a GUI it opens a per-launch AF_UNIX socket and passes +its path plus a one-shot token to the child through the environment. The child +streams startup-stage updates and a final ``ready`` edge back over that socket so +the launcher can show a live loading banner instead of a blind spinner. + +The whole thing is best-effort and must *never* interfere with startup: + +* if the environment variables are absent (app started outside the launcher) every + call is a cheap no-op returning ``False``; +* any socket error disables the client for the rest of the process and is swallowed. + +The module deliberately depends only on the standard library (no Qt, no bec_lib +heavy imports) because it is imported by :mod:`startup_profiler` *before* the +QApplication and the heavy widget imports exist. +""" + +from __future__ import annotations + +import json +import os +import socket +import sys +import threading + +SOCKET_ENV = "BEC_LAUNCH_PROGRESS_SOCKET" +TOKEN_ENV = "BEC_LAUNCH_PROGRESS_TOKEN" +APP_ENV = "BEC_LAUNCH_APP" + +_CONNECT_TIMEOUT_S = 0.75 +_SEND_TIMEOUT_S = 0.75 + + +class LaunchProgressClient: + """Best-effort AF_UNIX client that streams startup stages to the launcher.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sock: socket.socket | None = None + # Tri-state: None = not yet attempted, True = live, False = disabled. + self._live: bool | None = None + self._hello_sent = False + self._path = os.environ.get(SOCKET_ENV) or "" + self._token = os.environ.get(TOKEN_ENV) or "" + + # -- public API --------------------------------------------------------- + @property + def enabled(self) -> bool: + """True when the launcher provided a socket + token for this launch.""" + return bool(self._path and self._token and self._live is not False) + + def emit_stage(self, name: str, delta_ms: float, total_ms: float) -> bool: + return self._send( + { + "t": "stage", + "name": name, + "delta_ms": round(float(delta_ms), 1), + "total_ms": round(float(total_ms), 1), + } + ) + + def emit_ready(self, total_ms: float | None = None) -> bool: + payload: dict[str, object] = {"t": "ready"} + if total_ms is not None: + payload["total_ms"] = round(float(total_ms), 1) + return self._send(payload) + + def emit_error(self, message: str) -> bool: + return self._send({"t": "error", "msg": str(message)}) + + def close(self) -> None: + with self._lock: + self._disconnect_locked() + self._live = False + + # -- internals ---------------------------------------------------------- + def _send(self, payload: dict[str, object]) -> bool: + if not (self._path and self._token): + return False + with self._lock: + if self._live is False: + return False + if self._sock is None and not self._connect_locked(): + return False + line = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") + try: + self._sock.sendall(line) # type: ignore[union-attr] + return True + except OSError: + self._disconnect_locked() + self._live = False + return False + + def _connect_locked(self) -> bool: + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(_CONNECT_TIMEOUT_S) + sock.connect(self._path) + sock.settimeout(_SEND_TIMEOUT_S) + except OSError: + self._live = False + return False + self._sock = sock + self._live = True + if not self._hello_sent: + hello = { + "t": "hello", + "token": self._token, + "app": os.environ.get(APP_ENV) or _default_app_name(), + "pid": os.getpid(), + } + try: + sock.sendall((json.dumps(hello, separators=(",", ":")) + "\n").encode("utf-8")) + self._hello_sent = True + except OSError: + self._disconnect_locked() + self._live = False + return False + return True + + def _disconnect_locked(self) -> None: + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + +def _default_app_name() -> str: + argv0 = sys.argv[0] if sys.argv else "" + return os.path.basename(argv0) or "bec" + + +# Process-wide singleton; env is read once at import time. +launch_progress = LaunchProgressClient() diff --git a/bec_widgets/utils/launcher_ready.py b/bec_widgets/utils/launcher_ready.py new file mode 100644 index 00000000..767b1186 --- /dev/null +++ b/bec_widgets/utils/launcher_ready.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + +from bec_lib.logger import bec_logger + +from bec_widgets.utils.launch_progress import launch_progress + +logger = bec_logger.logger + + +def notify_launcher_ready(app_name: str, window: Any | None = None) -> bool: + """ + Notify bec_launcher that the GUI window for this launch is visible. + + Sends the final ``ready`` edge over the per-launch progress socket that + bec_launcher opened for this process (see + :mod:`bec_widgets.utils.launch_progress`). This resolves the launcher's + loading banner and lets it close itself. + + The call is intentionally a no-op returning ``False`` unless bec_launcher + provided the socket + token through the environment, and it never raises: + all socket errors are swallowed by the client. + + Args: + app_name(str): The launched app identifier (informational). + window(Any | None): The visible top-level window, if available (informational). + + Returns: + bool: True if the ready edge was delivered to the launcher, else False. + """ + if launch_progress is None or not launch_progress.enabled: + return False + delivered = launch_progress.emit_ready() + if not delivered: + logger.debug(f"Launcher ready edge not delivered for '{app_name}'.") + return delivered diff --git a/tests/unit_tests/test_companion_app.py b/tests/unit_tests/test_companion_app.py new file mode 100644 index 00000000..cfa42752 --- /dev/null +++ b/tests/unit_tests/test_companion_app.py @@ -0,0 +1,36 @@ +"""Unit tests for the GUI-server startup handshake wiring in companion_app.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from bec_widgets.applications import companion_app + + +def _server(**overrides): + args = SimpleNamespace( + config=None, id="test", gui_class=None, gui_class_id="bec", hide=False, **overrides + ) + return companion_app.GUIServer(args) + + +def test_notify_server_ready_resolves_launcher(monkeypatch): + calls = [] + monkeypatch.setattr( + "bec_widgets.utils.launcher_ready.notify_launcher_ready", + lambda app_name, window: calls.append((app_name, window)) or True, + ) + marks = [] + monkeypatch.setattr( + companion_app.startup_profiler, "mark", lambda stage, **kw: marks.append((stage, kw)) + ) + + server = _server() + sentinel_window = object() + server.launcher_window = sentinel_window + server._notify_server_ready() + + # The GUI-server path sends the ready edge itself (safety net for launches with no + # auto-launched gui_class window) and records the final startup stage. + assert calls == [("bec-gui-server", sentinel_window)] + assert ("interactive", {"final": True}) in marks diff --git a/tests/unit_tests/test_launch_progress.py b/tests/unit_tests/test_launch_progress.py new file mode 100644 index 00000000..5e9bde17 --- /dev/null +++ b/tests/unit_tests/test_launch_progress.py @@ -0,0 +1,128 @@ +"""Unit tests for the launch-progress socket client (child side).""" + +from __future__ import annotations + +import json +import os +import socket + +import pytest + +from bec_widgets.utils import launch_progress as lp + + +def _short_socket_path(suffix: str = "") -> str: + # AF_UNIX paths are length-limited (~104 on macOS); keep it short and under /tmp. + return f"/tmp/bec-lp-{os.getpid()}-{suffix or 'x'}.sock" + + +class _Server: + """Minimal single-connection AF_UNIX server for assertions.""" + + def __init__(self, path: str): + self.path = path + self._srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._srv.settimeout(2.0) + self._srv.bind(path) + self._srv.listen(1) + self._conn: socket.socket | None = None + + def read_lines(self) -> list[dict]: + if self._conn is None: + self._conn, _ = self._srv.accept() + self._conn.settimeout(2.0) + chunks = b"" + while True: + try: + data = self._conn.recv(65536) + except socket.timeout: + break + if not data: + break + chunks += data + # Heuristic: stop once we have drained what's buffered. + if len(data) < 65536: + break + return [json.loads(line) for line in chunks.decode().splitlines() if line.strip()] + + def close(self) -> None: + for sock in (self._conn, self._srv): + if sock is not None: + try: + sock.close() + except OSError: + pass + try: + os.unlink(self.path) + except OSError: + pass + + +@pytest.fixture +def server(): + srv = _Server(_short_socket_path("srv")) + try: + yield srv + finally: + srv.close() + + +def _client_env(monkeypatch, path: str, token: str = "tok-123", app: str = "bec-app"): + monkeypatch.setenv(lp.SOCKET_ENV, path) + monkeypatch.setenv(lp.TOKEN_ENV, token) + monkeypatch.setenv(lp.APP_ENV, app) + + +def test_client_is_noop_without_env(monkeypatch): + monkeypatch.delenv(lp.SOCKET_ENV, raising=False) + monkeypatch.delenv(lp.TOKEN_ENV, raising=False) + client = lp.LaunchProgressClient() + assert client.enabled is False + assert client.emit_stage("module imports", 10, 10) is False + assert client.emit_ready() is False + + +def test_client_streams_hello_stage_and_ready(monkeypatch, server): + _client_env(monkeypatch, server.path, token="tok-abc", app="bec-app") + client = lp.LaunchProgressClient() + assert client.enabled is True + + assert client.emit_stage("module imports", 6210.4, 6210.4) is True + assert client.emit_stage("BEC connection", 18400.0, 24610.4) is True + assert client.emit_ready(27710.0) is True + + messages = server.read_lines() + assert messages[0]["t"] == "hello" + assert messages[0]["token"] == "tok-abc" + assert messages[0]["app"] == "bec-app" + assert messages[0]["pid"] == os.getpid() + + stages = [m for m in messages if m["t"] == "stage"] + assert [s["name"] for s in stages] == ["module imports", "BEC connection"] + assert stages[0]["delta_ms"] == 6210.4 + assert stages[0]["total_ms"] == 6210.4 + + ready = [m for m in messages if m["t"] == "ready"] + assert ready and ready[0]["total_ms"] == 27710.0 + + +def test_client_never_raises_on_bad_socket(monkeypatch): + _client_env(monkeypatch, _short_socket_path("nonexistent")) + client = lp.LaunchProgressClient() + # No server is listening at the path -> connect fails, but nothing raises. + assert client.emit_stage("module imports", 1, 1) is False + assert client.enabled is False # disabled after the failed connect + assert client.emit_ready() is False + + +def test_client_disables_after_server_disconnect(monkeypatch, server): + _client_env(monkeypatch, server.path) + client = lp.LaunchProgressClient() + assert client.emit_stage("first", 1, 1) is True + # Force the server to drop the connection. + server.read_lines() + server.close() + # Subsequent sends eventually fail and disable the client without raising. + for _ in range(5): + client.emit_stage("later", 2, 2) + assert client.emit_ready() is False diff --git a/tests/unit_tests/test_launch_window.py b/tests/unit_tests/test_launch_window.py index edefb0c7..21bbd7bc 100644 --- a/tests/unit_tests/test_launch_window.py +++ b/tests/unit_tests/test_launch_window.py @@ -131,6 +131,22 @@ def test_open_dock_area_with_start_empty_option_calls_launch(bec_launch_window): mock_launch.assert_called_once_with("dock_area", startup_profile=None) +def test_launch_window_notifies_launcher_ready(qtbot, bec_launch_window, monkeypatch): + calls = [] + monkeypatch.setattr( + "bec_widgets.utils.launcher_ready.notify_launcher_ready", + lambda app_name, window: calls.append((app_name, window)) or True, + ) + + window = QWidget() + qtbot.addWidget(window) + window.show() + bec_launch_window._notify_launcher_ready(window) + qtbot.wait(10) + + assert calls == [("bec-gui-server", window)] + + @pytest.mark.parametrize( "connection_names, hide", [ diff --git a/tests/unit_tests/test_launcher_ready.py b/tests/unit_tests/test_launcher_ready.py new file mode 100644 index 00000000..04d9fd0d --- /dev/null +++ b/tests/unit_tests/test_launcher_ready.py @@ -0,0 +1,50 @@ +"""Unit tests for ``notify_launcher_ready`` (routes the ready edge over the socket).""" + +from __future__ import annotations + +import os +import socket + +from bec_widgets.utils import launcher_ready +from bec_widgets.utils.launch_progress import LaunchProgressClient + + +def _short_socket_path() -> str: + return f"/tmp/bec-ready-{os.getpid()}.sock" + + +def test_notify_launcher_ready_noops_without_env(monkeypatch): + monkeypatch.delenv("BEC_LAUNCH_PROGRESS_SOCKET", raising=False) + monkeypatch.delenv("BEC_LAUNCH_PROGRESS_TOKEN", raising=False) + # A freshly-built client with no configured socket is disabled -> notify no-ops. + monkeypatch.setattr(launcher_ready, "launch_progress", LaunchProgressClient()) + + assert launcher_ready.notify_launcher_ready("bec-app") is False + + +def test_notify_launcher_ready_sends_ready(monkeypatch): + path = _short_socket_path() + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.settimeout(2.0) + srv.bind(path) + srv.listen(1) + try: + monkeypatch.setenv("BEC_LAUNCH_PROGRESS_SOCKET", path) + monkeypatch.setenv("BEC_LAUNCH_PROGRESS_TOKEN", "tok-xyz") + monkeypatch.setattr(launcher_ready, "launch_progress", LaunchProgressClient()) + + assert launcher_ready.notify_launcher_ready("bec-app") is True + + conn, _ = srv.accept() + conn.settimeout(2.0) + data = conn.recv(65536).decode() + conn.close() + assert '"t":"hello"' in data + assert '"token":"tok-xyz"' in data + assert '"t":"ready"' in data + finally: + srv.close() + try: + os.unlink(path) + except OSError: + pass diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py index 2e28d95a..21a7b475 100644 --- a/tests/unit_tests/test_main_app.py +++ b/tests/unit_tests/test_main_app.py @@ -77,6 +77,22 @@ def test_viewbase_initializes(viewbase): assert viewbase.on_exit() is True +def test_main_app_notifies_launcher_ready_after_show(qtbot, mocked_client, monkeypatch): + calls = [] + monkeypatch.setattr( + "bec_widgets.applications.main_app.notify_launcher_ready", + lambda app_name, window: calls.append((app_name, window)) or True, + ) + + app = BECMainApp(client=mocked_client, anim_duration=ANIM_TEST_DURATION, show_examples=False) + qtbot.addWidget(app) + app.show() + qtbot.waitExposed(app) + qtbot.wait(10) + + assert calls == [("bec-app", app)] + + def test_on_enter_and_on_exit_are_called_on_switch(app_with_spies, qtbot): app, v1, v2, _ = app_with_spies