diff --git a/HISTORY.md b/HISTORY.md index 43d24069..21635801 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -18,6 +18,24 @@ # along with this program. If not, see . --> +## 0.2.284 - 2025-09-20 + +- Uninstall Windows resize hooks during interpreter shutdown and register a + global cleanup handler so callbacks are never invoked after the GIL is + released, avoiding Tk crashes when detached windows close. +- Keep window resize controllers removing native hooks even while Python is + finalizing so floating diagram windows stop receiving Win32 messages during + teardown. + +## 0.2.283 - 2025-09-19 + +- Ensure detached tabs register with floating window resizers so diagrams resize + with their new host windows instead of the original notebooks, avoiding + crashes during window configuration. +- Add a regression test covering the detachment flow to verify resized diagrams + are tracked by the floating window resizer and remain linked to their new + window instances. + ## 0.2.282 - 2025-09-18 - Harden ``cancel_after_events`` so it cancels pending Tk ``after`` jobs before @@ -961,3 +979,4 @@ - 0.1.2 - Clarified systems safety focus in description and About dialog. - 0.1.1 - Updated description and About dialog. - 0.1.0 - Added Help menu and version tracking. +- 0.2.26 - Guard detached window resize hooks during interpreter shutdown to avoid crashes. diff --git a/gui/utils/closable_notebook.py b/gui/utils/closable_notebook.py index cbfa4de8..0438a656 100644 --- a/gui/utils/closable_notebook.py +++ b/gui/utils/closable_notebook.py @@ -941,8 +941,15 @@ def _on_destroy(_e, w=dw.win) -> None: except tk.TclError: logger.exception("Failed to detach tab %s", tab_id) return - dw._ensure_toolbox(child) - dw._activate_hooks(child) + try: + title = self.tab(child, "text") + except Exception: + title = "" + try: + dw.add_moved_widget(child, title) + except Exception: + dw._ensure_toolbox(child) + dw._activate_hooks(child) def rewrite_option_references(self, mapping: dict[tk.Widget, tk.Widget]) -> None: """Rewrite widget configuration options to point at cloned widgets.""" diff --git a/gui/utils/win32_hooks.py b/gui/utils/win32_hooks.py index 84f905d1..c1302887 100644 --- a/gui/utils/win32_hooks.py +++ b/gui/utils/win32_hooks.py @@ -19,9 +19,11 @@ from __future__ import annotations +import atexit import logging import sys import typing as t +import weakref LOGGER = logging.getLogger(__name__) @@ -40,6 +42,8 @@ def _python_is_finalizing() -> bool: import ctypes from ctypes import wintypes + _HOOKS: "weakref.WeakSet[_Win32WindowProcHook]" = weakref.WeakSet() + LRESULT = wintypes.LPARAM WNDPROC = ctypes.WINFUNCTYPE( LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM @@ -128,6 +132,7 @@ def __init__(self, hwnd: int, callback: t.Callable[[int, int], None]) -> None: self._original: int | None = None self._wnd_proc = WNDPROC(self._procedure) self._install() + _HOOKS.add(self) def _install(self) -> None: proc_pointer = ctypes.cast(self._wnd_proc, ctypes.c_void_p).value @@ -143,6 +148,10 @@ def uninstall(self) -> None: _set_window_proc(self.hwnd, self._original) finally: self._original = None + try: + _HOOKS.discard(self) + except Exception: + pass def _procedure(self, hwnd, msg, wparam, lparam): # noqa: ANN001 if _python_is_finalizing(): @@ -190,10 +199,29 @@ def _extract_windowpos_dimensions(lparam: int) -> tuple[int, int] | None: return int(windowpos.cx), int(windowpos.cy) def __del__(self) -> None: + if _python_is_finalizing(): + try: + _HOOKS.discard(self) + except Exception: + pass + return try: self.uninstall() except Exception: LOGGER.debug("Ignoring error while uninstalling Win32 hook", exc_info=True) + try: + _HOOKS.discard(self) + except Exception: + pass + + def _uninstall_registered_hooks() -> None: + for hook in list(_HOOKS): + try: + hook.uninstall() + except Exception: + LOGGER.debug("Ignoring error during registered hook uninstall", exc_info=True) + + atexit.register(_uninstall_registered_hooks) else: # pragma: no cover - exercised only on non-Windows platforms class _Win32WindowProcHook: # type: ignore[no-redef] diff --git a/gui/utils/window_resizer.py b/gui/utils/window_resizer.py index e7546740..91e44345 100644 --- a/gui/utils/window_resizer.py +++ b/gui/utils/window_resizer.py @@ -19,12 +19,22 @@ from __future__ import annotations +import sys import tkinter as tk import typing as t from gui.utils.win32_hooks import create_window_size_hook +def _python_is_finalizing() -> bool: + """Return ``True`` when the Python interpreter is shutting down.""" + + finalizing = getattr(sys, "is_finalizing", None) + if finalizing is None: + return False + return bool(finalizing()) + + class WindowResizeController: """Propagate ```` events from a toplevel to tracked widgets.""" @@ -116,22 +126,7 @@ def _callback(event: tk.Event) -> None: def shutdown(self) -> None: """Release bindings and native hooks held by the controller.""" - unbind = getattr(self.win, "unbind", None) - if callable(unbind): - if self._binding_id is not None: - try: - unbind("", self._binding_id) - except Exception: - pass - self._binding_id = None - if self._destroy_binding_id is not None: - try: - unbind("", self._destroy_binding_id) - except Exception: - pass - self._destroy_binding_id = None - - self._callback = None + finalizing = _python_is_finalizing() hook = self._win32_hook if hook is not None: @@ -141,6 +136,23 @@ def shutdown(self) -> None: pass self._win32_hook = None + if not finalizing: + unbind = getattr(self.win, "unbind", None) + if callable(unbind): + if self._binding_id is not None: + try: + unbind("", self._binding_id) + except Exception: + pass + self._binding_id = None + if self._destroy_binding_id is not None: + try: + unbind("", self._destroy_binding_id) + except Exception: + pass + self._destroy_binding_id = None + + self._callback = None self._targets.clear() self._primary = None self._last_size = None @@ -319,6 +331,8 @@ def _notify(widget: tk.Widget, width: int, height: int) -> None: pass def __del__(self) -> None: # pragma: no cover - defensive cleanup + if _python_is_finalizing(): + return try: self.shutdown() except Exception: diff --git a/tests/detachment/window/test_closable_notebook_detach_resize.py b/tests/detachment/window/test_closable_notebook_detach_resize.py new file mode 100644 index 00000000..d1343576 --- /dev/null +++ b/tests/detachment/window/test_closable_notebook_detach_resize.py @@ -0,0 +1,95 @@ +# Author: Miguel Marina +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2025 Capek System Safety & Robotic Solutions +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Regression tests for detaching tabs into floating windows.""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + +import pytest + +from gui.utils.closable_notebook import ClosableNotebook + + +@pytest.mark.detachment +@pytest.mark.window_resizer +class TestClosableNotebookDetachment: + """Ensure detached tabs register with the floating window resizer.""" + + def test_detach_tab_registers_resizer( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + try: + root = tk.Tk() + except tk.TclError: + pytest.skip("Tk not available") + + notebook = ClosableNotebook(root) + frame = tk.Frame(notebook) + notebook.add(frame, text="Diagram") + + class StubWindow: + def __init__(self, *_args, **_kwargs) -> None: + self.win = tk.Toplevel(root) + self.nb = ttk.Notebook(self.win) + self.added: list[tuple[tk.Widget, str]] = [] + + def add_moved_widget(self, widget: tk.Widget, text: str) -> None: + self.added.append((widget, text)) + + def _ensure_toolbox(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API + self.added.append((widget, "toolbox")) + + def _activate_hooks(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API + self.added.append((widget, "hooks")) + + stub_windows: list[StubWindow] = [] + monkeypatch.setattr( + "gui.utils.closable_notebook.DetachedWindow", + lambda *args, **kwargs: stub_windows.append(StubWindow(*args, **kwargs)) + or stub_windows[-1], + ) + + class StubManager: + def __init__(self) -> None: + self.calls: list[tuple[tk.Widget, str, tk.Widget]] = [] + + def detach_tab( + self, source: tk.Widget, tab_id: str, target: tk.Widget + ) -> tk.Widget: + self.calls.append((source, tab_id, target)) + source.forget(tab_id) + target.add(frame, text="Diagram") + return frame + + manager = StubManager() + monkeypatch.setattr( + "gui.utils.closable_notebook.WidgetTransferManager", lambda: manager + ) + + notebook._detach_tab(str(frame), x=10, y=10) + + assert stub_windows, "DetachedWindow should have been instantiated" + window = stub_windows[0] + assert window.added[0] == (frame, "Diagram") + assert manager.calls and manager.calls[0][0] is notebook + + window.win.destroy() + root.destroy() diff --git a/tests/detachment/window/test_window_resizer_finalization.py b/tests/detachment/window/test_window_resizer_finalization.py new file mode 100644 index 00000000..fc6a12ee --- /dev/null +++ b/tests/detachment/window/test_window_resizer_finalization.py @@ -0,0 +1,54 @@ +# Author: Miguel Marina +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2025 Capek System Safety & Robotic Solutions +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Finalization safety for the window resize controller.""" + +from __future__ import annotations + +import gui.utils.window_resizer as window_resizer + + +class _StubWindow: + def __init__(self) -> None: + self.unbind_calls: list[tuple[tuple[str, str], dict[str, str]]] = [] + + def wm_resizable(self, *_args, **_kwargs) -> None: # pragma: no cover - noop + return None + + def bind(self, *args, **_kwargs) -> str: # pragma: no cover - basic stub + return f"bind-{args[0]}" + + def unbind(self, *args, **kwargs) -> None: # pragma: no cover - tracking only + self.unbind_calls.append((args, kwargs)) + + def winfo_id(self) -> None: # pragma: no cover - disable Win32 hook install + return None + + +def test_shutdown_skips_finalizing(monkeypatch) -> None: + """Ensure shutdown avoids Tk/Win32 work during interpreter finalization.""" + + win = _StubWindow() + monkeypatch.setattr(window_resizer, "_python_is_finalizing", lambda: False) + controller = window_resizer.WindowResizeController(win) + monkeypatch.setattr(window_resizer, "_python_is_finalizing", lambda: True) + + controller.shutdown() + + assert win.unbind_calls == [] +