Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Widget and window reference cleanup #2066

Merged
merged 23 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d0b6a24
add tests
samschott Aug 6, 2023
a995567
add window GC test
samschott Aug 9, 2023
8510601
retain window and release on __del__
samschott Aug 9, 2023
c4d6367
remove `native.interface` backrefs from winforms backend
samschott Aug 9, 2023
9444181
Make references in WidgetRegistry weak
samschott Aug 12, 2023
dba31f0
hack around segfault for Gtk WebView tests
samschott Aug 16, 2023
db5cdfa
skip window memory leak test on windows
samschott Aug 16, 2023
75c247c
Updated widget registry implementation comment.
freakboy3742 Aug 16, 2023
19aac87
Additional test of post-removal registry status.
freakboy3742 Aug 16, 2023
fc4ea4b
Add documentation about the finality of close().
freakboy3742 Aug 16, 2023
cb1e1bd
Removed an unnecessary window add.
freakboy3742 Aug 16, 2023
74b36e6
add a Window.closed attribute
samschott Aug 17, 2023
95039c4
added release note
samschott Aug 17, 2023
2babee6
avoid reference cycles between winforms and Python
samschott Aug 18, 2023
2104495
re-enable toga.Window memory leak test on winforms
samschott Aug 18, 2023
e87562c
change when we perform GC run in test_webview
samschott Aug 18, 2023
7ff95f0
Merge branch 'main' into ownership-cleanup-samschott
freakboy3742 Oct 9, 2023
5693cfa
Merge branch 'audit-window' into ownership-cleanup-samschott
freakboy3742 Oct 9, 2023
fb95616
Merge branch 'main' into ownership-cleanup-samschott
freakboy3742 Oct 17, 2023
1436fa2
Move wrappers into libs.
freakboy3742 Oct 18, 2023
3a0ddea
Resolve merging error with winforms detailedList.
freakboy3742 Oct 18, 2023
8373dfd
Correct issue with resize handler on OptionContainer.
freakboy3742 Oct 18, 2023
01bc60b
Finalize 3.12 support.
freakboy3742 Oct 18, 2023
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:

env:
min_python_version: "3.8"
max_python_version: "3.11"
max_python_version: "3.12"

defaults:
run:
Expand Down Expand Up @@ -65,12 +65,12 @@ jobs:
strategy:
matrix:
platform: [ "macos", "ubuntu", "windows" ]
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12-dev" ]
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
include:
- experimental: false

- python-version: "3.12-dev"
experimental: true
# - python-version: "3.13-dev"
# experimental: true
steps:
- uses: actions/checkout@v4.1.0
- name: Set up Python ${{ matrix.python-version }}
Expand Down
1 change: 1 addition & 0 deletions changes/1215.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Widgets are now removed from windows when the window is closed, preventing a memory leak on window closure.
14 changes: 10 additions & 4 deletions core/src/toga/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from builtins import id as identifier
from typing import TYPE_CHECKING, Iterator, NoReturn
from weakref import WeakValueDictionary

from travertino.node import Node

Expand All @@ -13,10 +14,12 @@
from toga.window import Window


class WidgetRegistry(dict):
# WidgetRegistry is implemented as a subclass of dict, because it provides
# a mapping from ID to widget. However, it exposes a set-like API; add()
# and update() take instances to be added, and iteration is over values.
class WidgetRegistry(WeakValueDictionary):
# WidgetRegistry is implemented as a subclass of WeakValueDictionary, because it
# provides a mapping from ID to widget. However, it exposes a set-like API; add()
# and update() take instances to be added, and iteration is over values. The
# mapping is weak so the registry doesn't retain a strong reference to the widget,
# preventing memory cleanup.

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand All @@ -43,6 +46,9 @@ def remove(self, id: str) -> None:
def __iter__(self) -> Iterator[Widget]:
return iter(self.values())

def __repr__(self) -> str:
return "{" + ", ".join(f"{k!r}: {v!r}" for k, v in self.items()) + "}"


