diff --git a/clive/__private/core/app_state.py b/clive/__private/core/app_state.py index a18dda2df19519c2e4cb082adf48a2f3b806356e..ecb2f02859a22814f9d43b9e574011c10113d793 100644 --- a/clive/__private/core/app_state.py +++ b/clive/__private/core/app_state.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from clive.__private.core.wallet_container import WalletContainer from clive.__private.core.world import World -LockSource = Literal["beekeeper_monitoring_thread", "unknown"] +LockSource = Literal["beekeeper_wallet_lock_status_update_worker", "unknown"] @dataclass @@ -34,16 +34,16 @@ class AppState: self._is_unlocked = True if wallets: await self.world.beekeeper_manager.set_wallets(wallets) - self.world.on_going_into_unlocked_mode() + await self.world.on_going_into_unlocked_mode() logger.info("Mode switched to UNLOCKED.") - def lock(self, source: LockSource = "unknown") -> None: + async def lock(self, source: LockSource = "unknown") -> None: if not self._is_unlocked: return self._is_unlocked = False self.world.beekeeper_manager.clear_wallets() - self.world.on_going_into_locked_mode(source) + await self.world.on_going_into_locked_mode(source) logger.info("Mode switched to LOCKED.") def __hash__(self) -> int: diff --git a/clive/__private/core/async_guard.py b/clive/__private/core/async_guard.py new file mode 100644 index 0000000000000000000000000000000000000000..e9af3707f88de0cf5c501f44650de0467b238f08 --- /dev/null +++ b/clive/__private/core/async_guard.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import asyncio +import contextlib +from typing import Final, Generator + +from clive.__private.logger import logger +from clive.exceptions import CliveError + + +class AsyncGuardNotAvailableError(CliveError): + """Raised when trying to acquire a guard that is already acquired.""" + + MESSAGE: Final[str] = "Guard is already acquired." + + def __init__(self) -> None: + super().__init__(self.MESSAGE) + + +class AsyncGuard: + """ + A helper class to manage an asynchronous event-like lock, ensuring exclusive execution. + + Use this for scenarios where you want to prevent concurrent execution of an async task. + When the guard is acquired by some other task, the guarded block could not execute, error will be raised instead. + Can be used together with `suppress`. Look into its documentation for more details. + """ + + def __init__(self) -> None: + self._event = asyncio.Event() + + @property + def is_available(self) -> bool: + """Return True if the event lock is currently available (not acquired).""" + return not self._event.is_set() + + def acquire(self) -> None: + if not self.is_available: + raise AsyncGuardNotAvailableError + + self._event.set() + + def release(self) -> None: + self._event.clear() + + @contextlib.contextmanager + def guard(self) -> Generator[None]: + self.acquire() + try: + yield + finally: + self.release() + + @staticmethod + @contextlib.contextmanager + def suppress() -> Generator[None]: + """ + Suppresses the AsyncGuardNotAvailable error raised by the guard. + + Use this together with `acquire` or `guard` to skip code execution when the guard is acquired. + """ + try: + yield + except AsyncGuardNotAvailableError: + logger.debug("Suppressing AsyncGuardNotAvailableError.") diff --git a/clive/__private/core/commands/lock.py b/clive/__private/core/commands/lock.py index 894b1fd92e7b52d89f7195192853cbd961efc16b..18df59c4b95e32e038938a7551d72bfac1afdc89 100644 --- a/clive/__private/core/commands/lock.py +++ b/clive/__private/core/commands/lock.py @@ -21,4 +21,4 @@ class Lock(Command): async def _execute(self) -> None: await self.session.lock_all() if self.app_state: - self.app_state.lock() + await self.app_state.lock() diff --git a/clive/__private/core/commands/sync_state_with_beekeeper.py b/clive/__private/core/commands/sync_state_with_beekeeper.py index d98c766accec14e1a22d8f13c6198557d9e8ebae..57113323dfadc9b1bc11bda793f2afae3b728d5f 100644 --- a/clive/__private/core/commands/sync_state_with_beekeeper.py +++ b/clive/__private/core/commands/sync_state_with_beekeeper.py @@ -59,6 +59,6 @@ class SyncStateWithBeekeeper(Command): if user_wallet and encryption_wallet: await self.app_state.unlock(WalletContainer(user_wallet, encryption_wallet)) elif not user_wallet and not encryption_wallet: - self.app_state.lock(self.source) + await self.app_state.lock(self.source) else: raise InvalidWalletStateError(self) diff --git a/clive/__private/core/error_handlers/tui_error_handler.py b/clive/__private/core/error_handlers/tui_error_handler.py index b146b6361bca73ab7c403e036dbe729c8922219c..f20acb18875ec0e10e632b977f3688d240b587db 100644 --- a/clive/__private/core/error_handlers/tui_error_handler.py +++ b/clive/__private/core/error_handlers/tui_error_handler.py @@ -36,4 +36,7 @@ class TUIErrorHandler(ErrorHandlerContextManager[Exception]): return ResultNotAvailable(error) def _switch_to_locked_mode(self) -> None: - self._app.world.app_state.lock() + async def impl() -> None: + await self._app.switch_mode_into_locked(save_profile=False) + + self._app.run_worker_with_screen_remove_guard(impl()) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 3a05ad97d185977522403d71cbc03d7eba8f8fe6..c02536ad07446afe63ae0b77732c0b655e9bdc74 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -16,9 +16,6 @@ from clive.__private.core.node import Node from clive.__private.core.profile import Profile from clive.__private.core.wallet_container import WalletContainer from clive.__private.ui.clive_dom_node import CliveDOMNode -from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm -from clive.__private.ui.screens.dashboard import Dashboard -from clive.__private.ui.screens.unlock import Unlock from clive.exceptions import ProfileNotLoadedError if TYPE_CHECKING: @@ -116,7 +113,7 @@ class World: self._node.teardown() self._beekeeper_manager.teardown() - self.app_state.lock() + await self.app_state.lock() self._profile = None self._node = None @@ -173,22 +170,22 @@ class World: self._profile = new_profile await self._update_node() - def on_going_into_locked_mode(self, source: LockSource) -> None: + async def on_going_into_locked_mode(self, source: LockSource) -> None: """Triggered when the application is going into the locked mode.""" if self._is_during_setup or self._is_during_closure: return - self._on_going_into_locked_mode(source) + await self._on_going_into_locked_mode(source) - def on_going_into_unlocked_mode(self) -> None: + async def on_going_into_unlocked_mode(self) -> None: """Triggered when the application is going into the unlocked mode.""" if self._is_during_setup or self._is_during_closure: return - self._on_going_into_unlocked_mode() + await self._on_going_into_unlocked_mode() - def _on_going_into_locked_mode(self, _: LockSource) -> None: + async def _on_going_into_locked_mode(self, _: LockSource) -> None: """Override this method to hook when clive goes into the locked mode.""" - def _on_going_into_unlocked_mode(self) -> None: + async def _on_going_into_unlocked_mode(self) -> None: """Override this method to hook when clive goes into the unlocked mode.""" @asynccontextmanager @@ -270,35 +267,12 @@ class TUIWorld(World, CliveDOMNode): def _watch_profile(self, profile: Profile) -> None: self.node.change_related_profile(profile) - def _on_going_into_locked_mode(self, source: LockSource) -> None: - if source == "beekeeper_monitoring_thread": - self.app.notify("Switched to the LOCKED mode due to timeout.", timeout=10) - self.app.pause_refresh_node_data_interval() - self.app.pause_refresh_alarms_data_interval() - self.node.cached.clear() - - async def lock() -> None: - self._add_welcome_modes() - await self.app.switch_mode("unlock") - await self._restart_dashboard_mode() - await self.switch_profile(None) - - self.app.run_worker(lock()) - - def _on_going_into_unlocked_mode(self) -> None: - self.app.trigger_app_state_watchers() + async def _on_going_into_locked_mode(self, source: LockSource) -> None: + await self.app._switch_mode_into_locked(source) def _setup_commands(self) -> TUICommands: return TUICommands(self) - def _add_welcome_modes(self) -> None: - self.app.add_mode("create_profile", CreateProfileForm) - self.app.add_mode("unlock", Unlock) - - async def _restart_dashboard_mode(self) -> None: - await self.app.remove_mode("dashboard") - self.app.add_mode("dashboard", Dashboard) - def _update_profile_related_reactive_attributes(self) -> None: # There's no proper way to add some proxy reactive property on textual reactives that could raise error if # not set yet, and still can be watched. See: https://github.com/Textualize/textual/discussions/4007 diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index df68a9728c4c2cde003aa6808e079f6464d68e57..61a4731567d1ad1d7a38103da80572acfe758498 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -4,17 +4,19 @@ import asyncio import math import traceback from contextlib import asynccontextmanager, contextmanager -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Awaitable, TypeVar, cast from beekeepy.exceptions import CommunicationError from textual import on, work from textual._context import active_app from textual.app import App +from textual.await_complete import AwaitComplete from textual.binding import Binding from textual.notifications import Notification, Notify, SeverityLevel from textual.reactive import var from textual.worker import WorkerCancelled +from clive.__private.core.async_guard import AsyncGuard from clive.__private.core.constants.terminal import TERMINAL_HEIGHT, TERMINAL_WIDTH from clive.__private.core.constants.tui.bindings import APP_QUIT_KEY_BINDING from clive.__private.core.profile import Profile @@ -37,6 +39,8 @@ if TYPE_CHECKING: from textual.screen import Screen, ScreenResultType from textual.worker import Worker + from clive.__private.core.app_state import LockSource + from clive.__private.ui.types import CliveModes UpdateScreenResultT = TypeVar("UpdateScreenResultT") @@ -85,11 +89,27 @@ class Clive(App[int]): super().__init__(*args, **kwargs) self._world: TUIWorld | None = None + self._screen_remove_guard = AsyncGuard() + """ + Due to https://github.com/Textualize/textual/issues/5008. + + Any action that involves removing a screen like remove_mode/switch_screen/pop_screen + cannot be awaited in the @on handler like Button.Pressed because it will deadlock the app. + Workaround is to not await mentioned action or run it in a separate task if something later needs to await it. + This workaround can create race conditions, so we need to guard against it. + """ + @property def world(self) -> TUIWorld: assert self._world is not None, "World is not set yet." return self._world + @property + def current_mode(self) -> CliveModes: + mode = super().current_mode + assert mode in self.MODES, f"Mode {mode} is not in the list of modes" + return cast("CliveModes", mode) + @staticmethod def app_instance() -> Clive: return cast(Clive, active_app.get()) @@ -177,7 +197,7 @@ class Clive(App[int]): should_enable_debug_loop = safe_settings.dev.should_enable_debug_loop if should_enable_debug_loop: debug_loop_period_secs = safe_settings.dev.debug_loop_period_secs - self.set_interval(debug_loop_period_secs, self.__debug_log) + self.set_interval(debug_loop_period_secs, self._debug_log) if Profile.is_any_profile_saved(): self.switch_mode("unlock") @@ -291,12 +311,68 @@ class Clive(App[int]): @work(name="beekeeper wallet lock status update worker") async def update_wallet_lock_status_from_beekeeper(self) -> None: if self.world._beekeeper_manager: - await self.world.commands.sync_state_with_beekeeper("beekeeper_monitoring_thread") + await self.world.commands.sync_state_with_beekeeper("beekeeper_wallet_lock_status_update_worker") + + def switch_mode_with_reset(self, new_mode: CliveModes) -> 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 other screen stacks. + + Args: + ---- + new_mode: The mode to switch to. + """ + + async def impl() -> None: + logger.debug(f"Switching mode from: `{self.current_mode}` to: `{new_mode}`") + 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(*cast("list[CliveModes]", modes_to_reset)) + + return AwaitComplete(impl()).call_next(self) - async def __debug_log(self) -> None: + def reset_mode(self, *modes: CliveModes) -> AwaitComplete: + async def impl() -> None: + logger.debug(f"Resetting modes: {modes}") + for mode in modes: + await self.remove_mode(mode) + self.add_mode(mode, self.MODES[mode]) + + return AwaitComplete(impl()).call_next(self) + + async def switch_mode_into_locked(self, *, save_profile: bool = True) -> None: + if save_profile: + await self.world.commands.save_profile() + await self.world.commands.lock() + + def run_worker_with_guard(self, awaitable: Awaitable[None], guard: AsyncGuard) -> None: + """Run work in a worker with a guard. It means that the work will be executed only if the guard is available.""" + + async def work_with_release() -> None: + try: + await awaitable + finally: + guard.release() + + with guard.suppress(): + guard.acquire() + self.run_worker(work_with_release()) + + def run_worker_with_screen_remove_guard(self, awaitable: Awaitable[None]) -> None: + self.run_worker_with_guard(awaitable, self._screen_remove_guard) + + async def _debug_log(self) -> None: logger.debug("===================== DEBUG =====================") logger.debug(f"Currently focused: {self.focused}") + logger.debug(f"Current mode: {self.current_mode}") logger.debug(f"Screen stack: {self.screen_stack}") + logger.debug(f"Screen stacks: {self._screen_stacks}") if self.world.is_profile_available: cached_dgpo = self.world.node.cached.dynamic_global_properties_or_none @@ -335,3 +411,19 @@ class Clive(App[int]): def _retrigger_update_alarms_data(self) -> None: if self.is_worker_group_empty("alarms_data"): self.update_alarms_data() + + async def _switch_mode_into_locked(self, source: LockSource) -> None: + if source == "beekeeper_wallet_lock_status_update_worker": + self.notify("Switched to the LOCKED mode due to timeout.", timeout=10) + + self.pause_refresh_node_data_interval() + self.pause_refresh_alarms_data_interval() + self.world.node.cached.clear() + await self.switch_mode_with_reset("unlock") + await self.world.switch_profile(None) + + async def _switch_mode_into_unlocked(self) -> None: + await self.switch_mode_with_reset("dashboard") + self.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) + self.resume_refresh_node_data_interval() + self.resume_refresh_alarms_data_interval() diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index a09cff3951d3a83c35595b77167b68bd0b59193b..6b5ae016ace6a451846a8f18944dfef5482f75e0 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -26,20 +26,21 @@ class CreateProfileForm(Form): async def exit_form(self) -> None: # when this form is displayed during onboarding, there is no previous screen to go back to # so this method won't be called - await self.app.switch_mode("unlock") - self.app.remove_mode("create_profile") + + async def impl() -> None: + await self.app.switch_mode_with_reset("unlock") + + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) async def finish_form(self) -> None: - async def handle_modes() -> None: - await self.app.switch_mode("dashboard") - self.app.remove_mode("create_profile") - self.app.remove_mode("unlock") - - self.add_post_action( - lambda: self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True), - self.app.resume_refresh_alarms_data_interval, - ) - await self.execute_post_actions() - await handle_modes() - self.profile.enable_saving() - await self.commands.save_profile() + async def impl() -> None: + await self.execute_post_actions() + self.profile.enable_saving() + await self.commands.save_profile() + await self.app._switch_mode_into_unlocked() + + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) diff --git a/clive/__private/ui/screens/dashboard/dashboard.py b/clive/__private/ui/screens/dashboard/dashboard.py index 05f21a4ba9e1ee09eb2c0c76f76671b8f466e33d..3c7285021d7d89ce039dc448f060e2b1501b6a35 100644 --- a/clive/__private/ui/screens/dashboard/dashboard.py +++ b/clive/__private/ui/screens/dashboard/dashboard.py @@ -38,7 +38,6 @@ if TYPE_CHECKING: from textual.app import ComposeResult from textual.widget import Widget - from clive.__private.core.app_state import AppState from clive.__private.core.commands.data_retrieval.update_node_data import Manabar from clive.__private.core.profile import Profile from clive.__private.ui.widgets.buttons.clive_button import CliveButtonVariant @@ -291,7 +290,6 @@ class Dashboard(BaseScreen): def on_mount(self) -> None: self.watch(self.world, "profile_reactive", self._update_account_containers) - self.watch(self.world, "app_state", self._update_mode) async def _update_account_containers(self, profile: Profile) -> None: if self.tracked_accounts == self._previous_tracked_accounts: @@ -315,9 +313,6 @@ class Dashboard(BaseScreen): await accounts_container.query("*").remove() await accounts_container.mount_all(widgets_to_mount) - def _update_mode(self, app_state: AppState) -> None: - self.is_unlocked = app_state.is_unlocked - @CliveScreen.prevent_action_when_no_working_account() @CliveScreen.prevent_action_when_no_accounts_node_data() def action_operations(self) -> None: @@ -334,8 +329,8 @@ class Dashboard(BaseScreen): self.app.push_screen(AddTrackedAccountDialog()) async def action_switch_mode_into_locked(self) -> None: - await self.app.world.commands.save_profile() - await self.app.world.commands.lock() + with self.app._screen_remove_guard.suppress(), self.app._screen_remove_guard.guard(): + await self.app.switch_mode_into_locked() @property def has_working_account(self) -> bool: diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 09886023e1aa94d60b6c0f1628999c2690ef7365..effd8022c6c54314b30514c8c5c7fba459bea87c 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -1,12 +1,10 @@ from __future__ import annotations -import contextlib from datetime import timedelta from typing import TYPE_CHECKING from beekeepy.exceptions import InvalidPasswordError from textual import on -from textual.app import InvalidModeError from textual.containers import Horizontal from textual.validation import Integer from textual.widgets import Button, Checkbox, Static @@ -15,7 +13,6 @@ from clive.__private.core.constants.tui.messages import PRESS_HELP_MESSAGE from clive.__private.core.profile import Profile from clive.__private.logger import logger from clive.__private.ui.clive_widget import CliveWidget -from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen from clive.__private.ui.widgets.buttons import CliveButton @@ -108,45 +105,43 @@ class Unlock(BaseScreen): @on(Button.Pressed, "#unlock-button") @on(CliveInput.Submitted) async def unlock(self) -> None: - password_input = self.query_exactly_one(PasswordInput) - select_profile = self.query_exactly_one(SelectProfile) - lock_after_time = self.query_exactly_one(LockAfterTime) - - if not password_input.validate_passed() or not lock_after_time.is_valid: - return - - try: - await self.world.load_profile( - profile_name=select_profile.value_ensure, - password=password_input.value_or_error, - permanent=lock_after_time.should_stay_unlocked, - time=lock_after_time.lock_duration, - ) - except InvalidPasswordError: - logger.error( - f"Profile `{select_profile.value_ensure}` was not unlocked " - "because entered password is invalid, skipping switching modes" - ) - return - - await self.app.switch_mode("dashboard") - self._remove_welcome_modes() - self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) - self.app.resume_refresh_node_data_interval() - self.app.resume_refresh_alarms_data_interval() + async def impl() -> None: + password_input = self.query_exactly_one(PasswordInput) + select_profile = self.query_exactly_one(SelectProfile) + lock_after_time = self.query_exactly_one(LockAfterTime) + + if not password_input.validate_passed() or not lock_after_time.is_valid: + return + + try: + await self.world.load_profile( + profile_name=select_profile.value_ensure, + password=password_input.value_or_error, + permanent=lock_after_time.should_stay_unlocked, + time=lock_after_time.lock_duration, + ) + except InvalidPasswordError: + logger.error( + f"Profile `{select_profile.value_ensure}` was not unlocked " + "because entered password is invalid, skipping switching modes" + ) + return + + await self.app._switch_mode_into_unlocked() + + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) @on(Button.Pressed, "#new-profile-button") async def create_new_profile(self) -> None: - with contextlib.suppress(InvalidModeError): - # If the mode is already added, we don't want to add it again - self.app.add_mode("create_profile", CreateProfileForm) + async def impl() -> None: + await self.app.switch_mode_with_reset("create_profile") - await self.app.switch_mode("create_profile") + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) @on(SelectProfile.Changed) def clear_password_input(self) -> None: self.query_exactly_one(PasswordInput).clear_validation() - - def _remove_welcome_modes(self) -> None: - self.app.remove_mode("unlock") - self.app.remove_mode("create_profile") diff --git a/clive/__private/ui/types.py b/clive/__private/ui/types.py index 4b8247ebb3c0ae88b816efb51df7097069f9ba14..996bef40e98c98caf0ac8526d1372027e9da021a 100644 --- a/clive/__private/ui/types.py +++ b/clive/__private/ui/types.py @@ -1,8 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias if TYPE_CHECKING: from textual.binding import ActiveBinding ActiveBindingsMap: TypeAlias = dict[str, ActiveBinding] + + CliveModes = Literal["unlock", "create_profile", "dashboard"] diff --git a/clive/__private/ui/widgets/clive_basic/clive_header.py b/clive/__private/ui/widgets/clive_basic/clive_header.py index a223159639494a099e9068432e585194db1da333..8734569bf39d15602eab70e32cc63dbe5514d4bb 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_header.py +++ b/clive/__private/ui/widgets/clive_basic/clive_header.py @@ -203,8 +203,9 @@ class LockStatus(DynamicOneLineButtonUnfocusable): @on(OneLineButton.Pressed) async def lock_wallet(self) -> None: - await self.commands.save_profile() - await self.commands.lock() + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(self.app.switch_mode_into_locked()) def _wallet_to_locked_changed(self) -> None: self.post_message(self.WalletLocked())