Skip to content

Commit 9e185ae

Browse files
committed
Block timeout introduction
This commit introduces a new timeout feature, which adds the possibility to set a specific timeout for a block of code with context manager like this: with self.wait_max(3): #code which should take max 3 seconds ... The `wait_max` method will send `SIGTERM` if the code doesn't end within 3 seconds. This signal will be caught by avocado-instrumented runner, which will interrupt the test, the same way as with a regular timeout. Reference: #5994 Signed-off-by: Jan Richter <[email protected]>
1 parent fc470ad commit 9e185ae

File tree

5 files changed

+91
-1
lines changed

5 files changed

+91
-1
lines changed

avocado/core/test.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@
2424
import logging
2525
import os
2626
import shutil
27+
import signal
2728
import sys
2829
import tempfile
30+
import threading
2931
import time
3032
import unittest
3133
import warnings
34+
from contextlib import contextmanager
3235

3336
from avocado.core import exceptions, parameters
3437
from avocado.core.settings import settings
@@ -514,6 +517,29 @@ def phase(self):
514517
"""
515518
return self.__phase
516519

520+
@contextmanager
521+
def wait_max(self, timeout):
522+
"""
523+
Context manager for getting block of code with its specific timeout.
524+
525+
Usage:
526+
with self.wait_max(3):
527+
# code which should take max 3 seconds
528+
...
529+
530+
:param timeout: Timeout in seconds for block of code.
531+
:type timeout: int
532+
"""
533+
534+
def raise_timeout():
535+
os.kill(os.getpid(), signal.SIGTERM)
536+
537+
timeout = timeout * self.params.get("timeout_factor", default=1.0)
538+
alarm = threading.Timer(timeout, raise_timeout)
539+
alarm.start()
540+
yield timeout
541+
alarm.cancel()
542+
517543
def __str__(self):
518544
return str(self.name)
519545

docs/source/guides/writer/chapters/writing.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,19 @@ runner task, making it raise a
773773
process is specific to spawner implementation, for more information
774774
see :class:`avocado.core.plugin_interfaces.Spawner.terminate_task`.
775775
776+
777+
Block Timeout
778+
-------------
779+
On more complex (and thus usually) longer tests, there may be multiple
780+
steps to complete. It may be known that some of these steps should not
781+
take more than a small percentage of the overall expected time for the
782+
test as a whole. Therefore, it is not convenient to set the timeout for
783+
the whole test, but it would be better to have timeout for each of those
784+
steps. For such use-case avocado supports `wait_max` context manager,
785+
which let you set specific timeout (in seconds) for a block of code:
786+
787+
.. literalinclude:: ../../../../../examples/tests/blocktimeouttest.py
788+
776789
Timeout Factor
777790
~~~~~~~~~~~~~~
778791
@@ -810,6 +823,9 @@ test logs. For the previous test execution it shows::
810823
...
811824
[stdlog] 2023-11-29 11:16:23,746 test L0354 DEBUG| actual timeout: 6.0
812825
826+
827+
.. note:: Be aweare that timeout factor will also affect timeouts created by `wait_max` context manager.
828+
813829
Skipping Tests
814830
--------------
815831

examples/tests/blocktimeouttest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import time
2+
3+
from avocado import Test
4+
5+
6+
class TimeoutTest(Test):
7+
"""
8+
Functional test for avocado. Throw a TestTimeoutError.
9+
10+
:param sleep_time: How long should the test sleep
11+
"""
12+
13+
def test(self):
14+
"""
15+
This should throw a TestTimeoutError.
16+
"""
17+
with self.wait_max(3):
18+
sleep_time = float(self.params.get("sleep_time", default=5.0))
19+
self.log.info(
20+
"Sleeping for %.2f seconds (2 more than the timeout)", sleep_time
21+
)
22+
time.sleep(sleep_time)

selftests/check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"nrunner-requirement": 28,
3030
"unit": 678,
3131
"jobs": 11,
32-
"functional-parallel": 309,
32+
"functional-parallel": 310,
3333
"functional-serial": 7,
3434
"optional-plugins": 0,
3535
"optional-plugins-golang": 2,

selftests/functional/basic.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,32 @@ def test_runner_timeout(self):
543543
# Ensure no test aborted error messages show up
544544
self.assertNotIn(b"TestAbortError: Test aborted unexpectedly", output)
545545

546+
def test_runner_block_timeout(self):
547+
cmd_line = (
548+
f"{AVOCADO} run --disable-sysinfo --job-results-dir "
549+
f"{self.tmpdir.name} examples/tests/blocktimeouttest.py"
550+
)
551+
result = process.run(cmd_line, ignore_status=True)
552+
json_path = os.path.join(self.tmpdir.name, "latest", "results.json")
553+
with open(json_path, encoding="utf-8") as json_file:
554+
result_json = json.load(json_file)
555+
output = result.stdout
556+
expected_rc = exit_codes.AVOCADO_JOB_INTERRUPTED
557+
unexpected_rc = exit_codes.AVOCADO_FAIL
558+
self.assertNotEqual(
559+
result.exit_status,
560+
unexpected_rc,
561+
f"Avocado crashed (rc {unexpected_rc}):\n{result}",
562+
)
563+
self.assertEqual(
564+
result.exit_status,
565+
expected_rc,
566+
f"Avocado did not return rc {expected_rc}:\n{result}",
567+
)
568+
self.assertIn("Timeout reached", result_json["tests"][0]["fail_reason"])
569+
# Ensure no test aborted error messages show up
570+
self.assertNotIn(b"TestAbortError: Test aborted unexpectedly", output)
571+
546572
def test_runner_timeout_factor(self):
547573
cmd_line = (
548574
f"{AVOCADO} run --disable-sysinfo --job-results-dir "

0 commit comments

Comments
 (0)