Skip to content

Deadlock/freeze when awaiting methods related to screen management in @on message handler #5596

@matkudela

Description

@matkudela

This issue is related to #5008 (comment) - problem is still valid - awaiting pop_screen freezes application. Given workaround solution by Will is not satisfying - it can cause other issues like race conditions. I am running out of solutions for this. Of course executing the same code by bindings works without any problems. Popping screen should work not matter what caused execution of code - reaction for binding in action method or button handle in @on(Button.Pressed).
Here is MRE which shows the problem:

from __future__ import annotations

from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Button, Footer, Static


class InitialScreen(Screen):
    def compose(self) -> ComposeResult:
        yield Static("Initial screen")
        yield Button("Push next screen")

    @on(Button.Pressed)
    def push_next_screen(self) -> None:
        self.app.push_screen(NextScreen())


class NextScreen(Screen):
    BINDINGS = [
        Binding("d", "do_something", "Do something"),
    ]

    def compose(self) -> ComposeResult:
        yield Static("Next screen")
        yield Button("Do something")
        yield Footer()

    async def action_do_something(self) -> None:
        """It's fine to call this from key bindings."""
        await self._do_something()

    @on(Button.Pressed)
    async def do_something_from_button(self) -> None:
        """Will deadlock the app."""
        await self._do_something()
        # workaround solution which can cause another issues like race conditions
        # self.app.run_worker(self._do_something())

    async def _do_something(self) -> None:
        await self.app.pop_screen()
        self.notify("Something that requires the screen to be already popped aka. await screen replacement.")


class MyApp(App):
    def on_mount(self) -> None:
        self.push_screen(InitialScreen())


MyApp().run()

A bit more complicated, but real example:

from __future__ import annotations

from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen, Screen
from textual.widgets import Button, Footer, Static


class ConfigScreen(Screen):
    BINDINGS = [
        Binding("escape", "app.pop_screen", "Go back"),
    ]

    def __init__(self, action: str) -> None:
        super().__init__()
        self.action = action

    def compose(self) -> ComposeResult:
        yield Static(f"Config screen of {self.action}")
        yield Footer()


class SelectConfigDialog(ModalScreen):
    DEFAULT_CSS = """
    SelectConfigDialog {
        align: center middle;
        background: $background 85%;

        Vertical {
            width: 50%;
            height: 50%;
            border: $primary outer;
            background: $panel;
        }
    }
    """

    BINDINGS = [
        Binding("escape", "dismiss", "Go back"),
        Binding("1", "push_config_screen('change-email')", "Change email"),
        Binding("2", "push_config_screen('change-password')", "Change password"),
    ]

    def compose(self) -> ComposeResult:
        with Vertical():
            yield Static("What do you want to do?")
            yield Button("Change email (1)", id="change-email")
            yield Button("Change password (2)", id="change-password")
        yield Footer()

    @on(Button.Pressed)
    async def push_config_screen_from_button(self, event: Button.Pressed) -> None:
        """Should dismiss the dialog and push the appropriate config screen."""
        # await self.dismiss()  # Will cause ScreenError("Can't await screen.dismiss() from the screen's message handler; try removing the await keyword.")
        # self.dismiss()  # AFAIK calling without await is dangerous as it may dismiss ConfigScreen instead of SelectConfigDialog by looking at the `await_pop = self.app.pop_screen()` in dismiss method
        await self.app.pop_screen()  # Will cause the deadlock
        self.app.push_screen(ConfigScreen(event.button.id))

    async def _action_push_config_screen(self, action: str) -> None:
        """Should dismiss the dialog and push the appropriate config screen."""
        await self.dismiss()  # works just fine
        self.app.push_screen(ConfigScreen(action))


class InitialScreen(Screen):
    BINDINGS = [
        Binding("c", "open_configuration_dialog", "Open configuration dialog"),
    ]

    def compose(self) -> ComposeResult:
        yield Static("Initial screen")
        yield Static("Press 'c' to open the dialog.")
        yield Footer()

    def action_open_configuration_dialog(self) -> None:
        self.app.push_screen(SelectConfigDialog())


class MyApp(App):
    def on_mount(self) -> None:
        self.push_screen(InitialScreen())


MyApp().run()

In my application also faced similar problem while switching modes. I have dashboard (main) mode and config mode. Also got some global buttons in header which change modes if necessary - dashboard, config and cart (which is in dashboard mode, additionally pushes next screen). This time application freezes probably while resetting old mode (need to remove and add it to drop all screens from screen stack). I think this is connected with pop_screen problem. Both are screen management.

from __future__ import annotations

from typing import Literal

from textual import on
from textual.app import App, ComposeResult, ScreenError
from textual.await_complete import AwaitComplete
from textual.binding import Binding
from textual.containers import Horizontal
from textual.screen import Screen, ScreenResultType
from textual.widgets import Button, Footer, Static


