Skip to content

Commit 3ee3920

Browse files
authored
Merge pull request #1219 from Textualize/remove-freeze-fix
fix remove freeze
2 parents 6ad9256 + 1f2781c commit 3ee3920

File tree

6 files changed

+74
-59
lines changed

6 files changed

+74
-59
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
4545
- Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175
4646
- Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155
4747
- Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202
48+
- Fixed deadlock when removing widgets from the App https://github.com/Textualize/textual/pull/1219
4849

4950
## [0.4.0] - 2022-11-08
5051

src/textual/app.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@
1818
from typing import (
1919
TYPE_CHECKING,
2020
Any,
21+
Callable,
2122
Generic,
2223
Iterable,
2324
List,
2425
Type,
2526
TypeVar,
2627
Union,
2728
cast,
28-
Callable,
2929
)
3030
from weakref import WeakSet, WeakValueDictionary
3131

@@ -45,7 +45,8 @@
4545
from ._event_broker import NoHandler, extract_handler_actions
4646
from ._filter import LineFilter, Monochrome
4747
from ._path import _make_path_object_relative
48-
from ._typing import TypeAlias, Final
48+
from ._typing import Final, TypeAlias
49+
from .await_remove import AwaitRemove
4950
from .binding import Binding, Bindings
5051
from .css.query import NoMatches
5152
from .css.stylesheet import Stylesheet
@@ -61,7 +62,8 @@
6162
from .reactive import Reactive
6263
from .renderables.blank import Blank
6364
from .screen import Screen
64-
from .widget import AwaitMount, Widget
65+
from .widget import AwaitMount, MountError, Widget
66+
6567

6668
if TYPE_CHECKING:
6769
from .devtools.client import DevtoolsClient
@@ -352,6 +354,7 @@ def __init__(
352354
else None
353355
)
354356
self._screenshot: str | None = None
357+
self._dom_lock = asyncio.Lock()
355358

356359
@property
357360
def return_value(self) -> ReturnType | None:
@@ -1951,6 +1954,48 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]:
19511954
for child in widget.children:
19521955
push(child)
19531956

1957+
def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove:
1958+
"""Remove nodes from DOM, and return an awaitable that awaits cleanup.
1959+
1960+
Args:
1961+
widgets (list[Widget]): List of nodes to remvoe.
1962+
1963+
Returns:
1964+
AwaitRemove: Awaitable that returns when the nodes have been fully removed.
1965+
"""
1966+
1967+
async def prune_widgets_task(
1968+
widgets: list[Widget], finished_event: asyncio.Event
1969+
) -> None:
1970+
"""Prune widgets as a background task.
1971+
1972+
Args:
1973+
widgets (list[Widget]): Widgets to prune.
1974+
finished_event (asyncio.Event): Event to set when complete.
1975+
"""
1976+
try:
1977+
await self._prune_nodes(widgets)
1978+
finally:
1979+
finished_event.set()
1980+
1981+
removed_widgets = self._detach_from_dom(widgets)
1982+
self.refresh(layout=True)
1983+
1984+
finished_event = asyncio.Event()
1985+
asyncio.create_task(prune_widgets_task(removed_widgets, finished_event))
1986+
1987+
return AwaitRemove(finished_event)
1988+
1989+
async def _prune_nodes(self, widgets: list[Widget]) -> None:
1990+
"""Remove nodes and children.
1991+
1992+
Args:
1993+
widgets (Widget): _description_
1994+
"""
1995+
async with self._dom_lock:
1996+
for widget in widgets:
1997+
await self._prune_node(widget)
1998+
19541999
async def _prune_node(self, root: Widget) -> None:
19552000
"""Remove a node and its children. Children are removed before parents.
19562001

src/textual/css/query.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -356,16 +356,9 @@ def remove(self) -> AwaitRemove:
356356
Returns:
357357
AwaitRemove: An awaitable object that waits for the widgets to be removed.
358358
"""
359-
prune_finished_event = asyncio.Event()
360359
app = active_app.get()
361-
app.post_message_no_wait(
362-
events.Prune(
363-
app,
364-
widgets=app._detach_from_dom(list(self)),
365-
finished_flag=prune_finished_event,
366-
)
367-
)
368-
return AwaitRemove(prune_finished_event)
360+
await_remove = app._remove_nodes(list(self))
361+
return await_remove
369362

