From 52340fb6d7ebf2791f99eef41e412fb03d9d1d4b Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:15:23 +0200 Subject: [PATCH] fix(tests): destroy the widgets of each test before the next one A closed main window stayed alive until the next test created its own: it was referenced by the Web API controller, shared by all main windows, and by the SigimaX singleton instance, while the closed internal console, deleted later by Qt, was only deleted by the next event loop. The previous main window was then destroyed by the garbage collector at an arbitrary point of the next test, possibly amid Qt operations. - Web API: when its main window is closed, the controller no longer references it (nor its actions, which no longer receive server notifications), and stopping the server releases the workspace adapter - Tests: pending events and deferred deletions are processed after each test, then the garbage collector is run Requires the SigimaX fix releasing the closed main window as singleton instance (same branch name). Assisted-by: Claude Opus 5.5 --- datalab/tests/conftest.py | 10 ++++++++++ datalab/webapi/actions.py | 18 +++++++++++++++--- datalab/webapi/controller.py | 17 +++++++++++++++++ datalab/webapi/routes.py | 4 ++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/datalab/tests/conftest.py b/datalab/tests/conftest.py index baf39f9aa..b2a5ade19 100644 --- a/datalab/tests/conftest.py +++ b/datalab/tests/conftest.py @@ -8,6 +8,7 @@ executed before running any tests. """ +import gc import os import os.path as osp @@ -23,6 +24,7 @@ import skimage from guidata.config import ValidationMode, set_validation_mode from guidata.utils.gitreport import format_git_info_for_pytest, get_git_info_for_modules +from qtpy import QtCore as QC from sigima.tests import helpers import datalab @@ -143,3 +145,11 @@ def reset_cwd(request): # pylint: disable=unused-argument def pytest_runtest_teardown(item, nextitem): # pylint: disable=unused-argument """Run teardown after each test.""" PluginRegistry.unregister_all_plugins() + # Destroy the widgets of the test now, rather than during a later test: closed + # widgets deleted later (e.g. the internal console) are only deleted by the + # next event loop, and main windows are part of reference cycles, only + # destroyed when the garbage collector runs, possibly amid Qt operations + if QC.QCoreApplication.instance() is not None: + QC.QCoreApplication.processEvents() + QC.QCoreApplication.sendPostedEvents(None, QC.QEvent.DeferredDelete) + gc.collect() diff --git a/datalab/webapi/actions.py b/datalab/webapi/actions.py index ebd44266f..a23e733ab 100644 --- a/datalab/webapi/actions.py +++ b/datalab/webapi/actions.py @@ -279,9 +279,21 @@ def _on_server_error(self, message: str) -> None: self._on_server_stopped() def cleanup(self) -> None: - """Clean up resources on shutdown.""" - if self._controller is not None and self._controller.is_running: - self._controller.stop() + """Clean up resources on shutdown. + + The Web API controller is shared by all main windows: once this one is + closed, the controller must neither keep it alive nor notify its actions. + """ + controller = self._controller + if controller is None: + return + if controller.is_running: + controller.stop() + controller.server_started.disconnect(self._on_server_started) + controller.server_stopped.disconnect(self._on_server_stopped) + controller.server_error.disconnect(self._on_server_error) + controller.release_main_window(self._main_window) + self._controller = None def show_connection_info(self) -> None: """Show connection info dialog or copy to clipboard. diff --git a/datalab/webapi/controller.py b/datalab/webapi/controller.py index bdc8c59ae..50d2fe5b9 100644 --- a/datalab/webapi/controller.py +++ b/datalab/webapi/controller.py @@ -106,6 +106,18 @@ def set_main_window(self, main_window: DLMainWindow) -> None: """ self._main_window = main_window + def release_main_window(self, main_window: DLMainWindow) -> None: + """Release the main window reference, if it is the given one. + + The controller being shared by all main windows, a closed main window + must not be kept alive by its reference. + + Args: + main_window: The DataLab main window being closed. + """ + if self._main_window is main_window: + self._main_window = None + @property def is_running(self) -> bool: """Check if the server is running.""" @@ -337,6 +349,11 @@ def stop(self) -> None: self._uvicorn_server = None self._adapter = None + # pylint: disable=import-outside-toplevel + from datalab.webapi.routes import set_adapter + + # The adapter references the main window + set_adapter(None) self._url = None self._token = None diff --git a/datalab/webapi/routes.py b/datalab/webapi/routes.py index 74f54f27d..f6624e1bb 100644 --- a/datalab/webapi/routes.py +++ b/datalab/webapi/routes.py @@ -60,8 +60,8 @@ _LOCALHOST_NO_TOKEN: bool = False -def set_adapter(adapter: WorkspaceAdapter) -> None: - """Set the workspace adapter for route handlers.""" +def set_adapter(adapter: WorkspaceAdapter | None) -> None: + """Set the workspace adapter for route handlers (None when stopped).""" global _ADAPTER # noqa: PLW0603 # pylint: disable=global-statement _ADAPTER = adapter