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