370363
def set_styles(
371364
self, css: str | None = None, **update_styles

src/textual/events.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -127,28 +127,6 @@ class Unmount(Mount, bubble=False, verbose=False):
127127
"""Sent when a widget is unmounted and may not longer receive messages."""
128128

129129

130-
class Prune(Event, bubble=False):
131-
"""Sent to the app to ask it to prune one or more widgets from the DOM.
132-
133-
Attributes:
134-
widgets (list[Widgets]): The list of widgets to prune.
135-
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
136-
"""
137-
138-
def __init__(
139-
self, sender: MessageTarget, widgets: list[Widget], finished_flag: asyncio.Event
140-
) -> None:
141-
"""Initialise the event.
142-
143-
Args:
144-
widgets (list[Widgets]): The list of widgets to prune.
145-
finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done.
146-
"""
147-
super().__init__(sender)
148-
self.finished_flag = finished_flag
149-
self.widgets = widgets
150-
151-
152130
class Show(Event, bubble=False):
153131
"""Sent when a widget has become visible."""
154132

src/textual/screen.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -310,18 +310,19 @@ async def _on_idle(self, event: events.Idle) -> None:
310310
# Check for any widgets marked as 'dirty' (needs a repaint)
311311
event.prevent_default()
312312

313-
if self.is_current:
314-
if self._layout_required:
315-
self._refresh_layout()
316-
self._layout_required = False
317-
self._dirty_widgets.clear()
318-
if self._repaint_required:
319-
self._dirty_widgets.clear()
320-
self._dirty_widgets.add(self)
321-
self._repaint_required = False
322-
323-
if self._dirty_widgets:
324-
self.update_timer.resume()
313+
async with self.app._dom_lock:
314+
if self.is_current:
315+
if self._layout_required:
316+
self._refresh_layout()
317+
self._layout_required = False
318+
self._dirty_widgets.clear()
319+
if self._repaint_required:
320+
self._dirty_widgets.clear()
321+
self._dirty_widgets.add(self)
322+
self._repaint_required = False
323+
324+
if self._dirty_widgets:
325+
self.update_timer.resume()
325326

326327
# The Screen is idle - a good opportunity to invoke the scheduled callbacks
327328
await self._invoke_and_clear_callbacks()

src/textual/widget.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -523,15 +523,19 @@ def mount(
523523

524524
# Decide the final resting place depending on what we've been asked
525525
# to do.
526+
insert_before: int | None = None
527+
insert_after: int | None = None
526528
if before is not None:
527-
parent, before = self._find_mount_point(before)
529+
parent, insert_before = self._find_mount_point(before)
528530
elif after is not None:
529-
parent, after = self._find_mount_point(after)
531+
parent, insert_after = self._find_mount_point(after)
530532
else:
531533
parent = self
532534

533535
return AwaitMount(
534-
self.app._register(parent, *widgets, before=before, after=after)
536+
self.app._register(
537+
parent, *widgets, before=insert_before, after=insert_after
538+
)
535539
)
536540

537541
def move_child(
@@ -697,7 +701,6 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int
697701
Returns:
698702
int: The height of the content.
699703
"""
700-
701704
if self.is_container:
702705
assert self._layout is not None
703706
height = (
@@ -2114,15 +2117,9 @@ def remove(self) -> AwaitRemove:
21142117
Returns:
21152118
AwaitRemove: An awaitable object that waits for the widget to be removed.
21162119
"""
2117-
prune_finished_event = AsyncEvent()
2118-
self.app.post_message_no_wait(
2119-
events.Prune(
2120-
self,
2121-
widgets=self.app._detach_from_dom([self]),
2122-
finished_flag=prune_finished_event,
2123-
)
2124-
)
2125-
return AwaitRemove(prune_finished_event)
2120+
2121+
await_remove = self.app._remove_nodes([self])
2122+
return await_remove
21262123

21272124
def render(self) -> RenderableType:
21282125
"""Get renderable for widget.

0 commit comments

Comments
 (0)