feat: stream startup stages to the launcher over a progress socket

This commit is contained in:
2026-08-25 13:45:27 +02:00
committed by wakonig_k
parent a00b0c40e3
commit d6e523eea4
11 changed files with 482 additions and 1 deletions
+32 -1
View File
@@ -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
+10
View File
@@ -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,
+10
View File
@@ -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)
@@ -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
+137
View File
@@ -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()
+37
View File
@@ -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
+36
View File
@@ -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
+128
View File
@@ -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
+16
View File
@@ -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",
[
+50
View File
@@ -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
+16
View File
@@ -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