From ee360adf3ae4fc4af75b09358f13f443af471432 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 23 Jun 2026 16:20:43 +0200 Subject: [PATCH] feat: add GUI display availability check for SSH sessions --- bec_widgets/cli/client_utils.py | 40 +++++++++++++++++++++++++++ tests/unit_tests/test_client_utils.py | 33 ++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 6733d4b7..a00e64ed 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -7,6 +7,7 @@ import os import select import signal import subprocess +import sys import threading import time from contextlib import contextmanager @@ -60,6 +61,40 @@ def _filter_output(output: str) -> str: return output +def check_gui_display_available() -> tuple[bool, str | None]: + """ + Check whether the current environment can launch the GUI. + + Returns: + tuple[bool, str | None]: + - ``True, None`` when a graphical display is available. + - ``False, `` when GUI startup should be blocked with a helpful message. + """ + if os.name != "posix" or sys.platform == "darwin": + return True, None + + if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"): + return True, None + + if ( + os.environ.get("SSH_CONNECTION") + or os.environ.get("SSH_CLIENT") + or os.environ.get("SSH_TTY") + ): + return ( + False, + "Cannot start BEC GUI: no graphical display was detected for this SSH session. " + "If you want to launch widgets remotely, reconnect with X11 forwarding enabled " + "(for example `ssh -X` or `ssh -Y`) or start the GUI from a local graphical session.", + ) + + return ( + False, + "Cannot start BEC GUI: no graphical display was detected. " + "Set `DISPLAY` or `WAYLAND_DISPLAY`, or start the GUI from a graphical session.", + ) + + def _get_output(process, logger, stop_event: threading.Event | None = None) -> None: log_func = {process.stdout: logger.debug, process.stderr: logger.info} stream_buffer = {process.stdout: [], process.stderr: []} @@ -655,6 +690,11 @@ class BECGuiClient(RPCBase): if self._gui_is_alive(): self._gui_started_event.set() return + gui_available, error_message = check_gui_display_available() + if not gui_available: + logger.error(error_message) + self._startup_timeout = 0 + return if self._process is None or self._process.poll() is not None: logger.success("GUI starting...") self._startup_timeout = 5 diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 5cf5750f..23200a25 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -12,6 +12,7 @@ from bec_widgets.cli.client_utils import ( BECGuiClient, _join_process_output_thread, _start_plot_process, + check_gui_display_available, ) from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCResponseTimeoutError, rpc_timeout @@ -97,6 +98,38 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher): ) +def test_check_gui_display_available_reports_missing_display_for_ssh_session(): + with mock.patch.dict( + "bec_widgets.cli.client_utils.os.environ", {"SSH_CONNECTION": "1"}, clear=True + ): + available, message = check_gui_display_available() + + assert available is False + assert message is not None + assert "SSH session" in message + assert "ssh -X" in message + + +def test_client_utils_start_server_returns_when_no_display(bec_dispatcher): + gui = BECGuiClient() + gui._client = bec_dispatcher.client + gui._gui_id = "gui_id" + gui._gui_is_alive = mock.MagicMock(return_value=False) + + with ( + mock.patch( + "bec_widgets.cli.client_utils.check_gui_display_available", + return_value=(False, "No display available"), + ), + mock.patch("bec_widgets.cli.client_utils._start_plot_process") as mock_start_plot, + mock.patch("bec_widgets.cli.client_utils.logger") as mock_logger, + ): + gui._start_server(wait=False) + + mock_start_plot.assert_not_called() + mock_logger.error.assert_called_once_with("No display available") + + @contextmanager def _no_wait_for_server(_client): yield