class Widget(Node):
_MIN_WIDTH = 100
Expand Down
12 changes: 12 additions & 0 deletions core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def __init__(
self._impl = None
self._content = None
self._is_full_screen = False
self._closed = False

self._resizable = resizable
self._closable = closable
Expand Down Expand Up @@ -155,6 +156,11 @@ def app(self, app: App) -> None:
self._app = app
self._impl.set_app(app._impl)

@property
def closed(self) -> bool:
"""Whether the window was closed."""
return self._closed

@property
def _default_title(self) -> str:
return "Toga"
Expand Down Expand Up @@ -298,9 +304,15 @@ def close(self) -> None:

This *does not* invoke the ``on_close`` handler; the window will be immediately
and unconditionally closed.

Once a window has been closed, it *cannot* be reused. The behavior of any method
or property on a :class:`~toga.Window` instance after it has been closed is
undefined, except for :attr:`closed` which can be used to check if the window
was closed.
"""
self.app.windows -= self
self._impl.close()
self._closed = True

############################################################
# Dialogs
Expand Down
4 changes: 4 additions & 0 deletions core/tests/test_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ def test_close_direct(window, app):
window.close()

# Window has been closed, but the close handler has *not* been invoked.
assert window.closed
assert window.app == app
assert window not in app.windows
assert_action_performed(window, "close")
Expand All @@ -269,6 +270,7 @@ def test_close_no_handler(window, app):
window._impl.simulate_close()

# Window has been closed, and is no longer in the app's list of windows.
assert window.closed
assert window.app == app
assert window not in app.windows
assert_action_performed(window, "close")
Expand All @@ -287,6 +289,7 @@ def test_close_sucessful_handler(window, app):
window._impl.simulate_close()

# Window has been closed, and is no longer in the app's list of windows.
assert window.closed
assert window.app == app
assert window not in app.windows
assert_action_performed(window, "close")
Expand All @@ -306,6 +309,7 @@ def test_close_rejected_handler(window, app):
window._impl.simulate_close()

# Window has *not* been closed
assert not window.closed
assert window.app == app
assert window in app.windows
assert_action_not_performed(window, "close")
Expand Down
122 changes: 94 additions & 28 deletions core/tests/widgets/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ def test_add_child(widget):
assert widget.app == app
assert widget.window == window

# Widget is registered with app and window
assert widget.id in app.widgets
assert widget.id in window.widgets

# Child list is empty
assert widget.children == []

Expand Down Expand Up @@ -181,10 +185,7 @@ def test_add_multiple_children(widget):
child3 = ExampleLeafWidget(id="child3_id")

# App's widget index only contains the parent
assert app.widgets["widget_id"] == widget
assert "child1_id" not in app.widgets
assert "child2_id" not in app.widgets
assert "child3_id" not in app.widgets
assert app.widgets == {"widget_id": widget}

# Add the children.
widget.add(child1, child2, child3)
Expand Down Expand Up @@ -218,11 +219,20 @@ def test_add_multiple_children(widget):
assert_action_performed_with(window.content, "refresh")

# App's widget index has been updated
assert len(app.widgets) == 4
assert app.widgets["widget_id"] == widget
assert app.widgets["child1_id"] == child1
assert app.widgets["child2_id"] == child2
assert app.widgets["child3_id"] == child3
assert app.widgets == {
"widget_id": widget,
"child1_id": child1,
"child2_id": child2,
"child3_id": child3,
}

# Window's widget index has been updated
assert window.widgets == {
"widget_id": widget,
"child1_id": child1,
"child2_id": child2,
"child3_id": child3,
}


def test_reparent_child(widget):
Expand Down Expand Up @@ -375,9 +385,16 @@ def test_insert_child(widget):
assert_action_performed_with(window.content, "refresh")

# App's widget index has been updated
assert len(app.widgets) == 2
assert app.widgets["widget_id"] == widget
assert app.widgets["child_id"] == child
assert app.widgets == {
"widget_id": widget,
"child_id": child,
}

# Window's widget index has been updated
assert window.widgets == {
"widget_id": widget,
"child_id": child,
}


def test_insert_position(widget):
Expand All @@ -402,10 +419,10 @@ def test_insert_position(widget):
child3 = ExampleLeafWidget(id="child3_id")

# App's widget index only contains the parent
assert app.widgets["widget_id"] == widget
assert "child1_id" not in app.widgets
assert "child2_id" not in app.widgets
assert "child3_id" not in app.widgets
assert app.widgets == {"widget_id": widget}

# Windows's widget index only contains the parent
assert window.widgets == {"widget_id": widget}

# insert the children.
widget.insert(0, child1)
Expand Down Expand Up @@ -440,11 +457,20 @@ def test_insert_position(widget):
assert_action_performed_with(window.content, "refresh")

# App's widget index has been updated
assert len(app.widgets) == 4
assert app.widgets["widget_id"] == widget
assert app.widgets["child1_id"] == child1
assert app.widgets["child2_id"] == child2
assert app.widgets["child3_id"] == child3
assert app.widgets == {
"widget_id": widget,
"child1_id": child1,
"child2_id": child2,
"child3_id": child3,
}

# Window's widget index has been updated
assert window.widgets == {
"widget_id": widget,
"child1_id": child1,
"child2_id": child2,
"child3_id": child3,
}


def test_insert_bad_position(widget):
Expand All @@ -467,10 +493,12 @@ def test_insert_bad_position(widget):
child = ExampleLeafWidget(id="child_id")

# App's widget index only contains the parent
assert app.widgets["widget_id"] == widget
assert "child_id" not in app.widgets
assert app.widgets == {"widget_id": widget}

# Window's widget index only contains the parent
assert window.widgets == {"widget_id": widget}

# Insert the child at an position greater than the length of the list.
# Insert the child at a position greater than the length of the list.
# Widget will be added to the end of the list.
widget.insert(37, child)

Expand All @@ -492,9 +520,16 @@ def test_insert_bad_position(widget):
assert_action_performed_with(window.content, "refresh")

# App's widget index has been updated
assert len(app.widgets) == 2
assert app.widgets["widget_id"] == widget
assert app.widgets["child_id"] == child
assert app.widgets == {
"widget_id": widget,
"child_id": child,
}

# Window's widget index has been updated
assert window.widgets == {
"widget_id": widget,
"child_id": child,
}


def test_insert_reparent_child(widget):
Expand Down Expand Up @@ -618,6 +653,8 @@ def test_remove_child(widget):
assert child.parent == widget
assert child.app == app
assert child.window == window
assert app.widgets == {"widget_id": widget, "child_id": child}
assert window.widgets == {"widget_id": widget, "child_id": child}

# Remove the child
widget.remove(child)
Expand All @@ -630,6 +667,10 @@ def test_remove_child(widget):
assert child.app is None
assert child.window is None

# child widget no longer exists in the app or widgets registries.
assert app.widgets == {"widget_id": widget}
assert window.widgets == {"widget_id": widget}

# The impl's remove_child has been invoked
assert_action_performed_with(widget, "remove child", child=child._impl)

Expand All @@ -639,6 +680,9 @@ def test_remove_child(widget):
# The window's content gets a refresh notification
assert_action_performed_with(window.content, "refresh")

# App's widget index does not contain the widget
assert "child_id" not in app.widgets


def test_remove_multiple_children(widget):
"Multiple children can be removed from a widget"
Expand All @@ -659,6 +703,8 @@ def test_remove_multiple_children(widget):
assert child.parent == widget
assert child.app == app
assert child.window == window
assert app.widgets[child.id] == child
assert window.widgets[child.id] == child

# Remove 2 children
widget.remove(child1, child3)
Expand Down Expand Up @@ -689,6 +735,14 @@ def test_remove_multiple_children(widget):
# The window's content gets a refresh notification
assert_action_performed_with(window.content, "refresh")

# App's widget index does not contain the widget
assert "child1_id" not in app.widgets
assert "child3_id" not in app.widgets

# Windows's widget index does not contain the widget
assert "child1_id" not in window.widgets
assert "child3_id" not in window.widgets


def test_clear_all_children(widget):
"All children can be simultaneously removed from a widget"
Expand All @@ -709,6 +763,8 @@ def test_clear_all_children(widget):
assert child.parent == widget
assert child.app == app
assert child.window == window
assert app.widgets[child.id] == child
assert window.widgets[child.id] == child

# Clear children
widget.clear()
Expand Down Expand Up @@ -740,6 +796,16 @@ def test_clear_all_children(widget):
# The window's content gets a refresh notification
assert_action_performed_with(window.content, "refresh")

# App's widget index does not contain the widget
assert "child1_id" not in app.widgets
assert "child2_id" not in app.widgets
assert "child3_id" not in app.widgets

# Window's widget index does not contain the widget
assert "child1_id" not in window.widgets
assert "child2_id" not in window.widgets
assert "child3_id" not in window.widgets


def test_clear_no_children(widget):
"No changes are made (no-op) if widget has no children"
Expand Down Expand Up @@ -830,7 +896,7 @@ def test_set_app(widget):
assert len(app.widgets) == 1
assert app.widgets["widget_id"] == widget

# The impl has had it's app property set.
# The impl has had its app property set.
assert attribute_value(widget, "app") == app


Expand Down
4 changes: 4 additions & 0 deletions docs/reference/api/window.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ If the user attempts to close the window, Toga will call the ``on_close`` handle
handler must return a ``bool`` confirming whether the close is permitted. This can be
used to implement protections against closing a window with unsaved changes.

Once a window has been closed (either by user action, or programmatically with
:meth:`~toga.Window.close()`), it *cannot* be reused. The behavior of any method on a
:class:`~toga.Window` instance after it has been closed is undefined.

Notes
-----

Expand Down
Loading