Skip to content

Commit aeb256a

Browse files
committed
Suspend execution feature
This commit introduces the suspend execution feature to the nrunner. The suspend execution was available on the legacy runner, but we didn't move it to the nrunner. With this feature, it is possible to pause execution of python based task on process spawner by sending SIGTSTP signal (ctrl+z). It is helpful for debugging test execution. Reference: #6059 Signed-off-by: Jan Richter <jarichte@redhat.com>
1 parent bc3ee8c commit aeb256a

File tree

9 files changed

+183
-18
lines changed

9 files changed

+183
-18
lines changed

avocado/core/nrunner/runner.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from avocado.core.nrunner.runnable import RUNNERS_REGISTRY_STANDALONE_EXECUTABLE
99
from avocado.core.plugin_interfaces import RunnableRunner
1010
from avocado.core.utils import messages
11+
from avocado.utils import process
1112

1213
#: The amount of time (in seconds) between each internal status check
1314
RUNNER_RUN_CHECK_INTERVAL = 0.01
@@ -99,14 +100,30 @@ class PythonBaseRunner(BaseRunner, abc.ABC):
99100
Base class for Python runners
100101
"""
101102

102-
@staticmethod
103-
def signal_handler(signum, frame): # pylint: disable=W0613
103+
def __init__(self):
104+
super().__init__()
105+
self.proc = None
106+
self.sigtstp = multiprocessing.Lock()
107+
self.sigstopped = False
108+
self.timeout = float("inf")
109+
110+
def signal_handler(self, signum, frame): # pylint: disable=W0613
104111
if signum == signal.SIGTERM.value:
105112
raise TestInterrupt("Test interrupted: Timeout reached")
106-
107-
@staticmethod
108-
def _monitor(proc, time_started, queue):
109-
timeout = float("inf")
113+
elif signum == signal.SIGTSTP.value:
114+
if self.sigstopped:
115+
self.sigstopped = False
116+
sign = signal.SIGCONT
117+
else:
118+
self.sigstopped = True
119+
sign = signal.SIGSTOP
120+
if not self.proc: # Ignore ctrl+z when proc not yet started
121+
return
122+
with self.sigtstp:
123+
self.timeout = float("inf")
124+
process.kill_process_tree(self.proc.pid, sign, False)
125+
126+
def _monitor(self, time_started, queue):
110127
next_status_time = None
111128
while True:
112129
time.sleep(RUNNER_RUN_CHECK_INTERVAL)
@@ -115,37 +132,42 @@ def _monitor(proc, time_started, queue):
115132
if next_status_time is None or now > next_status_time:
116133
next_status_time = now + RUNNER_RUN_STATUS_INTERVAL
117134
yield messages.RunningMessage.get()
118-
if (now - time_started) > timeout:
119-
proc.terminate()
135+
if (now - time_started) > self.timeout:
136+
self.proc.terminate()
120137
else:
121138
message = queue.get()
122139
if message.get("type") == "early_state":
123-
timeout = float(message.get("timeout") or float("inf"))
140+
self.timeout = float(message.get("timeout") or float("inf"))
124141
else:
125142
yield message
126143
if message.get("status") == "finished":
127144
break
145+
while self.sigstopped:
146+
time.sleep(RUNNER_RUN_CHECK_INTERVAL)
128147

129148
def run(self, runnable):
130-
# pylint: disable=W0201
149+
signal.signal(signal.SIGTSTP, signal.SIG_IGN)
131150
signal.signal(signal.SIGTERM, self.signal_handler)
151+
signal.signal(signal.SIGTSTP, self.signal_handler)
152+
# pylint: disable=W0201
132153
self.runnable = runnable
133154
yield messages.StartedMessage.get()
134155
try:
135156
queue = multiprocessing.SimpleQueue()
136-
process = multiprocessing.Process(
157+
self.proc = multiprocessing.Process(
137158
target=self._run, args=(self.runnable, queue)
138159
)
139-
140-
process.start()
141-
160+
while self.sigstopped:
161+
pass
162+
with self.sigtstp:
163+
self.proc.start()
142164
time_started = time.monotonic()
143-
for message in self._monitor(process, time_started, queue):
165+
for message in self._monitor(time_started, queue):
144166
yield message
145167

146168
except TestInterrupt:
147-
process.terminate()
148-
for message in self._monitor(process, time_started, queue):
169+
self.proc.terminate()
170+
for message in self._monitor(time_started, queue):
149171
yield message
150172
except Exception as e:
151173
yield messages.StderrMessage.get(traceback.format_exc())

avocado/core/plugin_interfaces.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,28 @@ async def terminate_task(self, runtime_task):
376376
:rtype: bool
377377
"""
378378

379+
async def stop_task(self, runtime_task):
380+
"""Stop already spawned task.
381+
382+
:param runtime_task: wrapper for a Task with additional runtime
383+
information.
384+
:type runtime_task: :class:`avocado.core.task.runtime.RuntimeTask`
385+
:returns: whether the task has been stopped or not.
386+
:rtype: bool
387+
"""
388+
raise NotImplementedError()
389+
390+
async def resume_task(self, runtime_task):
391+
"""Resume already stopped task.
392+
393+
:param runtime_task: wrapper for a Task with additional runtime
394+
information.
395+
:type runtime_task: :class:`avocado.core.task.runtime.RuntimeTask`
396+
:returns: whether the task has been resumed or not.
397+
:rtype: bool
398+
"""
399+
raise NotImplementedError()
400+
379401
@staticmethod
380402
@abc.abstractmethod
381403
async def check_task_requirements(runtime_task):

avocado/core/task/runtime.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class RuntimeTaskStatus(Enum):
1818
FAIL_TRIAGE = "FINISHED WITH FAILURE ON TRIAGE"
1919
FAIL_START = "FINISHED FAILING TO START"
2020
STARTED = "STARTED"
21+
PAUSED = "PAUSED"
2122

2223
@staticmethod
2324
def finished_statuses():

avocado/core/task/statemachine.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import time
66

77
from avocado.core.exceptions import JobFailFast
8+
from avocado.core.output import LOG_UI
89
from avocado.core.task.runtime import RuntimeTaskStatus
910
from avocado.core.teststatus import STATUSES_NOT_OK
1011
from avocado.core.utils import messages
@@ -493,6 +494,31 @@ async def terminate_tasks_interrupted(self):
493494
terminated = await self._terminate_tasks(task_status)
494495
await self._send_finished_tasks_message(terminated, "Interrupted by user")
495496

497+
@staticmethod
498+
async def stop_resume_tasks(state_machine, spawner):
499+
async with state_machine.lock:
500+
try:
501+
for runtime_task in state_machine.monitored:
502+
if runtime_task.status == RuntimeTaskStatus.STARTED:
503+
await spawner.stop_task(runtime_task)
504+
runtime_task.status = RuntimeTaskStatus.PAUSED
505+
LOG_UI.warning(
506+
f"{runtime_task.task.identifier}: {runtime_task.status.value}"
507+
)
508+
elif runtime_task.status == RuntimeTaskStatus.PAUSED:
509+
await spawner.resume_task(runtime_task)
510+
runtime_task.status = RuntimeTaskStatus.STARTED
511+
LOG_UI.warning(
512+
f"{runtime_task.task.identifier}: {runtime_task.status.value}"
513+
)
514+
except NotImplementedError:
515+
LOG.warning(
516+
f"Sending signals to tasks is not implemented for spawner: {spawner}"
517+
)
518+
LOG_UI.warning(
519+
f"Sending signals to tasks is not implemented for spawner: {spawner}"
520+
)
521+
496522
async def run(self):
497523
"""Pushes Tasks forward and makes them do something with their lives."""
498524
while True:

avocado/plugins/runner_nrunner.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import os
2222
import platform
2323
import random
24+
import signal
2425
import tempfile
2526

2627
from avocado.core.dispatcher import SpawnerDispatcher
@@ -269,6 +270,10 @@ def _abort_if_missing_runners(runnables):
269270
)
270271
raise JobError(msg)
271272

273+
@staticmethod
274+
def signal_handler(spawner, state_machine):
275+
asyncio.create_task(Worker.stop_resume_tasks(state_machine, spawner))
276+
272277
def run_suite(self, job, test_suite):
273278
summary = set()
274279

@@ -335,6 +340,11 @@ def run_suite(self, job, test_suite):
335340
]
336341
asyncio.ensure_future(self._update_status(job))
337342
loop = asyncio.get_event_loop()
343+
if hasattr(signal, "SIGTSTP"):
344+
loop.add_signal_handler(
345+
signal.SIGTSTP,
346+
lambda: self.signal_handler(spawner, self.tsm),
347+
)
338348
try:
339349
try:
340350
loop.run_until_complete(

avocado/plugins/spawners/process.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import os
3+
import signal
34
import socket
45

56
from avocado.core.dependencies.requirements import cache
@@ -109,6 +110,16 @@ async def terminate_task(self, runtime_task):
109110
pass
110111
return returncode is not None
111112

113+
async def stop_task(self, runtime_task):
114+
try:
115+
runtime_task.spawner_handle.process.send_signal(signal.SIGTSTP)
116+
except ProcessLookupError:
117+
return False
118+
return
119+
120+
async def resume_task(self, runtime_task):
121+
await self.stop_task(runtime_task)
122+
112123
@staticmethod
113124
async def check_task_requirements(runtime_task):
114125
"""Check the runtime task requirements needed to be able to run"""

docs/source/guides/contributor/chapters/tips.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@ During the execution look for::
3333

3434
avocado --show avocado.utils.debug run examples/tests/assets.py
3535

36+
Interrupting test
37+
-----------------
38+
39+
In case you want to "pause" the running test, you can use SIGTSTP (ctrl+z)
40+
signal sent to the main avocado process. This signal is forwarded to test
41+
and it's children processes. To resume testing you repeat the same signal.
42+
43+
.. note::
44+
The job timeouts are still enabled on stopped processes.
45+
46+
.. note::
47+
It is supported on on process spawner only.
48+
49+
.. warning::
50+
This feature is meant only for debugging purposes and it can
51+
cause unreliable behavior especially if the signal is sent during the
52+
test initialization. Therefore use it with caution.
53+
3654
Line-profiler
3755
-------------
3856

selftests/check.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"unit": 682,
3131
"jobs": 11,
3232
"functional-parallel": 317,
33-
"functional-serial": 7,
33+
"functional-serial": 8,
3434
"optional-plugins": 0,
3535
"optional-plugins-golang": 2,
3636
"optional-plugins-html": 3,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import os
2+
import re
3+
import signal
4+
import time
5+
6+
from avocado.utils import process, script
7+
from selftests.utils import AVOCADO, TestCaseTmpDir
8+
9+
SLEEP_TEST = """import time
10+
11+
from avocado import Test
12+
13+
14+
class SleepTest(Test):
15+
16+
timeout = 10
17+
18+
def test(self):
19+
self.log.debug("Sleeping starts: %s", time.time())
20+
time.sleep(5)
21+
self.log.debug("Sleeping ends: %s", time.time())
22+
"""
23+
24+
25+
class RunnerOperationTest(TestCaseTmpDir):
26+
def test_pause(self):
27+
with script.TemporaryScript(
28+
"sleep.py",
29+
SLEEP_TEST,
30+
) as tst:
31+
cmd_line = f"{AVOCADO} run --disable-sysinfo --job-results-dir {self.tmpdir.name} -- {tst}"
32+
proc = process.SubProcess(cmd_line)
33+
proc.start()
34+
init = True
35+
while init:
36+
output = proc.get_stdout()
37+
if b"STARTED" in output:
38+
init = False
39+
time.sleep(1)
40+
proc.send_signal(signal.SIGTSTP)
41+
time.sleep(10)
42+
proc.send_signal(signal.SIGTSTP)
43+
proc.wait()
44+
full_log_path = os.path.join(self.tmpdir.name, "latest", "full.log")
45+
with open(full_log_path, encoding="utf-8") as full_log_file:
46+
full_log = full_log_file.read()
47+
self.assertIn("SleepTest.test: PAUSED", full_log)
48+
self.assertIn("SleepTest.test: STARTED", full_log)
49+
self.assertIn("Sleeping starts:", full_log)
50+
self.assertIn("Sleeping ends:", full_log)
51+
regex_start = re.search("Sleeping starts: ([0-9]*)", full_log)
52+
regex_end = re.search("Sleeping ends: ([0-9]*)", full_log)
53+
start_time = int(regex_start.group(1))
54+
end_time = int(regex_end.group(1))
55+
self.assertGreaterEqual(end_time - start_time, 10)

0 commit comments

Comments
 (0)