From b8a5ffc803dba54c97b624ebf5763ceb4d2a7bbb Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Mon, 21 Sep 2026 13:48:20 +0200 Subject: [PATCH 1/2] attempt at fixing visa-related opengl issues --- EasyReflectometryApp/Backends/Py/helpers.py | 77 +++++++++++++++++++++ EasyReflectometryApp/main.py | 11 +++ tests/test_py_helpers.py | 44 ++++++++++++ 3 files changed, 132 insertions(+) diff --git a/EasyReflectometryApp/Backends/Py/helpers.py b/EasyReflectometryApp/Backends/Py/helpers.py index 104ce1cf..07483359 100644 --- a/EasyReflectometryApp/Backends/Py/helpers.py +++ b/EasyReflectometryApp/Backends/Py/helpers.py @@ -5,6 +5,10 @@ import numpy as np from PySide6.QtCore import Qt from PySide6.QtCore import QUrl +from PySide6.QtGui import QOffscreenSurface +from PySide6.QtGui import QOpenGLContext +from PySide6.QtQuick import QQuickWindow +from PySide6.QtQuick import QSGRendererInterface from PySide6.QtWidgets import QApplication from uncertainties import ufloat @@ -104,3 +108,76 @@ def __init__(self, sysArgv): # The attribute covers both QtQuick.Dialogs and QtWidgets.QFileDialog. if sys.platform.startswith('linux'): self.setAttribute(Qt.AA_DontUseNativeDialogs) + + +class Rendering: + """ + Chooses the Qt Quick scene graph backend on Linux. + + In remote-desktop sessions (VISA, xrdp, VNC, X2Go, ...) OpenGL is provided by + Mesa's software rasteriser and frames are presented through GLX. When the + remote client disconnects, the X server may stop completing buffer swaps, so + the next frame blocks the GUI thread inside glXSwapBuffers and the window + stays frozen ("not responding") after reconnecting. The software scene graph + backend paints through plain X11 image uploads and never waits on a swap. + """ + + ENV_VAR = 'EASYREFLECTOMETRY_SOFTWARE_RENDERING' # '1' forces it on, '0' forces it off + QT_BACKEND_ENV_VARS = ('QT_QUICK_BACKEND', 'QSG_RHI_BACKEND') + REMOTE_SESSION_ENV_VARS = ('XRDP_SESSION', 'VNCDESKTOP', 'X2GO_SESSION') + SOFTWARE_GL_RENDERERS = ('llvmpipe', 'softpipe', 'swrast', 'software rasterizer') + GL_RENDERER = 0x1F01 + + @staticmethod + def softwareRequested(environ, platform: str): + """ + Decide from the environment alone. + :return: True/False when decided, None when the OpenGL renderer has to be probed + """ + override = environ.get(Rendering.ENV_VAR, '').strip().lower() + if override in ('1', 'true', 'yes', 'on'): + return True + if override in ('0', 'false', 'no', 'off'): + return False + if not platform.startswith('linux'): + return False + if any(environ.get(name) for name in Rendering.QT_BACKEND_ENV_VARS): + return False # the user already picked a backend; leave it alone + if any(environ.get(name) for name in Rendering.REMOTE_SESSION_ENV_VARS): + return True + return None + + @staticmethod + def isSoftwareGlRenderer(rendererName: str) -> bool: + rendererName = rendererName.lower() + return any(name in rendererName for name in Rendering.SOFTWARE_GL_RENDERERS) + + @staticmethod + def openGlRendererName() -> str: + """Name of the OpenGL renderer Qt would use, or '' if it cannot be queried.""" + context = QOpenGLContext() + if not context.create(): + return '' + surface = QOffscreenSurface() + surface.setFormat(context.format()) + surface.create() + if not context.makeCurrent(surface): + return '' + try: + return context.functions().glGetString(Rendering.GL_RENDERER) or '' + finally: + context.doneCurrent() + + @staticmethod + def configure(forceSoftware: bool = False) -> bool: + """ + Must be called after the QApplication is created and before the first + QQuickWindow (i.e. before the QML engine loads the main component). + :return: True if the software backend was selected + """ + useSoftware = True if forceSoftware else Rendering.softwareRequested(os.environ, sys.platform) + if useSoftware is None: + useSoftware = Rendering.isSoftwareGlRenderer(Rendering.openGlRendererName()) + if useSoftware: + QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Software) + return useSoftware diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index 93562d9a..aa5c9154 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -29,11 +29,13 @@ try: # Running locally from Backends.Py import PyBackend from Backends.Py.helpers import Application + from Backends.Py.helpers import Rendering INSTALLER = False except ImportError: # Running from installer from EasyReflectometryApp.Backends.Py import PyBackend from EasyReflectometryApp.Backends.Py.helpers import Application + from EasyReflectometryApp.Backends.Py.helpers import Rendering INSTALLER = True @@ -46,6 +48,11 @@ if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('-t', '--testmode', action='store_true', help='run the application in test mode') + parser.add_argument( + '--software-rendering', + action='store_true', + help='render the GUI without OpenGL (use in remote desktop sessions if the window freezes)', + ) args = parser.parse_args() qInstallMessageHandler(console.qmlMessageHandler) @@ -57,6 +64,10 @@ app = Application(sys.argv) # Create the QApplication (Not QGuiApplication) console.debug(f'Qt Application created {app}') + # Has to happen before the QML engine creates the first window + if Rendering.configure(forceSoftware=args.software_rendering): + console.debug('Qt Quick software rendering backend selected') + engine = QQmlApplicationEngine() console.debug(f'QML application engine created {engine}') diff --git a/tests/test_py_helpers.py b/tests/test_py_helpers.py index 2086bf0f..534d61bf 100644 --- a/tests/test_py_helpers.py +++ b/tests/test_py_helpers.py @@ -52,3 +52,47 @@ def test_old_precision_formatter_still_returns_three_parts(): assert value_str assert std_dev_str assert '(' in combined and ')' in combined + + +def test_rendering_env_override_wins_on_any_platform(): + rendering = helpers_module.Rendering + + assert rendering.softwareRequested({rendering.ENV_VAR: '1'}, 'win32') is True + assert rendering.softwareRequested({rendering.ENV_VAR: '0', 'XRDP_SESSION': '1'}, 'linux') is False + + +def test_rendering_not_changed_outside_linux(): + assert helpers_module.Rendering.softwareRequested({'VNCDESKTOP': 'host:1'}, 'darwin') is False + + +def test_rendering_software_in_linux_remote_sessions(): + rendering = helpers_module.Rendering + + for name in rendering.REMOTE_SESSION_ENV_VARS: + assert rendering.softwareRequested({name: '1'}, 'linux') is True + + +def test_rendering_respects_user_selected_qt_backend(): + environ = {'QT_QUICK_BACKEND': 'rhi', 'XRDP_SESSION': '1'} + + assert helpers_module.Rendering.softwareRequested(environ, 'linux') is False + + +def test_rendering_probes_renderer_when_environment_is_inconclusive(): + rendering = helpers_module.Rendering + + assert rendering.softwareRequested({}, 'linux') is None + assert rendering.isSoftwareGlRenderer('llvmpipe (LLVM 15.0.7, 256 bits)') is True + assert rendering.isSoftwareGlRenderer('NVIDIA GeForce RTX 3060/PCIe/SSE2') is False + + +def test_rendering_configure_selects_software_backend_for_software_gl(monkeypatch): + rendering = helpers_module.Rendering + selected = [] + monkeypatch.setattr(helpers_module.sys, 'platform', 'linux') + monkeypatch.setattr(helpers_module.os, 'environ', {}) + monkeypatch.setattr(rendering, 'openGlRendererName', staticmethod(lambda: 'llvmpipe (LLVM 15.0.7, 256 bits)')) + monkeypatch.setattr(helpers_module.QQuickWindow, 'setGraphicsApi', staticmethod(selected.append)) + + assert rendering.configure() is True + assert selected == [helpers_module.QSGRendererInterface.GraphicsApi.Software] From a953898539e4fcf8da94a1220fd8b8a202a89374 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 23 Sep 2026 15:07:23 +0200 Subject: [PATCH 2/2] improved handling of QTWEBENGINE rendering types --- EasyReflectometryApp/Backends/Py/helpers.py | 89 ++++++++++----- .../Gui/Pages/Analysis/Layout.qml | 4 + EasyReflectometryApp/main.py | 7 +- tests/test_py_helpers.py | 101 ++++++++++++++---- 4 files changed, 151 insertions(+), 50 deletions(-) diff --git a/EasyReflectometryApp/Backends/Py/helpers.py b/EasyReflectometryApp/Backends/Py/helpers.py index 07483359..d86236db 100644 --- a/EasyReflectometryApp/Backends/Py/helpers.py +++ b/EasyReflectometryApp/Backends/Py/helpers.py @@ -112,39 +112,42 @@ def __init__(self, sysArgv): class Rendering: """ - Chooses the Qt Quick scene graph backend on Linux. + Chooses how the GUI is rendered on Linux. In remote-desktop sessions (VISA, xrdp, VNC, X2Go, ...) OpenGL is provided by - Mesa's software rasteriser and frames are presented through GLX. When the - remote client disconnects, the X server may stop completing buffer swaps, so - the next frame blocks the GUI thread inside glXSwapBuffers and the window - stays frozen ("not responding") after reconnecting. The software scene graph - backend paints through plain X11 image uploads and never waits on a swap. + Mesa's software rasteriser (llvmpipe). The Qt Quick scene graph copes with + that, but Qt WebEngine (the Bayesian plots) additionally runs Chromium's GPU + thread in-process on the same software GL, with contexts shared with the + scene graph; the window then stops repainting or freezes ("not responding"). + Telling Chromium to stay off the GPU (``--disable-gpu``) is enough: it + composites in software instead, and the rest of the GUI keeps using OpenGL. + + The Qt Quick *software* scene graph backend avoids OpenGL altogether. It is + heavier (every frame is painted on the CPU, ShaderEffect is unavailable) and + is only used when explicitly requested. """ - ENV_VAR = 'EASYREFLECTOMETRY_SOFTWARE_RENDERING' # '1' forces it on, '0' forces it off + ENV_VAR = 'EASYREFLECTOMETRY_SOFTWARE_RENDERING' # '1' forces the software scene graph, '0' leaves Qt alone + CHROMIUM_FLAGS_ENV_VAR = 'QTWEBENGINE_CHROMIUM_FLAGS' + CHROMIUM_DISABLE_GPU = '--disable-gpu' QT_BACKEND_ENV_VARS = ('QT_QUICK_BACKEND', 'QSG_RHI_BACKEND') REMOTE_SESSION_ENV_VARS = ('XRDP_SESSION', 'VNCDESKTOP', 'X2GO_SESSION') SOFTWARE_GL_RENDERERS = ('llvmpipe', 'softpipe', 'swrast', 'software rasterizer') GL_RENDERER = 0x1F01 + # Return values of configure() + DEFAULT = 'default' # nothing changed + WEBENGINE_SOFTWARE = 'webengine-software' # Qt Quick on OpenGL, Chromium without GPU + SOFTWARE = 'software' # Qt Quick software scene graph, Chromium without GPU + @staticmethod - def softwareRequested(environ, platform: str): - """ - Decide from the environment alone. - :return: True/False when decided, None when the OpenGL renderer has to be probed - """ - override = environ.get(Rendering.ENV_VAR, '').strip().lower() - if override in ('1', 'true', 'yes', 'on'): + def override(environ): + """The user's choice from the environment: True/False, or None when not set.""" + value = environ.get(Rendering.ENV_VAR, '').strip().lower() + if value in ('1', 'true', 'yes', 'on'): return True - if override in ('0', 'false', 'no', 'off'): - return False - if not platform.startswith('linux'): + if value in ('0', 'false', 'no', 'off'): return False - if any(environ.get(name) for name in Rendering.QT_BACKEND_ENV_VARS): - return False # the user already picked a backend; leave it alone - if any(environ.get(name) for name in Rendering.REMOTE_SESSION_ENV_VARS): - return True return None @staticmethod @@ -169,15 +172,45 @@ def openGlRendererName() -> str: context.doneCurrent() @staticmethod - def configure(forceSoftware: bool = False) -> bool: + def usesSoftwareGl(environ, platform: str) -> bool: + """True when OpenGL on this Linux session is (very likely) software rendered.""" + if not platform.startswith('linux'): + return False + if any(environ.get(name) for name in Rendering.REMOTE_SESSION_ENV_VARS): + return True + return Rendering.isSoftwareGlRenderer(Rendering.openGlRendererName()) + + @staticmethod + def disableWebEngineGpu(environ) -> None: + """ + Keep Chromium off the GPU. Chromium reads the flags when the first + WebEngine view is created, so this has to run before the QML is loaded. + """ + flags = environ.get(Rendering.CHROMIUM_FLAGS_ENV_VAR, '') + if Rendering.CHROMIUM_DISABLE_GPU in flags.split(): + return + environ[Rendering.CHROMIUM_FLAGS_ENV_VAR] = f'{flags} {Rendering.CHROMIUM_DISABLE_GPU}'.strip() + + @staticmethod + def configure(forceSoftware: bool = False, environ=None, platform: str = None) -> str: """ Must be called after the QApplication is created and before the first QQuickWindow (i.e. before the QML engine loads the main component). - :return: True if the software backend was selected + :return: one of DEFAULT, WEBENGINE_SOFTWARE, SOFTWARE """ - useSoftware = True if forceSoftware else Rendering.softwareRequested(os.environ, sys.platform) - if useSoftware is None: - useSoftware = Rendering.isSoftwareGlRenderer(Rendering.openGlRendererName()) - if useSoftware: + environ = os.environ if environ is None else environ + platform = sys.platform if platform is None else platform + + override = Rendering.override(environ) + if forceSoftware or override is True: + Rendering.disableWebEngineGpu(environ) QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.Software) - return useSoftware + return Rendering.SOFTWARE + if override is False: + return Rendering.DEFAULT + if any(environ.get(name) for name in Rendering.QT_BACKEND_ENV_VARS): + return Rendering.DEFAULT # the user already picked a backend; leave it alone + if Rendering.usesSoftwareGl(environ, platform): + Rendering.disableWebEngineGpu(environ) + return Rendering.WEBENGINE_SOFTWARE + return Rendering.DEFAULT diff --git a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml index 3ebb4ff6..3dbbe844 100644 --- a/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml +++ b/EasyReflectometryApp/Gui/Pages/Analysis/Layout.qml @@ -32,6 +32,10 @@ EaComponents.ContentPage { onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) }, Loader { + // The Bayesian views are WebEngine (Chromium) based. Only start it once + // there is a result to show; on software OpenGL (remote desktops) an idle + // Chromium GPU thread is enough to freeze the window. + active: Globals.BackendWrapper.bayesianResultAvailable source: `MainContent/BayesianPosteriorView.qml` onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`) } diff --git a/EasyReflectometryApp/main.py b/EasyReflectometryApp/main.py index aa5c9154..0718005a 100644 --- a/EasyReflectometryApp/main.py +++ b/EasyReflectometryApp/main.py @@ -51,7 +51,8 @@ parser.add_argument( '--software-rendering', action='store_true', - help='render the GUI without OpenGL (use in remote desktop sessions if the window freezes)', + help='render the whole GUI without OpenGL (Qt Quick software backend). By default only the ' + 'WebEngine views are kept off the GPU when a software OpenGL renderer is detected.', ) args = parser.parse_args() @@ -65,8 +66,8 @@ console.debug(f'Qt Application created {app}') # Has to happen before the QML engine creates the first window - if Rendering.configure(forceSoftware=args.software_rendering): - console.debug('Qt Quick software rendering backend selected') + rendering = Rendering.configure(forceSoftware=args.software_rendering) + console.debug(f'Rendering mode: {rendering}') engine = QQmlApplicationEngine() console.debug(f'QML application engine created {engine}') diff --git a/tests/test_py_helpers.py b/tests/test_py_helpers.py index 534d61bf..0058a98b 100644 --- a/tests/test_py_helpers.py +++ b/tests/test_py_helpers.py @@ -54,45 +54,108 @@ def test_old_precision_formatter_still_returns_three_parts(): assert '(' in combined and ')' in combined -def test_rendering_env_override_wins_on_any_platform(): +class _GraphicsApiSpy: + """Records QQuickWindow.setGraphicsApi calls instead of touching Qt.""" + + def __init__(self, monkeypatch): + self.calls = [] + monkeypatch.setattr(helpers_module.QQuickWindow, 'setGraphicsApi', staticmethod(self.calls.append)) + self.use_renderer(monkeypatch, 'NVIDIA GeForce RTX 3060/PCIe/SSE2') + + def use_renderer(self, monkeypatch, name): + monkeypatch.setattr(helpers_module.Rendering, 'openGlRendererName', staticmethod(lambda: name)) + + +def test_rendering_env_override_forces_software_scene_graph_on_any_platform(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + environ = {rendering.ENV_VAR: '1'} + + assert rendering.configure(environ=environ, platform='win32') == rendering.SOFTWARE + assert spy.calls == [helpers_module.QSGRendererInterface.GraphicsApi.Software] + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == rendering.CHROMIUM_DISABLE_GPU + + +def test_rendering_command_line_flag_forces_software_scene_graph(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + environ = {} + + assert rendering.configure(forceSoftware=True, environ=environ, platform='darwin') == rendering.SOFTWARE + assert len(spy.calls) == 1 + assert rendering.CHROMIUM_DISABLE_GPU in environ[rendering.CHROMIUM_FLAGS_ENV_VAR] + + +def test_rendering_env_override_off_leaves_everything_alone(monkeypatch): rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + environ = {rendering.ENV_VAR: '0', 'XRDP_SESSION': '1'} + + assert rendering.configure(environ=environ, platform='linux') == rendering.DEFAULT + assert spy.calls == [] + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ - assert rendering.softwareRequested({rendering.ENV_VAR: '1'}, 'win32') is True - assert rendering.softwareRequested({rendering.ENV_VAR: '0', 'XRDP_SESSION': '1'}, 'linux') is False +def test_rendering_not_changed_outside_linux(monkeypatch): + rendering = helpers_module.Rendering + _GraphicsApiSpy(monkeypatch) + environ = {'VNCDESKTOP': 'host:1'} -def test_rendering_not_changed_outside_linux(): - assert helpers_module.Rendering.softwareRequested({'VNCDESKTOP': 'host:1'}, 'darwin') is False + assert rendering.configure(environ=environ, platform='darwin') == rendering.DEFAULT + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ -def test_rendering_software_in_linux_remote_sessions(): +def test_rendering_remote_linux_session_only_disables_webengine_gpu(monkeypatch): rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) for name in rendering.REMOTE_SESSION_ENV_VARS: - assert rendering.softwareRequested({name: '1'}, 'linux') is True + environ = {name: '1'} + assert rendering.configure(environ=environ, platform='linux') == rendering.WEBENGINE_SOFTWARE + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == rendering.CHROMIUM_DISABLE_GPU + assert spy.calls == [] -def test_rendering_respects_user_selected_qt_backend(): +def test_rendering_respects_user_selected_qt_backend(monkeypatch): + rendering = helpers_module.Rendering + _GraphicsApiSpy(monkeypatch) environ = {'QT_QUICK_BACKEND': 'rhi', 'XRDP_SESSION': '1'} - assert helpers_module.Rendering.softwareRequested(environ, 'linux') is False + assert rendering.configure(environ=environ, platform='linux') == rendering.DEFAULT + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ -def test_rendering_probes_renderer_when_environment_is_inconclusive(): +def test_rendering_probes_renderer_when_environment_is_inconclusive(monkeypatch): + rendering = helpers_module.Rendering + spy = _GraphicsApiSpy(monkeypatch) + + spy.use_renderer(monkeypatch, 'llvmpipe (LLVM 15.0.7, 256 bits)') + environ = {} + assert rendering.configure(environ=environ, platform='linux') == rendering.WEBENGINE_SOFTWARE + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == rendering.CHROMIUM_DISABLE_GPU + + spy.use_renderer(monkeypatch, 'NVIDIA GeForce RTX 3060/PCIe/SSE2') + environ = {} + assert rendering.configure(environ=environ, platform='linux') == rendering.DEFAULT + assert rendering.CHROMIUM_FLAGS_ENV_VAR not in environ + assert spy.calls == [] + + +def test_rendering_is_software_gl_renderer(): rendering = helpers_module.Rendering - assert rendering.softwareRequested({}, 'linux') is None assert rendering.isSoftwareGlRenderer('llvmpipe (LLVM 15.0.7, 256 bits)') is True + assert rendering.isSoftwareGlRenderer('Mesa Software Rasterizer') is True assert rendering.isSoftwareGlRenderer('NVIDIA GeForce RTX 3060/PCIe/SSE2') is False + assert rendering.isSoftwareGlRenderer('') is False -def test_rendering_configure_selects_software_backend_for_software_gl(monkeypatch): +def test_rendering_keeps_existing_chromium_flags_and_does_not_duplicate(): rendering = helpers_module.Rendering - selected = [] - monkeypatch.setattr(helpers_module.sys, 'platform', 'linux') - monkeypatch.setattr(helpers_module.os, 'environ', {}) - monkeypatch.setattr(rendering, 'openGlRendererName', staticmethod(lambda: 'llvmpipe (LLVM 15.0.7, 256 bits)')) - monkeypatch.setattr(helpers_module.QQuickWindow, 'setGraphicsApi', staticmethod(selected.append)) - assert rendering.configure() is True - assert selected == [helpers_module.QSGRendererInterface.GraphicsApi.Software] + environ = {rendering.CHROMIUM_FLAGS_ENV_VAR: '--no-sandbox'} + rendering.disableWebEngineGpu(environ) + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == '--no-sandbox --disable-gpu' + + rendering.disableWebEngineGpu(environ) + assert environ[rendering.CHROMIUM_FLAGS_ENV_VAR] == '--no-sandbox --disable-gpu'