From a01b090c2b067a6e6134767d48eaaae823661264 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Wed, 17 Jul 2024 17:58:24 +0300 Subject: [PATCH] feat: improve lock mechanisms * improve lock mechanisms * use /run/ahriman for sockett * better water --- docs/faq.rst | 4 +- package/archlinux/ahriman.tmpfiles | 3 +- src/ahriman/application/ahriman.py | 13 +- src/ahriman/application/lock.py | 128 ++++++++++---- src/ahriman/models/waiter.py | 83 +++++++-- tests/ahriman/application/test_ahriman.py | 3 +- tests/ahriman/application/test_lock.py | 198 +++++++++++++++------- tests/ahriman/models/test_waiter.py | 56 +++++- 8 files changed, 370 insertions(+), 118 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index c17ce015..dcb9b268 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -475,7 +475,7 @@ The following environment variables are supported: * ``AHRIMAN_REPOSITORY`` - repository name, default is ``aur-clone``. * ``AHRIMAN_REPOSITORY_SERVER`` - optional override for the repository URL. Useful if you would like to download packages from remote instead of local filesystem. * ``AHRIMAN_REPOSITORY_ROOT`` - repository root. Because of filesystem rights it is required to override default repository root. By default, it uses ``ahriman`` directory inside ahriman's home, which can be passed as mount volume. -* ``AHRIMAN_UNIX_SOCKET`` - full path to unix socket which is used by web server, default is empty. Note that more likely you would like to put it inside ``AHRIMAN_REPOSITORY_ROOT`` directory (e.g. ``/var/lib/ahriman/ahriman/ahriman-web.sock``) or to ``/tmp``. +* ``AHRIMAN_UNIX_SOCKET`` - full path to unix socket which is used by web server, default is empty. Note that more likely you would like to put it inside ``AHRIMAN_REPOSITORY_ROOT`` directory (e.g. ``/var/lib/ahriman/ahriman/ahriman-web.sock``) or to ``/run/ahriman``. * ``AHRIMAN_USER`` - ahriman user, usually must not be overwritten, default is ``ahriman``. * ``AHRIMAN_VALIDATE_CONFIGURATION`` - if set (default) validate service configuration. @@ -1318,7 +1318,7 @@ How to enable basic authorization .. code-block:: ini [web] - unix_socket = /var/lib/ahriman/ahriman-web.sock + unix_socket = /run/ahriman/ahriman-web.sock This socket path must be available for web service instance and must be available for all application instances (e.g. in case if you are using docker container - see above - you need to make sure that the socket is passed to the root filesystem). diff --git a/package/archlinux/ahriman.tmpfiles b/package/archlinux/ahriman.tmpfiles index 6a1b6bad..1929a150 100644 --- a/package/archlinux/ahriman.tmpfiles +++ b/package/archlinux/ahriman.tmpfiles @@ -1 +1,2 @@ -d /var/lib/ahriman 0755 ahriman ahriman \ No newline at end of file +d /var/lib/ahriman 0755 ahriman ahriman +d /run/ahriman 0755 ahriman ahriman \ No newline at end of file diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 4713fdf8..510d63db 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -19,7 +19,6 @@ # # pylint: disable=too-many-lines import argparse -import tempfile from pathlib import Path from typing import TypeVar @@ -73,8 +72,7 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/") / "etc" / "ahriman.ini") parser.add_argument("--force", help="force run, remove file lock", action="store_true") - parser.add_argument("-l", "--lock", help="lock file", type=Path, - default=Path(tempfile.gettempdir()) / "ahriman.lock") + parser.add_argument("-l", "--lock", help="lock file", type=Path, default=Path("ahriman.pid")) parser.add_argument("--log-handler", help="explicit log handler specification. If none set, the handler will be " "guessed from environment", type=LogHandler, choices=enum_values(LogHandler)) @@ -628,8 +626,7 @@ def _set_repo_daemon_parser(root: SubParserAction) -> argparse.ArgumentParser: parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, " "-yy to force refresh even if up to date", action="count", default=False) - parser.set_defaults(handler=handlers.Daemon, exit_code=False, - lock=Path(tempfile.gettempdir()) / "ahriman-daemon.lock", package=[]) + parser.set_defaults(handler=handlers.Daemon, exit_code=False, lock=Path("ahriman-daemon.pid"), package=[]) return parser @@ -880,7 +877,7 @@ def _set_service_clean_parser(root: SubParserAction) -> argparse.ArgumentParser: action=argparse.BooleanOptionalAction, default=False) parser.add_argument("--pacman", help="clear directory with pacman local database cache", action=argparse.BooleanOptionalAction, default=False) - parser.set_defaults(handler=handlers.Clean, quiet=True, unsafe=True) + parser.set_defaults(handler=handlers.Clean, lock=None, quiet=True, unsafe=True) return parser @@ -1139,8 +1136,8 @@ def _set_web_parser(root: SubParserAction) -> argparse.ArgumentParser: argparse.ArgumentParser: created argument parser """ parser = root.add_parser("web", help="web server", description="start web server", formatter_class=_formatter) - parser.set_defaults(handler=handlers.Web, architecture="", lock=Path(tempfile.gettempdir()) / "ahriman-web.lock", - report=False, repository="", parser=_parser) + parser.set_defaults(handler=handlers.Web, architecture="", lock=Path("ahriman-web.pid"), report=False, + repository="", parser=_parser) return parser diff --git a/src/ahriman/application/lock.py b/src/ahriman/application/lock.py index 58b0cdc3..526dbd56 100644 --- a/src/ahriman/application/lock.py +++ b/src/ahriman/application/lock.py @@ -18,7 +18,10 @@ # along with this program. If not, see . # import argparse +import fcntl +import os +from io import TextIOWrapper from pathlib import Path from types import TracebackType from typing import Literal, Self @@ -36,7 +39,7 @@ from ahriman.models.waiter import Waiter class Lock(LazyLogging): """ - wrapper for application lock file + wrapper for application lock file. Credits for idea to https://github.com/bmhatfield/python-pidfile.git Attributes: force(bool): remove lock file on start if any @@ -70,8 +73,13 @@ class Lock(LazyLogging): repository_id(RepositoryId): repository unique identifier configuration(Configuration): configuration instance """ - self.path: Path | None = \ - args.lock.with_stem(f"{args.lock.stem}_{repository_id.id}") if args.lock is not None else None + self.path: Path | None = None + if args.lock is not None: + self.path = args.lock.with_stem(f"{args.lock.stem}_{repository_id.id}") + if not self.path.is_absolute(): + # prepend full path to the lock file + self.path = Path("/") / "run" / "ahriman" / self.path + self._pid_file: TextIOWrapper | None = None self.force: bool = args.force self.unsafe: bool = args.unsafe @@ -80,6 +88,72 @@ class Lock(LazyLogging): self.paths = configuration.repository_paths self.reporter = Client.load(repository_id, configuration, report=args.report) + @staticmethod + def perform_lock(fd: int) -> bool: + """ + perform file lock + + Args: + fd(int): file descriptor: + + Returns: + bool: True in case if file is locked and False otherwise + """ + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + return False + + return True + + def _open(self) -> None: + """ + create lock file + """ + if self.path is None: + return + self._pid_file = self.path.open("a+") + + def _watch(self) -> bool: + """ + watch until lock disappear + + Returns: + bool: True in case if file is locked and False otherwise + """ + # there are reasons why we are not using inotify here. First of all, if we would use it, it would bring to + # race conditions because multiple processes will be notified at the same time. Secondly, it is good library, + # but platform-specific, and we only need to check if file exists + if self._pid_file is None: + return False + + waiter = Waiter(self.wait_timeout) + return bool(waiter.wait(lambda fd: not self.perform_lock(fd), self._pid_file.fileno())) + + def _write(self, *, is_locked: bool = False) -> None: + """ + write pid to the lock file + + Args: + is_locked(bool, optional): indicates if file was already locked or not (Default value = False) + + Raises: + DuplicateRunError: if it cannot lock PID file + """ + if self._pid_file is None: + return + if not is_locked: + if not self.perform_lock(self._pid_file.fileno()): + raise DuplicateRunError + + self._pid_file.seek(0) # reset position and remove file content if any + self._pid_file.truncate() + + self._pid_file.write(str(os.getpid())) # write current pid + self._pid_file.flush() # flush data to disk + + self._pid_file.seek(0) # reset position again + def check_user(self) -> None: """ check if current user is actually owner of ahriman root @@ -100,46 +174,33 @@ class Lock(LazyLogging): """ remove lock file """ - if self.path is None: - return - self.path.unlink(missing_ok=True) + if self._pid_file is not None: # close file descriptor + try: + self._pid_file.close() + except IOError: + pass # suppress any IO errors occur + if self.path is not None: # remove lock file + self.path.unlink(missing_ok=True) - def create(self) -> None: + def lock(self) -> None: """ - create lock file - - Raises: - DuplicateRunError: if lock exists and no force flag supplied + create pid file """ - if self.path is None: - return - try: - self.path.touch(exist_ok=self.force) - except FileExistsError: - raise DuplicateRunError from None - - def watch(self) -> None: - """ - watch until lock disappear - """ - # there are reasons why we are not using inotify here. First of all, if we would use it, it would bring to - # race conditions because multiple processes will be notified in the same time. Secondly, it is good library, - # but platform-specific, and we only need to check if file exists - if self.path is None: - return - - waiter = Waiter(self.wait_timeout) - waiter.wait(self.path.is_file) + if self.force: # remove lock if force flag is set + self.clear() + self._open() + is_locked = self._watch() + self._write(is_locked=is_locked) def __enter__(self) -> Self: """ default workflow is the following: #. Check user UID - #. Check if there is lock file #. Check web status watcher status + #. Open lock file #. Wait for lock file to be free - #. Create lock file and directory tree + #. Write current PID to the lock file #. Report to status page if enabled Returns: @@ -147,8 +208,7 @@ class Lock(LazyLogging): """ self.check_user() self.check_version() - self.watch() - self.create() + self.lock() self.reporter.status_update(BuildStatusEnum.Building) return self diff --git a/src/ahriman/models/waiter.py b/src/ahriman/models/waiter.py index 7f804315..8a22c008 100644 --- a/src/ahriman/models/waiter.py +++ b/src/ahriman/models/waiter.py @@ -21,27 +21,87 @@ import time from collections.abc import Callable from dataclasses import dataclass, field -from typing import ParamSpec +from typing import Literal, ParamSpec Params = ParamSpec("Params") +@dataclass(frozen=True) +class WaiterResult: + """ + representation of a waiter result. This class should not be used directly, use derivatives instead + + Attributes: + took(float): consumed time in seconds + """ + + took: float + + def __bool__(self) -> bool: + """ + indicates whether the waiter completed with success or not + + Raises: + NotImplementedError: not implemented method + """ + raise NotImplementedError + + def __float__(self) -> float: + """ + extract time spent to retrieve the result in seconds + + Returns: + float: consumed time in seconds + """ + return self.took + + +class WaiterTaskFinished(WaiterResult): + """ + a waiter result used to notify that the task has been completed successfully + """ + + def __bool__(self) -> Literal[True]: + """ + indicates whether the waiter completed with success or not + + Returns: + Literal[True]: always False + """ + return True + + +class WaiterTimedOut(WaiterResult): + """ + a waiter result used to notify that the waiter run out of time + """ + + def __bool__(self) -> Literal[False]: + """ + indicates whether the waiter completed with success or not + + Returns: + Literal[False]: always False + """ + return False + + @dataclass(frozen=True) class Waiter: """ simple waiter implementation Attributes: - interval(int): interval in seconds between checks + interval(float): interval in seconds between checks start_time(float): monotonic time of the waiter start. More likely must not be assigned explicitly - wait_timeout(int): timeout in seconds to wait for. Negative value will result in immediate exit. Zero value + wait_timeout(float): timeout in seconds to wait for. Negative value will result in immediate exit. Zero value means infinite timeout """ - wait_timeout: int + wait_timeout: float start_time: float = field(default_factory=time.monotonic, kw_only=True) - interval: int = field(default=10, kw_only=True) + interval: float = field(default=10, kw_only=True) def is_timed_out(self) -> bool: """ @@ -51,10 +111,10 @@ class Waiter: bool: True in case current monotonic time is more than :attr:`start_time` and :attr:`wait_timeout` doesn't equal to 0 """ - since_start: float = time.monotonic() - self.start_time + since_start = time.monotonic() - self.start_time return self.wait_timeout != 0 and since_start > self.wait_timeout - def wait(self, in_progress: Callable[Params, bool], *args: Params.args, **kwargs: Params.kwargs) -> float: + def wait(self, in_progress: Callable[Params, bool], *args: Params.args, **kwargs: Params.kwargs) -> WaiterResult: """ wait until requirements are not met @@ -64,9 +124,12 @@ class Waiter: **kwargs(Params.kwargs): keyword arguments for check call Returns: - float: consumed time in seconds + WaiterResult: consumed time in seconds """ - while not self.is_timed_out() and in_progress(*args, **kwargs): + while not (timed_out := self.is_timed_out()) and in_progress(*args, **kwargs): time.sleep(self.interval) + took = time.monotonic() - self.start_time - return time.monotonic() - self.start_time + if timed_out: + return WaiterTimedOut(took) + return WaiterTaskFinished(took) diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index d4121150..06acc232 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -1097,9 +1097,10 @@ def test_subparsers_repo_update_option_refresh(parser: argparse.ArgumentParser) def test_subparsers_service_clean(parser: argparse.ArgumentParser) -> None: """ - service-clean command must imply quiet and unsafe + service-clean command must imply lock, quiet and unsafe """ args = parser.parse_args(["service-clean"]) + assert args.lock is None assert args.quiet assert args.unsafe diff --git a/tests/ahriman/application/test_lock.py b/tests/ahriman/application/test_lock.py index 1a0b3b03..2db2c8cd 100644 --- a/tests/ahriman/application/test_lock.py +++ b/tests/ahriman/application/test_lock.py @@ -1,10 +1,12 @@ import argparse +import fcntl +import os import pytest -import tempfile from pathlib import Path from pytest_mock import MockerFixture -from unittest.mock import call as MockCall +from tempfile import NamedTemporaryFile +from unittest.mock import MagicMock, call as MockCall from ahriman import __version__ from ahriman.application.lock import Lock @@ -22,14 +24,113 @@ def test_path(args: argparse.Namespace, configuration: Configuration) -> None: assert Lock(args, repository_id, configuration).path is None - args.lock = Path("/run/ahriman.lock") - assert Lock(args, repository_id, configuration).path == Path("/run/ahriman_x86_64-aur-clone.lock") + args.lock = Path("/run/ahriman.pid") + assert Lock(args, repository_id, configuration).path == Path("/run/ahriman_x86_64-aur-clone.pid") + + args.lock = Path("ahriman.pid") + assert Lock(args, repository_id, configuration).path == Path("/run/ahriman/ahriman_x86_64-aur-clone.pid") with pytest.raises(ValueError): args.lock = Path("/") assert Lock(args, repository_id, configuration).path # special case +def test_perform_lock(mocker: MockerFixture) -> None: + """ + must lock file with fcntl + """ + flock_mock = mocker.patch("fcntl.flock") + assert Lock.perform_lock(1) + flock_mock.assert_called_once_with(1, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def test_perform_lock_exception(mocker: MockerFixture) -> None: + """ + must return False on OSError + """ + mocker.patch("fcntl.flock", side_effect=OSError) + assert not Lock.perform_lock(1) + + +def test_open(lock: Lock, mocker: MockerFixture) -> None: + """ + must open file + """ + open_mock = mocker.patch("pathlib.Path.open") + lock.path = Path("ahriman.pid") + + lock._open() + open_mock.assert_called_once_with("a+") + + +def test_open_skip(lock: Lock, mocker: MockerFixture) -> None: + """ + must skip file opening if path is not set + """ + open_mock = mocker.patch("pathlib.Path.open") + lock._open() + open_mock.assert_not_called() + + +def test_watch(lock: Lock, mocker: MockerFixture) -> None: + """ + must check if lock file exists + """ + lock._pid_file = MagicMock() + lock._pid_file.fileno.return_value = 1 + wait_mock = mocker.patch("ahriman.models.waiter.Waiter.wait") + + lock._watch() + wait_mock.assert_called_once_with(pytest.helpers.anyvar(int), 1) + + +def test_watch_skip(lock: Lock, mocker: MockerFixture) -> None: + """ + must skip watch on empty path + """ + mocker.patch("ahriman.application.lock.Lock.perform_lock", return_value=True) + lock._watch() + + +def test_write(lock: Lock) -> None: + """ + must write PID to lock file + """ + with NamedTemporaryFile("a+") as pid_file: + lock._pid_file = pid_file + lock._write(is_locked=False) + + assert int(lock._pid_file.readline()) == os.getpid() + + +def test_write_skip(lock: Lock) -> None: + """ + must skip write to file if no path set + """ + lock._write(is_locked=False) + + +def test_write_locked(lock: Lock, mocker: MockerFixture) -> None: + """ + must raise DuplicateRunError if cannot lock file + """ + mocker.patch("ahriman.application.lock.Lock.perform_lock", return_value=False) + with pytest.raises(DuplicateRunError): + lock._pid_file = MagicMock() + lock._write(is_locked=False) + + +def test_write_locked_before(lock: Lock, mocker: MockerFixture) -> None: + """ + must skip lock in case if file was locked before + """ + lock_mock = mocker.patch("ahriman.application.lock.Lock.perform_lock") + lock._pid_file = MagicMock() + + lock._write(is_locked=True) + lock_mock.assert_not_called() + + def test_check_user(lock: Lock, mocker: MockerFixture) -> None: """ must check user correctly @@ -88,7 +189,7 @@ def test_clear(lock: Lock) -> None: """ must remove lock file """ - lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock" + lock.path = Path("ahriman-test.pid") lock.path.touch() lock.clear() @@ -99,7 +200,7 @@ def test_clear_missing(lock: Lock) -> None: """ must not fail on lock removal if file is missing """ - lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock" + lock.path = Path("ahriman-test.pid") lock.clear() @@ -112,67 +213,52 @@ def test_clear_skip(lock: Lock, mocker: MockerFixture) -> None: unlink_mock.assert_not_called() -def test_create(lock: Lock) -> None: +def test_clear_close(lock: Lock) -> None: """ - must create lock + must close pid file if opened """ - lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock" - - lock.create() - assert lock.path.is_file() - lock.path.unlink() + close_mock = lock._pid_file = MagicMock() + lock.clear() + close_mock.close.assert_called_once_with() -def test_create_exception(lock: Lock) -> None: +def test_clear_close_exception(lock: Lock) -> None: """ - must raise exception if file already exists + must suppress IO exception on file closure """ - lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock" - lock.path.touch() - - with pytest.raises(DuplicateRunError): - lock.create() - lock.path.unlink() + close_mock = lock._pid_file = MagicMock() + close_mock.close.side_effect = IOError() + lock.clear() -def test_create_skip(lock: Lock, mocker: MockerFixture) -> None: +def test_lock(lock: Lock, mocker: MockerFixture) -> None: """ - must skip creating if no file set + must perform lock correctly """ - touch_mock = mocker.patch("pathlib.Path.touch") - lock.create() - touch_mock.assert_not_called() + clear_mock = mocker.patch("ahriman.application.lock.Lock.clear") + open_mock = mocker.patch("ahriman.application.lock.Lock._open") + watch_mock = mocker.patch("ahriman.application.lock.Lock._watch", return_value=True) + write_mock = mocker.patch("ahriman.application.lock.Lock._write") + + lock.lock() + clear_mock.assert_not_called() + open_mock.assert_called_once_with() + watch_mock.assert_called_once_with() + write_mock.assert_called_once_with(is_locked=True) -def test_create_unsafe(lock: Lock) -> None: +def test_lock_clear(lock: Lock, mocker: MockerFixture) -> None: """ - must not raise exception if force flag set + must clear lock file before lock if force flag is set """ + mocker.patch("ahriman.application.lock.Lock._open") + mocker.patch("ahriman.application.lock.Lock._watch") + mocker.patch("ahriman.application.lock.Lock._write") + clear_mock = mocker.patch("ahriman.application.lock.Lock.clear") lock.force = True - lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock" - lock.path.touch() - lock.create() - lock.path.unlink() - - -def test_watch(lock: Lock, mocker: MockerFixture) -> None: - """ - must check if lock file exists - """ - wait_mock = mocker.patch("ahriman.models.waiter.Waiter.wait") - lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock" - - lock.watch() - wait_mock.assert_called_once_with(lock.path.is_file) - - -def test_watch_skip(lock: Lock, mocker: MockerFixture) -> None: - """ - must skip watch on empty path - """ - mocker.patch("pathlib.Path.is_file", return_value=True) - lock.watch() + lock.lock() + clear_mock.assert_called_once_with() def test_enter(lock: Lock, mocker: MockerFixture) -> None: @@ -181,18 +267,14 @@ def test_enter(lock: Lock, mocker: MockerFixture) -> None: """ check_user_mock = mocker.patch("ahriman.application.lock.Lock.check_user") check_version_mock = mocker.patch("ahriman.application.lock.Lock.check_version") - watch_mock = mocker.patch("ahriman.application.lock.Lock.watch") - clear_mock = mocker.patch("ahriman.application.lock.Lock.clear") - create_mock = mocker.patch("ahriman.application.lock.Lock.create") + lock_mock = mocker.patch("ahriman.application.lock.Lock.lock") update_status_mock = mocker.patch("ahriman.core.status.Client.status_update") with lock: pass check_user_mock.assert_called_once_with() - clear_mock.assert_called_once_with() - create_mock.assert_called_once_with() check_version_mock.assert_called_once_with() - watch_mock.assert_called_once_with() + lock_mock.assert_called_once_with() update_status_mock.assert_has_calls([MockCall(BuildStatusEnum.Building), MockCall(BuildStatusEnum.Success)]) @@ -202,7 +284,7 @@ def test_exit_with_exception(lock: Lock, mocker: MockerFixture) -> None: """ mocker.patch("ahriman.application.lock.Lock.check_user") mocker.patch("ahriman.application.lock.Lock.clear") - mocker.patch("ahriman.application.lock.Lock.create") + mocker.patch("ahriman.application.lock.Lock.lock") update_status_mock = mocker.patch("ahriman.core.status.Client.status_update") with pytest.raises(ValueError): diff --git a/tests/ahriman/models/test_waiter.py b/tests/ahriman/models/test_waiter.py index b98e757c..5af432a2 100644 --- a/tests/ahriman/models/test_waiter.py +++ b/tests/ahriman/models/test_waiter.py @@ -1,6 +1,36 @@ +import pytest import time -from ahriman.models.waiter import Waiter +from ahriman.models.waiter import Waiter, WaiterResult, WaiterTaskFinished, WaiterTimedOut + + +def test_result_to_float() -> None: + """ + must convert waiter result to float + """ + assert float(WaiterResult(4.2)) == 4.2 + + +def test_result_not_implemented() -> None: + """ + must raise NotImplementedError for abstract class + """ + with pytest.raises(NotImplementedError): + assert bool(WaiterResult(4.2)) + + +def test_result_success_to_bool() -> None: + """ + must convert success waiter result to bool + """ + assert bool(WaiterTaskFinished(4.2)) + + +def test_result_failure_to_bool() -> None: + """ + must convert failure waiter result to bool + """ + assert not bool(WaiterTimedOut(4.2)) def test_is_timed_out() -> None: @@ -22,8 +52,26 @@ def test_is_timed_out_infinite() -> None: def test_wait() -> None: """ - must wait until file will disappear + must wait for success result """ results = iter([True, False]) - waiter = Waiter(1, interval=1) - assert waiter.wait(lambda: next(results)) > 0 + waiter = Waiter(1, interval=0.1) + assert float(waiter.wait(lambda: next(results))) > 0 + + +def test_wait_timeout() -> None: + """ + must return WaiterTimedOut on timeout + """ + results = iter([True, False]) + waiter = Waiter(-1, interval=0.1) + assert isinstance(waiter.wait(lambda: next(results)), WaiterTimedOut) + + +def test_wait_success() -> None: + """ + must return WaiterTaskFinished on success + """ + results = iter([True, False]) + waiter = Waiter(1, interval=0.1) + assert isinstance(waiter.wait(lambda: next(results)), WaiterTaskFinished)