Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
-->

## 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
Expand Down Expand Up @@ -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.
11 changes: 9 additions & 2 deletions gui/utils/closable_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
28 changes: 28 additions & 0 deletions gui/utils/win32_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@

from __future__ import annotations

import atexit
import logging
import sys
import typing as t
import weakref

LOGGER = logging.getLogger(__name__)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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
Comment on lines +203 to +207

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Win32 hook finalizer skips uninstall during shutdown

When sys.is_finalizing() is true, _Win32WindowProcHook.__del__ now drops the hook from _HOOKS and returns without calling uninstall, so the patched window procedure remains installed while the CFUNCTYPE callback is being torn down. During interpreter shutdown WindowResizeController.__del__ also exits immediately (window_resizer.py:333-334), so hooks created by controllers are collected via this branch and never reach the new atexit cleanup (the hook has already been removed from _HOOKS). On Windows any late WM messages in that window tear-down window will then call a freed callback, risking crashes precisely during interpreter exit—the scenario this change was meant to prevent.

Useful? React with 👍 / 👎.

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]
Expand Down
46 changes: 30 additions & 16 deletions gui/utils/window_resizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<Configure>`` events from a toplevel to tracked widgets."""

Expand Down Expand Up @@ -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("<Configure>", self._binding_id)
except Exception:
pass
self._binding_id = None
if self._destroy_binding_id is not None:
try:
unbind("<Destroy>", 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:
Expand All @@ -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("<Configure>", self._binding_id)
except Exception:
pass
self._binding_id = None
if self._destroy_binding_id is not None:
try:
unbind("<Destroy>", 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
Expand Down Expand Up @@ -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:
Expand Down
95 changes: 95 additions & 0 deletions tests/detachment/window/test_closable_notebook_detach_resize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Author: Miguel Marina <karel.capek.robotics@gmail.com>
# 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 <https://www.gnu.org/licenses/>.

"""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()
54 changes: 54 additions & 0 deletions tests/detachment/window/test_window_resizer_finalization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Author: Miguel Marina <karel.capek.robotics@gmail.com>
# 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 <https://www.gnu.org/licenses/>.

"""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 == []