userver: en/testsuite/daemons/spawn.py Source File
Loading...
Searching...
No Matches
spawn.py
1import asyncio
2import contextlib
3import ctypes
4import inspect
5import logging
6import signal
7import subprocess
8import sys
9import time
10from collections.abc import AsyncGenerator, Sequence
11
12from testsuite.utils import traceback
13
14SIGNAL_ERRORS: dict[int, str] = {
15 signal.SIGSEGV: (
16 'Service crashed with {signal_name} signal (segmentation fault)'
17 ),
18 signal.SIGABRT: 'Service aborted by {signal_name} signal',
19}
20DEFAULT_SIGNAL_ERROR = 'Service terminated by {signal_name} signal'
21
22_KNOWN_SIGNALS: dict[int, str] = {
23 signal.SIGABRT: 'SIGABRT',
24 signal.SIGBUS: 'SIGBUS',
25 signal.SIGFPE: 'SIGFPE',
26 signal.SIGHUP: 'SIGHUP',
27 signal.SIGINT: 'SIGINT',
28 signal.SIGKILL: 'SIGKILL',
29 signal.SIGPIPE: 'SIGPIPE',
30 signal.SIGSEGV: 'SIGSEGV',
31 signal.SIGTERM: 'SIGTERM',
32}
33_POLL_TIMEOUT = 0.1
34
35logger = logging.getLogger(__name__)
36
37
38class BaseError(Exception):
39 pass
40
41
43 pass
44
45
47 def __init__(self, message: str, exit_code: int) -> None:
48 super().__init__(message)
49 self.exit_code = exit_code
50
51
52# TODO: drop py3.6 support and use asyncio.Process instead
54 def __init__(self, loop=None):
55 self._tasks = []
56 self._loop = loop
57
58 async def aclose(self):
59 if self._tasks:
60 await asyncio.wait(self._tasks)
61
62 async def add(self, pipe, handler):
63 if not pipe or not handler:
64 return
65 is_coro = inspect.iscoroutinefunction(handler)
66 reader = await _create_pipe_reader(pipe, loop=self._loop)
67
68 async def data_handler():
69 async for line in reader:
70 if is_coro:
71 await handler(line)
72 else:
73 handler(line)
74
75 coro = data_handler()
76 self._tasks.append(asyncio.create_task(coro))
77
78
79@contextlib.asynccontextmanager
80async def spawned(
81 args: Sequence[str],
82 *,
83 shutdown_signal: int = signal.SIGINT,
84 shutdown_timeout: float = 120,
85 subprocess_spawner=None,
86 stdout_handler=None,
87 stderr_handler=None,
88 **kwargs,
89) -> AsyncGenerator[subprocess.Popen, None]:
90 if stdout_handler:
91 kwargs['stdout'] = subprocess.PIPE
92 if stderr_handler:
93 kwargs['stderr'] = subprocess.PIPE
94
95 kwargs['preexec_fn'] = kwargs.get('preexec_fn', _setup_process)
96
97 logger.debug('Starting process with args %r', args)
98 if subprocess_spawner:
99 process = subprocess_spawner(args, **kwargs)
100 else:
101 process = subprocess.Popen(args, **kwargs)
102 logging.debug('[%d] Process started', process.pid)
103
104 readers = AioReaders()
105 await readers.add(process.stdout, stdout_handler)
106 await readers.add(process.stderr, stderr_handler)
107
108 async with contextlib.aclosing(readers):
109 async with _shutdown_service(
110 process,
111 shutdown_signal=shutdown_signal,
112 shutdown_timeout=shutdown_timeout,
113 ):
114 yield process
115
116
117def exit_code_error(process: subprocess.Popen) -> ExitCodeError:
118 retcode = process.returncode
119 return ExitCodeError(_exit_code_text(retcode), retcode)
120
121
122def _exit_code_text(retcode: int):
123 if retcode >= 0:
124 return f'Service exited with status code {retcode}'
125 signal_name = _pretty_signal(-retcode)
126 signal_error_fmt = SIGNAL_ERRORS.get(-retcode, DEFAULT_SIGNAL_ERROR)
127 return signal_error_fmt.format(signal_name=signal_name)
128
129
130@contextlib.asynccontextmanager
131async def _shutdown_service(*args, **kwargs):
132 try:
133 yield
134 finally:
135 await _do_service_shutdown(*args, **kwargs)
136
137
138async def _do_service_shutdown(process, *, shutdown_signal, shutdown_timeout):
139 allowed_exit_codes = (-shutdown_signal, 0)
140
141 retcode = process.poll()
142 if retcode is not None:
143 logger.info(
144 '[%d] Process already finished with code %d', process.pid, retcode
145 )
146 if retcode not in allowed_exit_codes:
147 raise exit_code_error(process)
148 return retcode
149
150 try:
151 process.send_signal(shutdown_signal)
152 except OSError:
153 pass
154 else:
155 logger.info(
156 '[%d] Trying to stop process with signal %s',
157 process.pid,
158 _pretty_signal(shutdown_signal),
159 )
160 poll_start = time.monotonic()
161 while True:
162 retcode = process.poll()
163 if retcode is not None:
164 if retcode not in allowed_exit_codes:
165 raise exit_code_error(process)
166 return retcode
167 current_time = time.monotonic()
168 if current_time - poll_start > shutdown_timeout:
169 break
170 await asyncio.sleep(_POLL_TIMEOUT)
171
172 logger.warning(
173 '[%d] Process did not finished within shutdown timeout %d seconds',
174 process.pid,
175 shutdown_timeout,
176 )
177
178 logger.warning('[%d] Now killing process with signal SIGKILL', process.pid)
179 while True:
180 retcode = process.poll()
181 if retcode is not None:
182 raise exit_code_error(process)
183 try:
184 process.send_signal(signal.SIGKILL)
185 except OSError:
186 continue
187 await asyncio.sleep(_POLL_TIMEOUT)
188
189
190def _pretty_signal(signum: int) -> str:
191 if signum in _KNOWN_SIGNALS:
192 return _KNOWN_SIGNALS[signum]
193 return str(signum)
194
195
196async def _create_pipe_reader(pipe, loop=None):
197 if loop is None:
198 loop = asyncio.get_running_loop()
199 reader = asyncio.StreamReader(loop=loop)
200 reader_protocol = asyncio.StreamReaderProtocol(reader)
201 await loop.connect_read_pipe(lambda: reader_protocol, pipe)
202 return reader
203
204
205# Send SIGKILL to child process on unexpected parent termination
206_PR_SET_PDEATHSIG = 1
207if sys.platform == 'linux':
208 _LIBC = ctypes.CDLL('libc.so.6')
209else:
210 _LIBC = None
211
212
213def _setup_process() -> None:
214 if _LIBC is not None:
215 _LIBC.prctl(_PR_SET_PDEATHSIG, signal.SIGKILL)
216
217
218__tracebackhide__ = traceback.hide(BaseError)