class GlobalButtons(Horizontal):
    """In real scenario, this can be e.g in the header or hamburger menu.."""

    DEFAULT_CSS = """
    GlobalButtons {
        align: right bottom;
        margin-bottom: 1;
        height: auto;
    }
    """
    def compose(self) -> ComposeResult:
        yield Button("Dashboard", id="main")
        yield Button("Config", id="config")
        yield Button("Cart", id="cart")
        yield Button("Account", id="account")

    @on(Button.Pressed)
    async def handle_navigation(self, event: Button.Pressed) -> None:
        #await self.app.handle_navigation(event.button.id) # Will cause deadlock
        self.app.run_worker(self.app.handle_navigation(event.button.id))   # This workaround can cause race when pressing buttons too fast e.g. ActiveModeError: Can't remove active mode 'main'


class CartScreen(Screen):
    """Cart should be pushed only in the main mode."""

    BINDINGS = [
        Binding("escape", "app.pop_screen", "Go back"),
    ]

    def compose(self) -> ComposeResult:
        yield GlobalButtons()
        yield Static("Cart")
        yield Footer()

class AccountScreen(Screen):
    """Account should be pushed only in the main mode."""

    BINDINGS = [
        Binding("escape", "app.pop_screen", "Go back"),
    ]

    def compose(self) -> ComposeResult:
        yield GlobalButtons()
        yield Static("Account")
        yield Footer()


class DashboardScreen(Screen):
    def compose(self) -> ComposeResult:
        yield GlobalButtons()
        yield Static("Dashboard")
        yield Footer()


class ConfigScreen(Screen):
    def compose(self) -> ComposeResult:
        yield GlobalButtons()
        yield Static("Config screen")
        yield Footer()


class MyApp(App):
    BINDINGS = [
        Binding("d", "handle_navigation('main')", "Dashboard"),
        Binding("c", "handle_navigation('config')", "Config"),
        Binding("t", "handle_navigation('cart')", "Cart"),
        Binding("u", "handle_navigation('account')", "Account"),
    ]
    """Global bindings works just fine with no deadlock."""

    MODES = {
        "config": ConfigScreen,
        "main": DashboardScreen,
    }

    def on_mount(self) -> None:
        self.switch_mode("main")

    async def action_handle_navigation(self, requested: Literal["cart", "account", "config", "main"]) -> None:
        await self.handle_navigation(requested)

    async def handle_navigation(self, requested: Literal["cart", "account", "config", "main"]) -> None:
        main_push_transitions = {
            ("main", "cart"): CartScreen,
            ("main", "account"): AccountScreen,
        }

        config_switch_to_main_and_push_transitions = {
            ("config", "cart"): CartScreen,
            ("config", "account"): AccountScreen,
        }

        if isinstance(self.screen, CartScreen) and requested == "cart" or isinstance(self.screen, AccountScreen) and requested == "account":
            # nothing to do
            return

        if self.current_mode == "main" and requested == "main":
            self.get_screen_from_current_stack(DashboardScreen).pop_until_active()
            return

        if (key := (self.current_mode, requested)) in main_push_transitions:
            await self.push_screen(main_push_transitions[key]())
            return

        if (key := (self.current_mode, requested)) in config_switch_to_main_and_push_transitions:
            # Transitioning from config mode to main mode before pushing the screen
            with self.app.batch_update():
                await self.switch_mode_with_reset("main")
                await self.push_screen(config_switch_to_main_and_push_transitions[key]())
            return

        if requested in {"main", "config"}:
            await self.switch_mode_with_reset(requested)
            return

        raise AssertionError(f"Unexpected navigation request: {requested}")

    def switch_mode_with_reset(self, new_mode: str) -> AwaitComplete:
        """
        Switch mode and reset all other active modes.

        The `App.switch_mode` method from Textual keeps the previous mode in the stack.
        This method allows to switch to a new mode and have only a new mode in the stack without keeping
        any screen stacks of other modes. The next switch mode will be like fresh switch mode.

        Args:
        ----
            new_mode: The mode to switch to.
        """

        async def impl() -> None:
            await self.switch_mode(new_mode)

            modes_to_keep = (new_mode, "_default")
            modes_to_reset = [mode for mode in self._screen_stacks if mode not in modes_to_keep]
            assert all(mode in self.MODES for mode in modes_to_reset), "Unexpected mode in modes_to_reset"
            await self.reset_mode(*modes_to_reset)

        return AwaitComplete(impl()).call_next(self)

    def reset_mode(self, *modes: str) -> AwaitComplete:
        async def impl() -> None:
            for mode in modes:
                await self.remove_mode(mode)
                self.add_mode(mode, self.MODES[mode])

        return AwaitComplete(impl()).call_next(self)

    def get_screen_from_current_stack(self, screen: type[Screen[ScreenResultType]]) -> Screen[ScreenResultType]:
        for current_screen in self.screen_stack:
            if isinstance(current_screen, screen):
                return current_screen
        raise ScreenError(f"Screen {screen} not found in stack")

MyApp().run()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions