Skip to content

Commit 1555489

Browse files
committed
Avocado instrumented timeout handling in thread
This commit changes the way how avocado instrumented runner handles test timeouts. For timeout handling, we used to use signals and raising TestInterrupt error. Such solution has an issue that we can't control where in the code the Error will be raised, and it can be handled before it reaches the runner layer. More info about this issue in #6046. This change removes the signal handling and uses threading instead. Now each test method will be run in a separated thread and this thread will be terminated if timeout is reached. This solution selves the raising error issue and keeps the current test lifecycle untouched. Reference: #6046 Signed-off-by: Jan Richter <[email protected]>
1 parent 12863df commit 1555489

File tree

4 files changed

+80
-29
lines changed

4 files changed

+80
-29
lines changed

avocado/core/test.py

+23-7
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import functools
2323
import inspect
2424
import logging
25+
import multiprocessing
26+
import multiprocessing.pool
2527
import os
2628
import shutil
2729
import sys
@@ -654,14 +656,26 @@ def _setup_environment_variables(self):
654656

655657
def _catch_test_status(self, method):
656658
"""Wrapper around test methods for catching and logging failures."""
657-
try:
659+
660+
def set_new_event_loop_for_method(method):
661+
asyncio.set_event_loop(asyncio.new_event_loop())
658662
method()
659-
if self.__log_warn_used and self.__status not in STATUSES_NOT_OK:
660-
raise exceptions.TestWarn(
661-
"Test passed but there were warnings "
662-
"during execution. Check the log for "
663-
"details."
664-
)
663+
664+
try:
665+
pool = multiprocessing.pool.ThreadPool(1)
666+
res = pool.apply_async(set_new_event_loop_for_method, [method])
667+
pool.close()
668+
try:
669+
res.get(self.timeout)
670+
if self.__log_warn_used and self.__status not in STATUSES_NOT_OK:
671+
raise exceptions.TestWarn(
672+
"Test passed but there were warnings "
673+
"during execution. Check the log for "
674+
"details."
675+
)
676+
except multiprocessing.TimeoutError:
677+
raise exceptions.TestInterrupt("Test interrupted: Timeout reached")
678+
665679
except exceptions.TestBaseException as detail:
666680
self.__status = detail.status
667681
self.__fail_class = detail.__class__.__name__
@@ -686,6 +700,8 @@ def _catch_test_status(self, method):
686700
)
687701
for e_line in tb_info:
688702
self.log.error(e_line)
703+
finally:
704+
pool.terminate()
689705

690706
def run_avocado(self):
691707
"""

avocado/plugins/runners/avocado_instrumented.py

+6-20
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@
88

99
from avocado.core.exceptions import TestInterrupt
1010
from avocado.core.nrunner.app import BaseRunnerApp
11-
from avocado.core.nrunner.runner import (
12-
RUNNER_RUN_CHECK_INTERVAL,
13-
RUNNER_RUN_STATUS_INTERVAL,
14-
BaseRunner,
15-
)
11+
from avocado.core.nrunner.runner import RUNNER_RUN_CHECK_INTERVAL, BaseRunner
1612
from avocado.core.test import TestID
1713
from avocado.core.tree import TreeNodeEnvOnly
1814
from avocado.core.utils import loader, messages
@@ -138,23 +134,14 @@ def _run_avocado(runnable, queue):
138134
)
139135

140136
@staticmethod
141-
def _monitor(proc, time_started, queue):
142-
timeout = float("inf")
143-
next_status_time = None
137+
def _monitor(queue):
144138
while True:
145139
time.sleep(RUNNER_RUN_CHECK_INTERVAL)
146-
now = time.monotonic()
147140
if queue.empty():
148-
if next_status_time is None or now > next_status_time:
149-
next_status_time = now + RUNNER_RUN_STATUS_INTERVAL
150-
yield messages.RunningMessage.get()
151-
if (now - time_started) > timeout:
152-
proc.terminate()
141+
yield messages.RunningMessage.get()
153142
else:
154143
message = queue.get()
155-
if message.get("type") == "early_state":
156-
timeout = float(message.get("timeout") or float("inf"))
157-
else:
144+
if message.get("type") != "early_state":
158145
yield message
159146
if message.get("status") == "finished":
160147
break
@@ -172,13 +159,12 @@ def run(self, runnable):
172159

173160
process.start()
174161

175-
time_started = time.monotonic()
176-
for message in self._monitor(process, time_started, queue):
162+
for message in self._monitor(queue):
177163
yield message
178164

179165
except TestInterrupt:
180166
process.terminate()
181-
for message in self._monitor(process, time_started, queue):
167+
for message in self._monitor(queue):
182168
yield message
183169
except Exception as e:
184170
yield messages.StderrMessage.get(traceback.format_exc())

selftests/check.py

+1-1
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": 313,
32+
"functional-parallel": 314,
3333
"functional-serial": 7,
3434
"optional-plugins": 0,
3535
"optional-plugins-golang": 2,

selftests/functional/plugin/runners/avocado_instrumented.py

+50-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,30 @@
1+
import json
2+
import os
3+
14
from avocado import Test
25
from avocado.core.exit_codes import AVOCADO_JOB_INTERRUPTED
3-
from avocado.utils import process
6+
from avocado.utils import process, script
47
from selftests.utils import AVOCADO, TestCaseTmpDir
58

9+
TIMEOU_TEST_WITH_EXCEPTION = """
10+
import time
11+
12+
from avocado import Test
13+
14+
class TimeoutTest(Test):
15+
16+
timeout = 3
17+
18+
def test(self):
19+
try:
20+
time.sleep(5)
21+
except Exception:
22+
pass
23+
24+
def tearDown(self):
25+
self.log.info("TearDown")
26+
"""
27+
628

729
class AvocadoInstrumentedRunnerTest(TestCaseTmpDir, Test):
830
def test_timeout(self):
@@ -16,3 +38,30 @@ def test_timeout(self):
1638
"examples/tests/timeouttest.py:TimeoutTest.test: INTERRUPTED: Test interrupted: Timeout reached",
1739
result.stdout_text,
1840
)
41+
42+
def test_timeout_with_exception(self):
43+
with script.TemporaryScript(
44+
"test_timeout.py",
45+
TIMEOU_TEST_WITH_EXCEPTION,
46+
"avocado_timeout_test",
47+
) as tst:
48+
res = process.run(
49+
(
50+
f"{AVOCADO} run --disable-sysinfo "
51+
f"--job-results-dir {self.tmpdir.name} {tst} "
52+
f"--json -"
53+
),
54+
ignore_status=True,
55+
)
56+
results = json.loads(res.stdout_text)
57+
self.assertIn(
58+
"Test interrupted: Timeout reached",
59+
results["tests"][0]["fail_reason"],
60+
)
61+
debug_log_path = results["tests"][0]["logfile"]
62+
self.assertTrue(os.path.exists(debug_log_path))
63+
with open(debug_log_path, encoding="utf-8") as file:
64+
self.assertIn(
65+
"INFO | TearDown",
66+
file.read(),
67+
)

0 commit comments

Comments
 (0)