diff --git a/HISTORY.md b/HISTORY.md index 7c0952ba..b8de0b24 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,10 @@ --> # Version History +- 0.2.207 - Close splash screen from the Tk main thread to avoid + `Tcl_AsyncDelete` errors from cross-thread callbacks. +- 0.2.206 - Cancel splash screen callbacks before destroying the Tk root to + prevent `Tcl_AsyncDelete` errors on shutdown. - 0.2.205 - Launch AutoML core through service manager and allow non-daemon service threads with join support. - 0.2.204 - Introduce threaded service manager to lazily load services, diff --git a/README.md b/README.md index df25164b..bb3ac3b4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -version: 0.2.205 +version: 0.2.207 Author: Miguel Marina - [LinkedIn](https://www.linkedin.com/in/progman32/) # AutoML diff --git a/gui/windows/splash_screen.py b/gui/windows/splash_screen.py index d8d52a9c..ec73b57c 100644 --- a/gui/windows/splash_screen.py +++ b/gui/windows/splash_screen.py @@ -117,6 +117,11 @@ def __init__( font=("Helvetica", 9), fill="white", ) + # Track scheduled callbacks so they can be cancelled on close + self._anim_after = None + self._fade_in_after = None + self._fade_out_after = None + self._close_after = None # Start animation and fade-in effect self._anim_after = self.after(10, self._animate) self._fade_in_after = self.after(10, self._fade_in) @@ -489,6 +494,19 @@ def _animate(self): def _close(self): """Destroy splash screen and accompanying shadow window.""" + for handle_name in ( + "_anim_after", + "_fade_in_after", + "_fade_out_after", + "_close_after", + ): + handle = getattr(self, handle_name, None) + if handle: + try: + self.after_cancel(handle) + except Exception: + pass + setattr(self, handle_name, None) try: self.shadow.destroy() except Exception: diff --git a/mainappsrc/version.py b/mainappsrc/version.py index c6d7eb23..02c5cb9c 100644 --- a/mainappsrc/version.py +++ b/mainappsrc/version.py @@ -18,6 +18,6 @@ """Project version information.""" -VERSION = "0.2.205" +VERSION = "0.2.207" __all__ = ["VERSION"] diff --git a/tests/test_splash_launcher.py b/tests/test_splash_launcher.py index 88d99535..7d080805 100644 --- a/tests/test_splash_launcher.py +++ b/tests/test_splash_launcher.py @@ -19,7 +19,11 @@ import importlib import builtins import sys +import threading +import tkinter as tk +import types +import pytest import tools.splash_launcher as splash_module @@ -51,3 +55,33 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0): importlib.reload(splash_module) assert splash_module.VERSION == "0.0.0" + + def _make_loader(self, calls): + def loader() -> types.SimpleNamespace: + calls.append(("loader", threading.current_thread().name)) + return types.SimpleNamespace( + main=lambda: calls.append(("main", threading.current_thread().name)) + ) + + return loader + + def test_loader_runs_in_background_thread(self): + calls: list[tuple[str, str]] = [] + loader = self._make_loader(calls) + try: + launcher = splash_module.SplashLauncher(loader=loader, post_delay=0) + except tk.TclError: + pytest.skip("Tk not available") + launcher.launch() + assert calls[0][1] != threading.current_thread().name + assert calls[1][0] == "main" + + def test_main_called_after_launch(self): + calls: list[tuple[str, str]] = [] + loader = self._make_loader(calls) + try: + launcher = splash_module.SplashLauncher(loader=loader, post_delay=0) + except tk.TclError: + pytest.skip("Tk not available") + launcher.launch() + assert ("main", threading.current_thread().name) in calls diff --git a/tests/test_splash_screen.py b/tests/test_splash_screen.py index 56703e72..19c37a74 100644 --- a/tests/test_splash_screen.py +++ b/tests/test_splash_screen.py @@ -106,6 +106,14 @@ def test_close_fades_to_invisible(self): self.assertAlmostEqual(float(self.splash.attributes("-alpha")), 0.0) self.assertTrue(self._closed) + def test_close_cancels_after_events(self): + scheduled = set(self.root.tk.call("after", "info").split()) + self.assertIn(self.splash._anim_after, scheduled) + self.splash._close() + scheduled_after = set(self.root.tk.call("after", "info").split()) + self.assertNotIn(self.splash._anim_after, scheduled_after) + self.assertIsNone(self.splash._anim_after) + if __name__ == "__main__": unittest.main() diff --git a/tools/splash_launcher.py b/tools/splash_launcher.py index 514bee16..aee81105 100644 --- a/tools/splash_launcher.py +++ b/tools/splash_launcher.py @@ -75,6 +75,7 @@ def __init__( self.module_name = module_name self.post_delay = post_delay self._module: Optional[ModuleType] = None + self._loaded = threading.Event() def _load_module(self) -> None: """Initialise the application in a background thread.""" @@ -82,8 +83,14 @@ def _load_module(self) -> None: self._module = self.loader() else: self._module = importlib.import_module(self.module_name) - # Once loading is complete, close the splash screen on the main thread - self._root.after(self.post_delay, self._splash.close) + self._loaded.set() + + def _poll(self) -> None: + """Check whether the background load finished and close the splash.""" + if self._loaded.is_set(): + self._root.after(self.post_delay, self._splash.close) + else: + self._root.after(50, self._poll) def launch(self) -> None: """Display the splash screen and run the application's main function.""" @@ -109,6 +116,7 @@ def launch(self) -> None: on_close=self._root.destroy, ) threading.Thread(target=self._load_module, daemon=True).start() + self._poll() self._root.mainloop() if self._module and hasattr(self._module, "main"): self._module.main()