Skip to content

Commit 9d8c93d

Browse files
committed
conn: add support for timeout
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.
1 parent e6ceecd commit 9d8c93d

File tree

8 files changed

+516
-113
lines changed

8 files changed

+516
-113
lines changed

docs/articles/running-commands.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ SSH connection to the host for different user).
3535
running-commands/configuration
3636
running-commands/blocking-calls
3737
running-commands/non-blocking-calls
38+
running-commands/timeouts
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
Timeouts
2+
########
3+
4+
By default, all command timeout in 300 seconds, which should be more then
5+
sufficient for most use cases. This timeout ensures that code that runs
6+
unexpectedly long time (for example an unexpected user input is requested) is
7+
terminated gracefully. A command that hits the timeout raises
8+
:class:`~pytest_mh.conn.ProcessTimeoutError`.
9+
10+
The default timeout can be overridden on multiple places:
11+
12+
* Per host, in a configuration file, by setting ``timeout`` field in the
13+
``host.conn`` section
14+
* In a blocking calls using the ``timeout`` parameter, see
15+
:meth:`~pytest_mh.conn.Connection.run` and
16+
:meth:`~pytest_mh.conn.Connection.exec`
17+
* In a non-blocking calls using the ``timeout`` parameter on the
18+
:meth:`~pytest_mh.conn.Process.wait` method of a running process creating by
19+
:meth:`~pytest_mh.conn.Connection.async_run` or
20+
:meth:`~pytest_mh.conn.Connection.async_exec`
21+
22+
.. code-block:: yaml
23+
:caption: Example: Overriding default timeout for the host
24+
25+
hosts:
26+
- hostname: client1.test
27+
role: client
28+
conn:
29+
type: ssh
30+
host: 192.168.0.10
31+
user: root
32+
password: Secret123
33+
timeout: 600 # setting default timeout to 10 minutes
34+
35+
.. code-block:: python
36+
:caption: Example: Setting specific timeout for single command
37+
38+
@pytest.mark.topology(KnownTopology.Client)
39+
def test_timeout(client: Client):
40+
result = client.host.conn.run(
41+
"""
42+
echo 'stdout before';
43+
>&2 echo 'stderr before';
44+
sleep 15;
45+
echo 'stdout after';
46+
>&2 echo 'stderr after'
47+
""",
48+
timeout=5,
49+
)
50+
51+
52+
@pytest.mark.topology(KnownTopology.Client)
53+
def test_timeout_async(client: Client):
54+
process = client.host.conn.async_run(
55+
"""
56+
echo 'stdout before';
57+
>&2 echo 'stderr before';
58+
sleep 15;
59+
echo 'stdout after';
60+
>&2 echo 'stderr after'
61+
"""
62+
)
63+
64+
process.wait(timeout=5)

pytest_mh/_private/misc.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,63 @@
11
from __future__ import annotations
22

3+
import signal
34
from collections.abc import Mapping
45
from copy import deepcopy
5-
from functools import partial
6+
from functools import partial, wraps
67
from inspect import getfullargspec
78
from pathlib import Path
8-
from typing import TYPE_CHECKING, Any, Callable
9+
from typing import TYPE_CHECKING, Any, Callable, ParamSpec, TypeVar
910

1011
from .types import MultihostOutcome
1112

1213
if TYPE_CHECKING:
1314
from .artifacts import MultihostArtifactsMode
1415

1516

17+
Param = ParamSpec("Param")
18+
RetType = TypeVar("RetType")
19+
20+
21+
def timeout(
22+
seconds: int, message: str = "Operation timed out"
23+
) -> Callable[[Callable[Param, RetType]], Callable[Param, RetType]]:
24+
"""
25+
Raise TimeoutError if function takes longer then ``seconds`` to finish.
26+
27+
:param seconds: Number of seconds to wait.
28+
:type seconds: int
29+
:param message: Exception message, defaults to "Operation timed out"
30+
:type message: str, optional
31+
:raises ValueError: If ``seconds`` is less or equal to zero.
32+
:raises TimeoutError: If timeout occurrs.
33+
:return: Decorator.
34+
:rtype: Callable[[Callable[Param, RetType]], Callable[Param, RetType]]
35+
"""
36+
if seconds < 0:
37+
raise ValueError(f"Invalid timeout value: {seconds}")
38+
39+
def _timeout_handler(signum, frame):
40+
raise TimeoutError(seconds, message)
41+
42+
def decorator(func: Callable[Param, RetType]) -> Callable[Param, RetType]:
43+
if seconds == 0:
44+
return func
45+
46+
@wraps(func)
47+
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
48+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
49+
old_timer = signal.setitimer(signal.ITIMER_REAL, seconds)
50+
try:
51+
return func(*args, **kwargs)
52+
finally:
53+
signal.setitimer(signal.ITIMER_REAL, *old_timer)
54+
signal.signal(signal.SIGALRM, old_handler)
55+
56+
return wrapper
57+
58+
return decorator
59+
60+
1661
def validate_configuration(
1762
required_keys: list[str], confdict: dict[str, Any], error_fmt: str = '"{key}" property is missing'
1863
) -> None:

pytest_mh/_private/multihost.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@
1010
import pytest
1111

1212
from ..cli import CLIBuilder
13-
from ..conn import Bash, Connection, Powershell, Process, ProcessError, ProcessInputBuffer, ProcessResult, Shell
13+
from ..conn import (
14+
Bash,
15+
Connection,
16+
Powershell,
17+
Process,
18+
ProcessError,
19+
ProcessInputBuffer,
20+
ProcessResult,
21+
ProcessTimeoutError,
22+
Shell,
23+
)
1424
from ..conn.container import ContainerClient
1525
from ..conn.ssh import SSHClient
1626
from .artifacts import (
@@ -526,9 +536,9 @@ def __init__(self, domain: DomainType, confdict: dict[str, Any]):
526536
raise ValueError(f"Unknown operating system os_family: {self.os_family}")
527537

528538
# Connection to the host
529-
self.conn: Connection[Process[ProcessResult, ProcessInputBuffer], ProcessResult[ProcessError]] = (
530-
self.get_connection()
531-
)
539+
self.conn: Connection[
540+
Process[ProcessResult, ProcessInputBuffer, ProcessTimeoutError], ProcessResult[ProcessError]
541+
] = self.get_connection()
532542
"""Connection to the host."""
533543

534544
# CLI Builder instance

0 commit comments

Comments
 (0)