From bc6f8714176981469c1639a96d17ccfb8ce2663b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 07:37:42 -0400 Subject: [PATCH 1/3] Complete optional mounts without blocking the parent message pump --- src/textual/app.py | 4 +-- src/textual/await_complete.py | 25 ++++++++++++++++ src/textual/await_remove.py | 24 ++------------- src/textual/widget.py | 56 +++++++++++------------------------ tests/test_mount.py | 54 +++++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 62 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 50f4b753a69..5633dd1415a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2822,11 +2822,11 @@ def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]: if not _screen.is_running: widgets = self._register(self, _screen) await_mount = AwaitMount(_screen, widgets) - self.call_next(await_mount) + await_mount.call_when_ready(self) return (_screen, await_mount) else: await_mount = AwaitMount(_screen, []) - self.call_next(await_mount) + await_mount.call_when_ready(self) return (_screen, await_mount) def _load_screen_css(self, screen: Screen): diff --git a/src/textual/await_complete.py b/src/textual/await_complete.py index e94ee2c84d8..c2602b96d4b 100644 --- a/src/textual/await_complete.py +++ b/src/textual/await_complete.py @@ -29,6 +29,7 @@ def __init__( self._future: Future[Any] = gather(*awaitables) self._pre_await: CallbackType | None = pre_await self._caller = get_caller_file_and_line() + self._scheduled = False def __rich_repr__(self) -> rich.repr.Result: yield self._awaitables @@ -55,6 +56,30 @@ def call_next(self, node: MessagePump) -> Self: node.call_next(self) return self + def _start(self) -> Future[Any]: + """The completion owned by this optional awaitable.""" + return self._future + + def call_when_ready(self, node: MessagePump) -> None: + """Deliver completion without occupying the receiver's message pump. + + Mount and removal retain their own completion lifetimes. The original + callback path observes their result only when awaiting it cannot block + input or other messages behind another widget's startup / teardown. + """ + if self._scheduled: + return + self._scheduled = True + + def completed(future: Future[Any]) -> None: + if node._closing or node._closed: + if not future.cancelled(): + future.exception() + return + node.call_next(self) + + self._start().add_done_callback(completed) + async def __call__(self) -> Any: return await self diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index 71b6efdfa59..7de7730ee58 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -13,16 +13,16 @@ from textual._callback import invoke from textual._debug import get_caller_file_and_line from textual._types import CallbackType +from textual.await_complete import AwaitComplete if TYPE_CHECKING: from textual.dom import DOMNode - from textual.message_pump import MessagePump from textual.screen import Screen from textual.widget import Widget @rich.repr.auto -class AwaitRemove: +class AwaitRemove(AwaitComplete): """An awaitable that waits for nodes to be removed.""" def __init__( @@ -121,26 +121,6 @@ async def _finish(self) -> None: self._post_remove = None self._finisher = None - def call_when_ready(self, node: MessagePump) -> None: - """Deliver the completed receipt without blocking the receiver's queue. - - The application's message pump must remain available while another - widget runs an asynchronous unmount handler. Observe errors through the - ordinary callback path only after this transaction has actually ended. - """ - if self._scheduled: - return - self._scheduled = True - - def completed(future: Future[None]) -> None: - if node._closing or node._closed: - if not future.cancelled(): - future.exception() - return - node.call_next(self) - - self._start().add_done_callback(completed) - def __await__(self) -> Generator[None, None, None]: current_task = asyncio.current_task() self_removal = current_task in self._tasks diff --git a/src/textual/widget.py b/src/textual/widget.py index 18a0ff0879c..1902828dede 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -5,7 +5,7 @@ from __future__ import annotations -from asyncio import Lock, create_task, gather, wait +from asyncio import gather, shield from collections import Counter from contextlib import asynccontextmanager from fractions import Fraction @@ -51,7 +51,7 @@ from textual._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from textual._arrange import DockArrangeResult, arrange from textual._context import NoActiveAppError -from textual._debug import get_caller_file_and_line +from textual.await_complete import AwaitComplete from textual._dispatch_key import dispatch_key from textual._easing import DEFAULT_SCROLL_EASING from textual._extrema import Extrema @@ -132,7 +132,7 @@ @rich.repr.auto -class AwaitMount: +class AwaitMount(AwaitComplete): """An *optional* awaitable returned by [mount][textual.widget.Widget.mount] and [mount_all][textual.widget.Widget.mount_all]. Example: @@ -144,48 +144,28 @@ class AwaitMount: def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None: self._parent = parent self._widgets = widgets - self._caller = get_caller_file_and_line() - self._completion_lock = Lock() - self._completed = False + super().__init__(self._finish()) def __rich_repr__(self) -> rich.repr.Result: yield "parent", self._parent yield "widgets", self._widgets yield "caller", self._caller, None - async def __call__(self) -> None: - """Allows awaiting via a call operation.""" - await self + async def _finish(self) -> None: + """Complete registered child startup once, independently of waiters.""" + if self._widgets: + await gather(*(widget._mounted_event.wait() for widget in self._widgets)) + if not (self._parent._closing or self._parent._closed or self._parent._pruning): + self._parent.refresh(layout=True) + try: + self._parent.app._update_mouse_over(self._parent.screen) + except NoScreen: + pass def __await__(self) -> Generator[None, None, None]: - async def await_mount() -> None: - # mount() schedules this same object with call_next, and the caller - # may await it explicitly too. Complete one mount transaction, not - # a second set of waiters and layout invalidations for each await. - async with self._completion_lock: - if self._completed: - return - if self._widgets: - aws = [ - create_task(widget._mounted_event.wait(), name="await mount") - for widget in self._widgets - ] - try: - await wait(aws) - finally: - pending = [task for task in aws if not task.done()] - if pending: - for task in pending: - task.cancel() - await gather(*pending, return_exceptions=True) - self._parent.refresh(layout=True) - try: - self._parent.app._update_mouse_over(self._parent.screen) - except NoScreen: - pass - self._completed = True - - return await_mount().__await__() + # Cancelling an optional caller cannot cancel registered child startup + # or its completion publication to other callers / the parent. + return shield(self._future).__await__() class _Styled: @@ -1591,7 +1571,7 @@ def update_styles(children: list[DOMNode]) -> None: self.call_later(update_styles, self.displayed_children) await_mount = AwaitMount(self, mounted) - self.call_next(await_mount) + await_mount.call_when_ready(self) return await_mount diff --git a/tests/test_mount.py b/tests/test_mount.py index 4b4f15b10dc..694e8708e1a 100644 --- a/tests/test_mount.py +++ b/tests/test_mount.py @@ -32,6 +32,7 @@ async def test_render_only_after_mount(): async def test_mount_completion_is_shared_by_explicit_and_scheduled_awaiters(): parent = Mock(spec=Widget) + parent._closing = parent._closed = parent._pruning = False children = [Widget(), Widget()] mounted = AwaitMount(parent, children) explicit = asyncio.create_task(mounted()) @@ -49,6 +50,7 @@ async def test_mount_completion_is_shared_by_explicit_and_scheduled_awaiters(): async def test_cancelling_one_mount_awaiter_does_not_cancel_completion(): parent = Mock(spec=Widget) + parent._closing = parent._closed = parent._pruning = False child = Widget() mounted = AwaitMount(parent, [child]) first = asyncio.create_task(mounted()) @@ -143,3 +145,55 @@ class RegistrationApp(App): assert observed == ["left", "right", "last", "parent-a", "parent-b"] assert list(app.screen.children) == [first, second] assert list(first.children) == [left, right] + + +async def test_unawaited_mount_does_not_hold_parent_wheel_delivery(): + """Real driver input can use the existing viewport during child startup.""" + from textual import events + from textual.containers import VerticalScroll + from textual.widgets import Static + + entered, release, delivered = asyncio.Event(), asyncio.Event(), asyncio.Event() + + class AcquiringChild(Static): + async def on_mount(self): + entered.set() + await release.wait() + + class MountingApp(App): + def compose(self): + with VerticalScroll(id="viewport"): + yield Static("Existing body\n" * 40, id="body") + + async def on_event(self, event): + ingress = isinstance(event, events.MouseScrollDown) and not event.is_forwarded + result = await super().on_event(event) + if ingress: + delivered.set() + return result + + app = MountingApp() + async with app.run_test(size=(40, 12)) as pilot: + viewport = app.query_one("#viewport", VerticalScroll) + child = AcquiringChild("New body") + receipts = [] + + def acquire(): + receipts.append(viewport.mount(child)) + + viewport.call_later(acquire) + try: + await asyncio.wait_for(entered.wait(), 1) + assert not receipts[0].is_done + point = app.query_one("#body").region.offset + app._driver.process_message(events.MouseScrollDown(None, point.x + 1, point.y + 1, + 0, 0, 0, False, False, False)) + await asyncio.wait_for(delivered.wait(), 1) + assert viewport.scroll_y > 0 + assert not child.is_mounted + finally: + release.set() + await receipts[0] + await pilot.pause() + assert child.is_mounted + assert child in app.screen._compositor.widgets From 14d85b5bf58a40867c884df6da8671d48d8ff8c0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 07:39:06 -0400 Subject: [PATCH 2/3] Use each completion owner for shared receipt properties --- src/textual/await_complete.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/await_complete.py b/src/textual/await_complete.py index c2602b96d4b..1732e50709a 100644 --- a/src/textual/await_complete.py +++ b/src/textual/await_complete.py @@ -92,13 +92,14 @@ def __await__(self) -> Generator[Any, None, Any]: @property def is_done(self) -> bool: """`True` if the task has completed.""" - return self._future.done() + return self._start().done() @property def exception(self) -> BaseException | None: """An exception if the awaitables failed.""" - if self._future.done(): - return self._future.exception() + completion = self._start() + if completion.done(): + return completion.exception() return None @classmethod From c330608250493471dc5fe159c1bb6265e1c820a8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 07:44:43 -0400 Subject: [PATCH 3/3] Separate completion receipts from coroutine-group construction --- src/textual/await_complete.py | 56 +++++++++++++++++++++++------------ src/textual/await_remove.py | 17 ++++------- src/textual/widget.py | 19 +++++++----- tests/test_mount.py | 25 ++++++++++++++++ 4 files changed, 79 insertions(+), 38 deletions(-) diff --git a/src/textual/await_complete.py b/src/textual/await_complete.py index 1732e50709a..1c2c4f972f3 100644 --- a/src/textual/await_complete.py +++ b/src/textual/await_complete.py @@ -1,5 +1,6 @@ from __future__ import annotations +from abc import ABC, abstractmethod from asyncio import Future, gather from typing import TYPE_CHECKING, Any, Awaitable, Generator @@ -13,29 +14,19 @@ from textual.types import CallbackType -@rich.repr.auto(angular=True) -class AwaitComplete: - """An 'optionally-awaitable' object which runs one or more coroutines (or other awaitables) concurrently.""" +class AwaitCompletion(ABC): + """Shared receipt behavior, independent of how its operation is acquired. - def __init__( - self, *awaitables: Awaitable, pre_await: CallbackType | None = None - ) -> None: - """Create an AwaitComplete. + Coroutine groups, mounts and removals have distinct constructors and wait + lifetimes. They share completion notification and pre-await admission, + not a zero-argument operation factory or cancellation policy. + """ - Args: - awaitables: One or more awaitables to run concurrently. - """ - self._awaitables = awaitables - self._future: Future[Any] = gather(*awaitables) + def __init__(self, *, pre_await: CallbackType | None = None) -> None: self._pre_await: CallbackType | None = pre_await self._caller = get_caller_file_and_line() self._scheduled = False - def __rich_repr__(self) -> rich.repr.Result: - yield self._awaitables - yield "pre_await", self._pre_await, None - yield "caller", self._caller, None - def set_pre_await_callback(self, pre_await: CallbackType | None) -> None: """Set a callback to run prior to awaiting. @@ -56,9 +47,13 @@ def call_next(self, node: MessagePump) -> Self: node.call_next(self) return self + @abstractmethod def _start(self) -> Future[Any]: """The completion owned by this optional awaitable.""" - return self._future + + @abstractmethod + def _await(self) -> Awaitable[Any]: + """Acquire the concrete operation's wait / cancellation contract.""" def call_when_ready(self, node: MessagePump) -> None: """Deliver completion without occupying the receiver's message pump. @@ -87,7 +82,7 @@ def __await__(self) -> Generator[Any, None, Any]: _rich_traceback_omit = True if self._pre_await is not None: self._pre_await() - return self._future.__await__() + return self._await().__await__() @property def is_done(self) -> bool: @@ -102,6 +97,29 @@ def exception(self) -> BaseException | None: return completion.exception() return None + +@rich.repr.auto(angular=True) +class AwaitComplete(AwaitCompletion): + """An optional awaitable which runs a group of awaitables concurrently.""" + + def __init__( + self, *awaitables: Awaitable, pre_await: CallbackType | None = None + ) -> None: + super().__init__(pre_await=pre_await) + self._awaitables = awaitables + self._future: Future[Any] = gather(*awaitables) + + def __rich_repr__(self) -> rich.repr.Result: + yield self._awaitables + yield "pre_await", self._pre_await, None + yield "caller", self._caller, None + + def _start(self) -> Future[Any]: + return self._future + + def _await(self) -> Awaitable[Any]: + return self._future + @classmethod def nothing(cls): """Returns an already completed instance of AwaitComplete.""" diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index 7de7730ee58..f74d6672683 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -6,14 +6,13 @@ import asyncio from asyncio import Future, Task, gather -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING, Awaitable import rich.repr from textual._callback import invoke -from textual._debug import get_caller_file_and_line from textual._types import CallbackType -from textual.await_complete import AwaitComplete +from textual.await_complete import AwaitCompletion if TYPE_CHECKING: from textual.dom import DOMNode @@ -22,18 +21,17 @@ @rich.repr.auto -class AwaitRemove(AwaitComplete): +class AwaitRemove(AwaitCompletion): """An awaitable that waits for nodes to be removed.""" def __init__( self, tasks: list[Task], post_remove: CallbackType | None = None ) -> None: + super().__init__() self._tasks = list(tasks) self._post_remove = post_remove - self._caller = get_caller_file_and_line() self._completion: Future[None] | None = None self._finisher: Task[None] | None = None - self._scheduled = False @classmethod def prune(cls, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove: @@ -92,9 +90,6 @@ def __rich_repr__(self) -> rich.repr.Result: yield "post_remove", self._post_remove yield "caller", self._caller, None - async def __call__(self) -> None: - await self - def _start(self) -> Future[None]: """One independently-owned teardown completion for all optional waiters.""" if self._completion is None: @@ -121,7 +116,7 @@ async def _finish(self) -> None: self._post_remove = None self._finisher = None - def __await__(self) -> Generator[None, None, None]: + def _await(self) -> Awaitable[None]: current_task = asyncio.current_task() self_removal = current_task in self._tasks other_tasks = [task for task in self._tasks if task is not current_task] @@ -136,4 +131,4 @@ async def await_prune() -> None: return await asyncio.shield(completion) - return await_prune().__await__() + return await_prune() diff --git a/src/textual/widget.py b/src/textual/widget.py index 1902828dede..378b3b78015 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -5,7 +5,7 @@ from __future__ import annotations -from asyncio import gather, shield +from asyncio import Task, create_task, gather, shield from collections import Counter from contextlib import asynccontextmanager from fractions import Fraction @@ -14,10 +14,10 @@ from typing import ( TYPE_CHECKING, AsyncGenerator, + Awaitable, Callable, ClassVar, Collection, - Generator, Iterable, Mapping, NamedTuple, @@ -51,7 +51,6 @@ from textual._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from textual._arrange import DockArrangeResult, arrange from textual._context import NoActiveAppError -from textual.await_complete import AwaitComplete from textual._dispatch_key import dispatch_key from textual._easing import DEFAULT_SCROLL_EASING from textual._extrema import Extrema @@ -65,7 +64,7 @@ from textual._styles_cache import StylesCache from textual._types import AnimationLevel from textual.actions import SkipAction -from textual.await_complete import AwaitComplete +from textual.await_complete import AwaitComplete, AwaitCompletion from textual.await_remove import AwaitRemove from textual.box_model import BoxModel from textual.cache import FIFOCache, LRUCache @@ -132,7 +131,7 @@ @rich.repr.auto -class AwaitMount(AwaitComplete): +class AwaitMount(AwaitCompletion): """An *optional* awaitable returned by [mount][textual.widget.Widget.mount] and [mount_all][textual.widget.Widget.mount_all]. Example: @@ -142,9 +141,10 @@ class AwaitMount(AwaitComplete): """ def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None: + super().__init__() self._parent = parent self._widgets = widgets - super().__init__(self._finish()) + self._future: Task[None] = create_task(self._finish(), name="complete mount") def __rich_repr__(self) -> rich.repr.Result: yield "parent", self._parent @@ -162,10 +162,13 @@ async def _finish(self) -> None: except NoScreen: pass - def __await__(self) -> Generator[None, None, None]: + def _start(self) -> Task[None]: + return self._future + + def _await(self) -> Awaitable[None]: # Cancelling an optional caller cannot cancel registered child startup # or its completion publication to other callers / the parent. - return shield(self._future).__await__() + return shield(self._future) class _Styled: diff --git a/tests/test_mount.py b/tests/test_mount.py index 694e8708e1a..e1ed05ae2c6 100644 --- a/tests/test_mount.py +++ b/tests/test_mount.py @@ -197,3 +197,28 @@ def acquire(): await pilot.pause() assert child.is_mounted assert child in app.screen._compositor.widgets + + +async def test_completion_admission_applies_to_each_distinct_operation(): + """Shared admission runs without borrowing another operation's constructor.""" + from textual.await_complete import AwaitComplete + + admissions = [] + app = App() + async with app.run_test(): + child = Widget() + mounting = app.mount(child) + mounting.set_pre_await_callback(lambda: admissions.append("mount")) + assert await mounting is None + removing = child.remove() + removing.set_pre_await_callback(lambda: admissions.append("remove")) + assert await removing is None + completed = AwaitComplete.nothing() + completed.set_pre_await_callback(lambda: admissions.append("group")) + assert await completed is None + assert mounting.is_done and removing.is_done and completed.is_done + # Explicit and scheduled receipt consumers each run admission. The + # completion itself still publishes once, regardless of waiter count. + assert admissions[0] == "mount" + assert "remove" in admissions + assert admissions[-1] == "group"