Skip to content

Commit

Permalink
conn: add support for timeout
Browse files Browse the repository at this point in the history
Default timeout is 300 seconds. This can be changed using timeout
parameter of:

* conn.run(timeout=xy)
* conn.exec(timeout=xy)
* process.wait(timout=xy)

Or in mhc.yaml by setting host.conn.timeout.
  • Loading branch information
pbrezina committed Jan 28, 2025
1 parent c5a2469 commit a10e9ed
Show file tree
Hide file tree
Showing 8 changed files with 501 additions and 113 deletions.
1 change: 1 addition & 0 deletions docs/articles/running-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ SSH connection to the host for different user).
running-commands/configuration
running-commands/blocking-calls
running-commands/non-blocking-calls
running-commands/timeouts
64 changes: 64 additions & 0 deletions docs/articles/running-commands/timeouts.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
Timeouts
########

By default, all command timeouts in 300 seconds, which should be more then
sufficient for most use cases. This timeout ensures that code that runs
unexpectedly long time (for example and unexpected user input is requested) is
terminated gracefully. A command that hits the timeout raises
:class:`~pytest_mh.conn.ProcessTimeoutError`.

The default timeout can be overridden on multiple places:

* Per host, in a configuration file, by setting ``timeout`` field in the
``host.conn`` section
* In a blocking calls using the ``timeout`` parameter, see
:meth:`~pytest_mh.conn.Connection.run` and
:meth:`~pytest_mh.conn.Connection.exec`
* In a non-blocking calls using the ``timeout`` parameter on the
:meth:`~pytest_mh.conn.Process.wait` method of a running process creating by
:meth:`~pytest_mh.conn.Connection.async_run` or
:meth:`~pytest_mh.conn.Connection.async_exec`

.. code-block:: yaml
:caption: Example: Overriding default timeout for the host
hosts:
- hostname: client1.test
role: client
conn:
type: ssh
host: 192.168.0.10
user: root
password: Secret123
timeout: 600 # setting default timeout to 10 minutes
.. code-block:: python
:caption: Example: Setting specific timeout for single command
@pytest.mark.topology(KnownTopology.Client)
def test_timeout(client: Client):
result = client.host.conn.run(
"""
echo 'stdout before';
>&2 echo 'stderr before';
sleep 15;
echo 'stdout after';
>&2 echo 'stderr after'
""",
timeout=5,
)
@pytest.mark.topology(KnownTopology.Client)
def test_timeout_async(client: Client):
process = client.host.conn.async_run(
"""
echo 'stdout before';
>&2 echo 'stderr before';
sleep 15;
echo 'stdout after';
>&2 echo 'stderr after'
"""
)
process.wait(timeout=5)
49 changes: 47 additions & 2 deletions pytest_mh/_private/misc.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,63 @@
from __future__ import annotations

import signal
from collections.abc import Mapping
from copy import deepcopy
from functools import partial
from functools import partial, wraps
from inspect import getfullargspec
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from typing import TYPE_CHECKING, Any, Callable, ParamSpec, TypeVar

from .types import MultihostOutcome

if TYPE_CHECKING:
from .artifacts import MultihostArtifactsMode


Param = ParamSpec("Param")
RetType = TypeVar("RetType")


def timeout(
seconds: int, message: str = "Operation timed out"
) -> Callable[[Callable[Param, RetType]], Callable[Param, RetType]]:
"""
Raise TimeoutError if function takes longer then ``seconds`` to finish.
:param seconds: Number of seconds to wait.
:type seconds: int
:param message: Exception message, defaults to "Operation timed out"
:type message: str, optional
:raises ValueError: If ``seconds`` is less or equal to zero.
:raises TimeoutError: If timeout occurrs.
:return: Decorator.
:rtype: Callable[[Callable[Param, RetType]], Callable[Param, RetType]]
"""
if seconds < 0:
raise ValueError(f"Invalid timeout value: {seconds}")

def _timeout_handler(signum, frame):
raise TimeoutError(seconds, message)

def decorator(func: Callable[Param, RetType]) -> Callable[Param, RetType]:
if seconds == 0:
return func

@wraps(func)
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
old_timer = signal.setitimer(signal.ITIMER_REAL, seconds)
try:
return func(*args, **kwargs)
finally:
signal.setitimer(signal.ITIMER_REAL, *old_timer)
signal.signal(signal.SIGALRM, old_handler)

return wrapper

return decorator


def validate_configuration(
required_keys: list[str], confdict: dict[str, Any], error_fmt: str = '"{key}" property is missing'
) -> None:
Expand Down
18 changes: 14 additions & 4 deletions pytest_mh/_private/multihost.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@
import pytest

from ..cli import CLIBuilder
from ..conn import Bash, Connection, Powershell, Process, ProcessError, ProcessInputBuffer, ProcessResult, Shell
from ..conn import (
Bash,
Connection,
Powershell,
Process,
ProcessError,
ProcessInputBuffer,
ProcessResult,
ProcessTimeoutError,
Shell,
)
from ..conn.container import ContainerClient
from ..conn.ssh import SSHClient
from .artifacts import (
Expand Down Expand Up @@ -526,9 +536,9 @@ def __init__(self, domain: DomainType, confdict: dict[str, Any]):
raise ValueError(f"Unknown operating system os_family: {self.os_family}")

# Connection to the host
self.conn: Connection[Process[ProcessResult, ProcessInputBuffer], ProcessResult[ProcessError]] = (
self.get_connection()
)
self.conn: Connection[
Process[ProcessResult, ProcessInputBuffer, ProcessTimeoutError], ProcessResult[ProcessError]
] = self.get_connection()
"""Connection to the host."""

# CLI Builder instance
Expand Down
Loading

0 comments on commit a10e9ed

Please sign in to comment.