From 614c6c5e785b500144db0b3983e1d7b6c7774e76 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jan 2023 12:23:12 +0000 Subject: [PATCH 1/6] potential fix for freeze --- src/textual/app.py | 12 ++++++++---- src/textual/message_pump.py | 4 +++- src/textual/timer.py | 2 +- src/textual/widget.py | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b47c08acb..dbd832c6b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -859,7 +859,7 @@ class App(Generic[ReturnType], DOMNode): ) # Launch the app in the "background" - app_task = asyncio.create_task(run_app(app)) + app_task = asyncio.create_task(run_app(app), name=f"run_test {app}") # Wait until the app has performed all startup routines. await app_ready_event.wait() @@ -914,7 +914,9 @@ class App(Generic[ReturnType], DOMNode): raise pilot = Pilot(app) - auto_pilot_task = asyncio.create_task(run_auto_pilot(auto_pilot, pilot)) + auto_pilot_task = asyncio.create_task( + run_auto_pilot(auto_pilot, pilot), name=repr(pilot) + ) try: await app._process_messages( @@ -1649,7 +1651,7 @@ class App(Generic[ReturnType], DOMNode): """ - if not widgets: + if not widgets or not self._running: return [] new_widgets = list(widgets) @@ -2111,7 +2113,9 @@ class App(Generic[ReturnType], DOMNode): removed_widgets = self._detach_from_dom(widgets) finished_event = asyncio.Event() - asyncio.create_task(prune_widgets_task(removed_widgets, finished_event)) + asyncio.create_task( + prune_widgets_task(removed_widgets, finished_event), name="prune nodes" + ) return AwaitRemove(finished_event) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 303afa645..a468f8272 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -304,7 +304,9 @@ class MessagePump(metaclass=MessagePumpMeta): def _start_messages(self) -> None: """Start messages task.""" if self.app._running: - self._task = asyncio.create_task(self._process_messages()) + self._task = asyncio.create_task( + self._process_messages(), name=f"message pump {self}" + ) async def _process_messages(self) -> None: self._running = True diff --git a/src/textual/timer.py b/src/textual/timer.py index d6eb882c5..38c786923 100644 --- a/src/textual/timer.py +++ b/src/textual/timer.py @@ -89,7 +89,7 @@ class Timer: Returns: A Task instance for the timer. """ - self._task = asyncio.create_task(self._run_timer()) + self._task = asyncio.create_task(self._run_timer(), name=self.name) return self._task def stop_no_wait(self) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 26249cac6..4d2ca9367 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -94,7 +94,7 @@ class AwaitMount: async def await_mount() -> None: if self._widgets: aws = [ - create_task(widget._mounted_event.wait()) + create_task(widget._mounted_event.wait(), name="await mount") for widget in self._widgets ] if aws: From 75e585f85431fce333e9c4e5a76654fbc00e2138 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jan 2023 13:41:36 +0000 Subject: [PATCH 2/6] fix for freeze in tests --- CHANGELOG.md | 1 + src/textual/app.py | 8 +++++++- src/textual/message_pump.py | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5591ab5..7bc04bcd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed TextLog wrapping issue https://github.com/Textualize/textual/issues/1554 - Fixed issue with TextLog not writing anything before layout https://github.com/Textualize/textual/issues/1498 - Fixed an exception when populating a child class of `ListView` purely from `compose` https://github.com/Textualize/textual/issues/1588 +- Fixed freeze in tests https://github.com/Textualize/textual/issues/1608 ## [0.9.1] - 2022-12-30 diff --git a/src/textual/app.py b/src/textual/app.py index dbd832c6b..95d592237 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1651,10 +1651,11 @@ class App(Generic[ReturnType], DOMNode): """ - if not widgets or not self._running: + if not widgets: return [] new_widgets = list(widgets) + if before is not None or after is not None: # There's a before or after, which means there's going to be an # insertion, so make it easier to get the new things in the @@ -1670,6 +1671,11 @@ class App(Generic[ReturnType], DOMNode): if widget.children: self._register(widget, *widget.children) apply_stylesheet(widget) + + if not self._running: + # If the app is not running, prevent awaiting of the widget tasks + return [] + return list(widgets) def _unregister(self, widget: Widget) -> None: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index a468f8272..40d27de55 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -307,6 +307,9 @@ class MessagePump(metaclass=MessagePumpMeta): self._task = asyncio.create_task( self._process_messages(), name=f"message pump {self}" ) + else: + self._closing = True + self._closed = True async def _process_messages(self) -> None: self._running = True From 17ca4d0e5ecb49b5b7e15417c20b58a228253bb1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jan 2023 14:02:41 +0000 Subject: [PATCH 3/6] create_task compatibility layer --- src/textual/app.py | 14 +++++++------- src/textual/message_pump.py | 3 ++- src/textual/widget.py | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 95d592237..bff991d24 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,8 +1,6 @@ from __future__ import annotations import asyncio -from concurrent.futures import Future -from functools import partial import inspect import io import os @@ -12,8 +10,10 @@ import threading import unicodedata import warnings from asyncio import Task +from concurrent.futures import Future from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout from datetime import datetime +from functools import partial from pathlib import Path, PurePath from queue import Queue from time import perf_counter @@ -41,9 +41,10 @@ from rich.protocol import is_renderable from rich.segment import Segment, Segments from rich.traceback import Traceback -from . import actions, Logger, LogGroup, LogVerbosity, events, log, messages +from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START +from ._asyncio import create_task from ._callback import invoke from ._context import active_app from ._event_broker import NoHandler, extract_handler_actions @@ -69,7 +70,6 @@ from .renderables.blank import Blank from .screen import Screen from .widget import AwaitMount, Widget - if TYPE_CHECKING: from .devtools.client import DevtoolsClient from .pilot import Pilot @@ -859,7 +859,7 @@ class App(Generic[ReturnType], DOMNode): ) # Launch the app in the "background" - app_task = asyncio.create_task(run_app(app), name=f"run_test {app}") + app_task = create_task(run_app(app), name=f"run_test {app}") # Wait until the app has performed all startup routines. await app_ready_event.wait() @@ -914,7 +914,7 @@ class App(Generic[ReturnType], DOMNode): raise pilot = Pilot(app) - auto_pilot_task = asyncio.create_task( + auto_pilot_task = create_task( run_auto_pilot(auto_pilot, pilot), name=repr(pilot) ) @@ -2119,7 +2119,7 @@ class App(Generic[ReturnType], DOMNode): removed_widgets = self._detach_from_dom(widgets) finished_event = asyncio.Event() - asyncio.create_task( + create_task( prune_widgets_task(removed_widgets, finished_event), name="prune nodes" ) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 40d27de55..33544cfbd 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable from weakref import WeakSet from . import Logger, events, log, messages +from ._asyncio import create_task from ._callback import invoke from ._context import NoActiveAppError, active_app from ._time import time @@ -304,7 +305,7 @@ class MessagePump(metaclass=MessagePumpMeta): def _start_messages(self) -> None: """Start messages task.""" if self.app._running: - self._task = asyncio.create_task( + self._task = create_task( self._process_messages(), name=f"message pump {self}" ) else: diff --git a/src/textual/widget.py b/src/textual/widget.py index 4d2ca9367..27ad52b55 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,7 +1,6 @@ from __future__ import annotations -from asyncio import Event as AsyncEvent -from asyncio import Lock, create_task, wait +from asyncio import Lock, wait from collections import Counter from fractions import Fraction from itertools import islice @@ -36,6 +35,7 @@ from rich.text import Text from rich.traceback import Traceback from . import errors, events, messages +from ._asyncio import create_task from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._context import active_app From 8ef1e5c62655bfe14f8e51c54e3a577e5423e1ce Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jan 2023 14:05:04 +0000 Subject: [PATCH 4/6] tests --- src/textual/_asyncio.py | 23 +++++++++++++++++++++++ tests/test_freeze.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/textual/_asyncio.py create mode 100644 tests/test_freeze.py diff --git a/src/textual/_asyncio.py b/src/textual/_asyncio.py new file mode 100644 index 000000000..f5c4aa5a4 --- /dev/null +++ b/src/textual/_asyncio.py @@ -0,0 +1,23 @@ +""" +Compatibility layer for asyncio. + +""" + +from __future__ import annotations + + +import sys + +__all__ = ["create_task"] + +if sys.version_info >= (3, 8): + from asyncio import create_task + +else: + import asyncio + from asyncio import create_task as _create_task + from typing import Awaitable + + def create_task(coroutine: Awaitable, *, name: str | None = None) -> asyncio.Task: + """Schedule the execution of a coroutine object in a spawn task.""" + return _create_task(coroutine) diff --git a/tests/test_freeze.py b/tests/test_freeze.py new file mode 100644 index 000000000..5bdb69d29 --- /dev/null +++ b/tests/test_freeze.py @@ -0,0 +1,29 @@ +import pytest + +from textual.app import App +from textual.screen import Screen +from textual.widgets import Footer, Header, Input + + +class MyScreen(Screen): + def compose(self): + yield Header() + yield Input() + yield Footer() + + +class MyApp(App): + def on_mount(self): + try: + self.install_screen(MyScreen(), "myscreen") + self.push_screen("myscreen") + except Exception as error: + print(error) + + +async def test_freeze(): + """Regression test for https://github.com/Textualize/textual/issues/1608""" + app = MyApp() + with pytest.raises(Exception): + async with app.run_test(): + raise Exception("never raised") From 64cbab0a4ceb7ac0309ea0666875f1caa78b303c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jan 2023 14:08:31 +0000 Subject: [PATCH 5/6] missing create_task --- src/textual/timer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/timer.py b/src/textual/timer.py index 38c786923..649c5cf80 100644 --- a/src/textual/timer.py +++ b/src/textual/timer.py @@ -7,7 +7,6 @@ Timer objects are created by [set_interval][textual.message_pump.MessagePump.set from __future__ import annotations -import asyncio import weakref from asyncio import CancelledError, Event, Task from typing import Awaitable, Callable, Union @@ -15,6 +14,7 @@ from typing import Awaitable, Callable, Union from rich.repr import Result, rich_repr from . import _clock, events +from ._asyncio import create_task from ._callback import invoke from ._context import active_app from ._time import sleep @@ -89,7 +89,7 @@ class Timer: Returns: A Task instance for the timer. """ - self._task = asyncio.create_task(self._run_timer(), name=self.name) + self._task = create_task(self._run_timer(), name=self.name) return self._task def stop_no_wait(self) -> None: From 8cc41a13574cdb74d13b94d4ac41de1272b6e341 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 19 Jan 2023 14:18:40 +0000 Subject: [PATCH 6/6] remove except --- tests/test_freeze.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_freeze.py b/tests/test_freeze.py index 5bdb69d29..8d31d2030 100644 --- a/tests/test_freeze.py +++ b/tests/test_freeze.py @@ -14,11 +14,8 @@ class MyScreen(Screen): class MyApp(App): def on_mount(self): - try: - self.install_screen(MyScreen(), "myscreen") - self.push_screen("myscreen") - except Exception as error: - print(error) + self.install_screen(MyScreen(), "myscreen") + self.push_screen("myscreen") async def test_freeze():