Commit | Line | Data |
---|---|---|
ef945e4d JG |
1 | #!/usr/bin/env python3 |
2 | # | |
3 | # Copyright (C) 2022 Jérémie Galarneau <jeremie.galarneau@efficios.com> | |
4 | # | |
5 | # SPDX-License-Identifier: GPL-2.0-only | |
6 | # | |
7 | ||
8 | from types import FrameType | |
0ac0f70e | 9 | from typing import Callable, Iterator, Optional, Tuple, List, Generator |
ef945e4d JG |
10 | import sys |
11 | import pathlib | |
12 | import signal | |
13 | import subprocess | |
14 | import shlex | |
15 | import shutil | |
16 | import os | |
17 | import queue | |
18 | import tempfile | |
19 | from . import logger | |
20 | import time | |
21 | import threading | |
22 | import contextlib | |
23 | ||
24 | ||
25 | class TemporaryDirectory: | |
ce8470c9 MJ |
26 | def __init__(self, prefix): |
27 | # type: (str) -> None | |
ef945e4d JG |
28 | self._directory_path = tempfile.mkdtemp(prefix=prefix) |
29 | ||
30 | def __del__(self): | |
31 | shutil.rmtree(self._directory_path, ignore_errors=True) | |
32 | ||
33 | @property | |
ce8470c9 MJ |
34 | def path(self): |
35 | # type: () -> pathlib.Path | |
ef945e4d JG |
36 | return pathlib.Path(self._directory_path) |
37 | ||
38 | ||
39 | class _SignalWaitQueue: | |
40 | """ | |
41 | Utility class useful to wait for a signal before proceeding. | |
42 | ||
43 | Simply register the `signal` method as the handler for the signal you are | |
44 | interested in and call `wait_for_signal` to wait for its reception. | |
45 | ||
46 | Registering a signal: | |
47 | signal.signal(signal.SIGWHATEVER, queue.signal) | |
48 | ||
49 | Waiting for the signal: | |
50 | queue.wait_for_signal() | |
51 | """ | |
52 | ||
53 | def __init__(self): | |
ce8470c9 | 54 | self._queue = queue.Queue() # type: queue.Queue |
ef945e4d | 55 | |
ce8470c9 MJ |
56 | def signal( |
57 | self, | |
58 | signal_number, | |
59 | frame, # type: Optional[FrameType] | |
60 | ): | |
ef945e4d JG |
61 | self._queue.put_nowait(signal_number) |
62 | ||
63 | def wait_for_signal(self): | |
64 | self._queue.get(block=True) | |
65 | ||
0ac0f70e JG |
66 | @contextlib.contextmanager |
67 | def intercept_signal(self, signal_number): | |
68 | # type: (int) -> Generator[None, None, None] | |
69 | original_handler = signal.getsignal(signal_number) | |
70 | signal.signal(signal_number, self.signal) | |
71 | try: | |
72 | yield | |
73 | except: | |
74 | # Restore the original signal handler and forward the exception. | |
75 | raise | |
76 | finally: | |
77 | signal.signal(signal_number, original_handler) | |
78 | ||
ef945e4d JG |
79 | |
80 | class WaitTraceTestApplication: | |
81 | """ | |
82 | Create an application that waits before tracing. This allows a test to | |
83 | launch an application, get its PID, and get it to start tracing when it | |
84 | has completed its setup. | |
85 | """ | |
86 | ||
87 | def __init__( | |
88 | self, | |
ce8470c9 MJ |
89 | binary_path, # type: pathlib.Path |
90 | event_count, # type: int | |
91 | environment, # type: Environment | |
92 | wait_time_between_events_us=0, # type: int | |
ef945e4d | 93 | ): |
ce8470c9 | 94 | self._environment = environment # type: Environment |
ef07b7ae | 95 | self._iteration_count = event_count |
ef945e4d | 96 | # File that the application will wait to see before tracing its events. |
ce8470c9 | 97 | self._app_start_tracing_file_path = pathlib.Path( |
ef945e4d JG |
98 | tempfile.mktemp( |
99 | prefix="app_", | |
100 | suffix="_start_tracing", | |
2d2198ca | 101 | dir=self._compat_open_path(environment.lttng_home_location), |
ef945e4d JG |
102 | ) |
103 | ) | |
104 | self._has_returned = False | |
105 | ||
106 | test_app_env = os.environ.copy() | |
107 | test_app_env["LTTNG_HOME"] = str(environment.lttng_home_location) | |
108 | # Make sure the app is blocked until it is properly registered to | |
109 | # the session daemon. | |
110 | test_app_env["LTTNG_UST_REGISTER_TIMEOUT"] = "-1" | |
111 | ||
112 | # File that the application will create to indicate it has completed its initialization. | |
8466f071 | 113 | app_ready_file_path = tempfile.mktemp( |
2d2198ca MJ |
114 | prefix="app_", |
115 | suffix="_ready", | |
116 | dir=self._compat_open_path(environment.lttng_home_location), | |
ce8470c9 | 117 | ) # type: str |
ef945e4d JG |
118 | |
119 | test_app_args = [str(binary_path)] | |
120 | test_app_args.extend( | |
121 | shlex.split( | |
ef07b7ae | 122 | "--iter {iteration_count} --sync-application-in-main-touch {app_ready_file_path} --sync-before-first-event {app_start_tracing_file_path} --wait {wait_time_between_events_us}".format( |
ef945e4d JG |
123 | iteration_count=self._iteration_count, |
124 | app_ready_file_path=app_ready_file_path, | |
125 | app_start_tracing_file_path=self._app_start_tracing_file_path, | |
126 | wait_time_between_events_us=wait_time_between_events_us, | |
127 | ) | |
128 | ) | |
129 | ) | |
130 | ||
ce8470c9 | 131 | self._process = subprocess.Popen( |
ef945e4d JG |
132 | test_app_args, |
133 | env=test_app_env, | |
ce8470c9 | 134 | ) # type: subprocess.Popen |
ef945e4d JG |
135 | |
136 | # Wait for the application to create the file indicating it has fully | |
137 | # initialized. Make sure the app hasn't crashed in order to not wait | |
138 | # forever. | |
139 | while True: | |
140 | if os.path.exists(app_ready_file_path): | |
141 | break | |
142 | ||
143 | if self._process.poll() is not None: | |
144 | # Application has unexepectedly returned. | |
145 | raise RuntimeError( | |
146 | "Test application has unexepectedly returned during its initialization with return code `{return_code}`".format( | |
147 | return_code=self._process.returncode | |
148 | ) | |
149 | ) | |
150 | ||
151 | time.sleep(0.1) | |
152 | ||
ce8470c9 MJ |
153 | def trace(self): |
154 | # type: () -> None | |
ef945e4d JG |
155 | if self._process.poll() is not None: |
156 | # Application has unexepectedly returned. | |
157 | raise RuntimeError( | |
158 | "Test application has unexepectedly before tracing with return code `{return_code}`".format( | |
159 | return_code=self._process.returncode | |
160 | ) | |
161 | ) | |
2d2198ca | 162 | open(self._compat_open_path(self._app_start_tracing_file_path), mode="x") |
ef945e4d | 163 | |
ce8470c9 MJ |
164 | def wait_for_exit(self): |
165 | # type: () -> None | |
ef945e4d JG |
166 | if self._process.wait() != 0: |
167 | raise RuntimeError( | |
168 | "Test application has exit with return code `{return_code}`".format( | |
169 | return_code=self._process.returncode | |
170 | ) | |
171 | ) | |
172 | self._has_returned = True | |
173 | ||
174 | @property | |
ce8470c9 MJ |
175 | def vpid(self): |
176 | # type: () -> int | |
ef945e4d JG |
177 | return self._process.pid |
178 | ||
2d2198ca MJ |
179 | @staticmethod |
180 | def _compat_open_path(path): | |
ce8470c9 | 181 | # type: (pathlib.Path) -> pathlib.Path | str |
2d2198ca MJ |
182 | """ |
183 | The builtin open() in python >= 3.6 expects a path-like object while | |
184 | prior versions expect a string or bytes object. Return the correct type | |
185 | based on the presence of the "__fspath__" attribute specified in PEP-519. | |
186 | """ | |
187 | if hasattr(path, "__fspath__"): | |
188 | return path | |
189 | else: | |
190 | return str(path) | |
191 | ||
ef945e4d JG |
192 | def __del__(self): |
193 | if not self._has_returned: | |
194 | # This is potentially racy if the pid has been recycled. However, | |
195 | # we can't use pidfd_open since it is only available in python >= 3.9. | |
196 | self._process.kill() | |
197 | self._process.wait() | |
198 | ||
199 | ||
da1e97c9 MD |
200 | class TraceTestApplication: |
201 | """ | |
e88109fc JG |
202 | Create an application that emits events as soon as it is launched. In most |
203 | scenarios, it is preferable to use a WaitTraceTestApplication. | |
da1e97c9 MD |
204 | """ |
205 | ||
873d3601 MJ |
206 | def __init__(self, binary_path, environment): |
207 | # type: (pathlib.Path, Environment) | |
208 | self._environment = environment # type: Environment | |
da1e97c9 MD |
209 | self._has_returned = False |
210 | ||
211 | test_app_env = os.environ.copy() | |
212 | test_app_env["LTTNG_HOME"] = str(environment.lttng_home_location) | |
213 | # Make sure the app is blocked until it is properly registered to | |
214 | # the session daemon. | |
215 | test_app_env["LTTNG_UST_REGISTER_TIMEOUT"] = "-1" | |
216 | ||
217 | test_app_args = [str(binary_path)] | |
218 | ||
47ddc6e5 | 219 | self._process = subprocess.Popen( |
da1e97c9 | 220 | test_app_args, env=test_app_env |
47ddc6e5 | 221 | ) # type: subprocess.Popen |
da1e97c9 | 222 | |
873d3601 MJ |
223 | def wait_for_exit(self): |
224 | # type: () -> None | |
da1e97c9 MD |
225 | if self._process.wait() != 0: |
226 | raise RuntimeError( | |
227 | "Test application has exit with return code `{return_code}`".format( | |
228 | return_code=self._process.returncode | |
229 | ) | |
230 | ) | |
231 | self._has_returned = True | |
232 | ||
233 | def __del__(self): | |
234 | if not self._has_returned: | |
235 | # This is potentially racy if the pid has been recycled. However, | |
236 | # we can't use pidfd_open since it is only available in python >= 3.9. | |
237 | self._process.kill() | |
238 | self._process.wait() | |
239 | ||
240 | ||
ef945e4d JG |
241 | class ProcessOutputConsumer(threading.Thread, logger._Logger): |
242 | def __init__( | |
ce8470c9 MJ |
243 | self, |
244 | process, # type: subprocess.Popen | |
245 | name, # type: str | |
246 | log, # type: Callable[[str], None] | |
ef945e4d JG |
247 | ): |
248 | threading.Thread.__init__(self) | |
249 | self._prefix = name | |
250 | logger._Logger.__init__(self, log) | |
251 | self._process = process | |
252 | ||
ce8470c9 MJ |
253 | def run(self): |
254 | # type: () -> None | |
ef945e4d JG |
255 | while self._process.poll() is None: |
256 | assert self._process.stdout | |
257 | line = self._process.stdout.readline().decode("utf-8").replace("\n", "") | |
258 | if len(line) != 0: | |
259 | self._log("{prefix}: {line}".format(prefix=self._prefix, line=line)) | |
260 | ||
261 | ||
262 | # Generate a temporary environment in which to execute a test. | |
263 | class _Environment(logger._Logger): | |
264 | def __init__( | |
ce8470c9 MJ |
265 | self, |
266 | with_sessiond, # type: bool | |
267 | log=None, # type: Optional[Callable[[str], None]] | |
ef945e4d JG |
268 | ): |
269 | super().__init__(log) | |
270 | signal.signal(signal.SIGTERM, self._handle_termination_signal) | |
271 | signal.signal(signal.SIGINT, self._handle_termination_signal) | |
272 | ||
273 | # Assumes the project's hierarchy to this file is: | |
274 | # tests/utils/python/this_file | |
ce8470c9 MJ |
275 | self._project_root = ( |
276 | pathlib.Path(__file__).absolute().parents[3] | |
277 | ) # type: pathlib.Path | |
278 | self._lttng_home = TemporaryDirectory( | |
ef945e4d | 279 | "lttng_test_env_home" |
ce8470c9 | 280 | ) # type: Optional[TemporaryDirectory] |
ef945e4d | 281 | |
ce8470c9 | 282 | self._sessiond = ( |
ef945e4d | 283 | self._launch_lttng_sessiond() if with_sessiond else None |
ce8470c9 | 284 | ) # type: Optional[subprocess.Popen[bytes]] |
ef945e4d JG |
285 | |
286 | @property | |
ce8470c9 MJ |
287 | def lttng_home_location(self): |
288 | # type: () -> pathlib.Path | |
ef945e4d JG |
289 | if self._lttng_home is None: |
290 | raise RuntimeError("Attempt to access LTTng home after clean-up") | |
291 | return self._lttng_home.path | |
292 | ||
293 | @property | |
ce8470c9 MJ |
294 | def lttng_client_path(self): |
295 | # type: () -> pathlib.Path | |
ef945e4d JG |
296 | return self._project_root / "src" / "bin" / "lttng" / "lttng" |
297 | ||
ce8470c9 MJ |
298 | def create_temporary_directory(self, prefix=None): |
299 | # type: (Optional[str]) -> pathlib.Path | |
ef945e4d JG |
300 | # Simply return a path that is contained within LTTNG_HOME; it will |
301 | # be destroyed when the temporary home goes out of scope. | |
302 | assert self._lttng_home | |
303 | return pathlib.Path( | |
304 | tempfile.mkdtemp( | |
305 | prefix="tmp" if prefix is None else prefix, | |
306 | dir=str(self._lttng_home.path), | |
307 | ) | |
308 | ) | |
309 | ||
310 | # Unpack a list of environment variables from a string | |
311 | # such as "HELLO=is_it ME='/you/are/looking/for'" | |
312 | @staticmethod | |
ce8470c9 MJ |
313 | def _unpack_env_vars(env_vars_string): |
314 | # type: (str) -> List[Tuple[str, str]] | |
ef945e4d JG |
315 | unpacked_vars = [] |
316 | for var in shlex.split(env_vars_string): | |
317 | equal_position = var.find("=") | |
318 | # Must have an equal sign and not end with an equal sign | |
319 | if equal_position == -1 or equal_position == len(var) - 1: | |
320 | raise ValueError( | |
321 | "Invalid sessiond environment variable: `{}`".format(var) | |
322 | ) | |
323 | ||
324 | var_name = var[0:equal_position] | |
325 | var_value = var[equal_position + 1 :] | |
326 | # Unquote any paths | |
327 | var_value = var_value.replace("'", "") | |
328 | var_value = var_value.replace('"', "") | |
329 | unpacked_vars.append((var_name, var_value)) | |
330 | ||
331 | return unpacked_vars | |
332 | ||
ce8470c9 MJ |
333 | def _launch_lttng_sessiond(self): |
334 | # type: () -> Optional[subprocess.Popen] | |
ef945e4d JG |
335 | is_64bits_host = sys.maxsize > 2**32 |
336 | ||
337 | sessiond_path = ( | |
338 | self._project_root / "src" / "bin" / "lttng-sessiond" / "lttng-sessiond" | |
339 | ) | |
340 | consumerd_path_option_name = "--consumerd{bitness}-path".format( | |
341 | bitness="64" if is_64bits_host else "32" | |
342 | ) | |
343 | consumerd_path = ( | |
344 | self._project_root / "src" / "bin" / "lttng-consumerd" / "lttng-consumerd" | |
345 | ) | |
346 | ||
347 | no_sessiond_var = os.environ.get("TEST_NO_SESSIOND") | |
348 | if no_sessiond_var and no_sessiond_var == "1": | |
349 | # Run test without a session daemon; the user probably | |
350 | # intends to run one under gdb for example. | |
351 | return None | |
352 | ||
353 | # Setup the session daemon's environment | |
354 | sessiond_env_vars = os.environ.get("LTTNG_SESSIOND_ENV_VARS") | |
355 | sessiond_env = os.environ.copy() | |
356 | if sessiond_env_vars: | |
357 | self._log("Additional lttng-sessiond environment variables:") | |
358 | additional_vars = self._unpack_env_vars(sessiond_env_vars) | |
359 | for var_name, var_value in additional_vars: | |
360 | self._log(" {name}={value}".format(name=var_name, value=var_value)) | |
361 | sessiond_env[var_name] = var_value | |
362 | ||
363 | sessiond_env["LTTNG_SESSION_CONFIG_XSD_PATH"] = str( | |
364 | self._project_root / "src" / "common" | |
365 | ) | |
366 | ||
367 | assert self._lttng_home is not None | |
368 | sessiond_env["LTTNG_HOME"] = str(self._lttng_home.path) | |
369 | ||
370 | wait_queue = _SignalWaitQueue() | |
0ac0f70e JG |
371 | with wait_queue.intercept_signal(signal.SIGUSR1): |
372 | self._log( | |
373 | "Launching session daemon with LTTNG_HOME=`{home_dir}`".format( | |
374 | home_dir=str(self._lttng_home.path) | |
375 | ) | |
376 | ) | |
377 | process = subprocess.Popen( | |
378 | [ | |
379 | str(sessiond_path), | |
380 | consumerd_path_option_name, | |
381 | str(consumerd_path), | |
382 | "--sig-parent", | |
383 | ], | |
384 | stdout=subprocess.PIPE, | |
385 | stderr=subprocess.STDOUT, | |
386 | env=sessiond_env, | |
ef945e4d | 387 | ) |
ef945e4d | 388 | |
0ac0f70e JG |
389 | if self._logging_function: |
390 | self._sessiond_output_consumer = ProcessOutputConsumer( | |
391 | process, "lttng-sessiond", self._logging_function | |
392 | ) # type: Optional[ProcessOutputConsumer] | |
393 | self._sessiond_output_consumer.daemon = True | |
394 | self._sessiond_output_consumer.start() | |
ef945e4d | 395 | |
0ac0f70e JG |
396 | # Wait for SIGUSR1, indicating the sessiond is ready to proceed |
397 | wait_queue.wait_for_signal() | |
ef945e4d JG |
398 | |
399 | return process | |
400 | ||
ce8470c9 MJ |
401 | def _handle_termination_signal(self, signal_number, frame): |
402 | # type: (int, Optional[FrameType]) -> None | |
ef945e4d JG |
403 | self._log( |
404 | "Killed by {signal_name} signal, cleaning-up".format( | |
405 | signal_name=signal.strsignal(signal_number) | |
406 | ) | |
407 | ) | |
408 | self._cleanup() | |
409 | ||
ce8470c9 MJ |
410 | def launch_wait_trace_test_application(self, event_count): |
411 | # type: (int) -> WaitTraceTestApplication | |
ef945e4d JG |
412 | """ |
413 | Launch an application that will wait before tracing `event_count` events. | |
414 | """ | |
415 | return WaitTraceTestApplication( | |
416 | self._project_root | |
417 | / "tests" | |
418 | / "utils" | |
419 | / "testapp" | |
ef07b7ae JG |
420 | / "gen-ust-events" |
421 | / "gen-ust-events", | |
ef945e4d JG |
422 | event_count, |
423 | self, | |
424 | ) | |
425 | ||
873d3601 MJ |
426 | def launch_trace_test_constructor_application(self): |
427 | # type () -> TraceTestApplication | |
da1e97c9 MD |
428 | """ |
429 | Launch an application that will trace from within constructors. | |
430 | """ | |
431 | return TraceTestApplication( | |
432 | self._project_root | |
433 | / "tests" | |
434 | / "utils" | |
435 | / "testapp" | |
436 | / "gen-ust-events-constructor" | |
437 | / "gen-ust-events-constructor", | |
438 | self, | |
439 | ) | |
440 | ||
ef945e4d | 441 | # Clean-up managed processes |
ce8470c9 MJ |
442 | def _cleanup(self): |
443 | # type: () -> None | |
ef945e4d JG |
444 | if self._sessiond and self._sessiond.poll() is None: |
445 | # The session daemon is alive; kill it. | |
446 | self._log( | |
447 | "Killing session daemon (pid = {sessiond_pid})".format( | |
448 | sessiond_pid=self._sessiond.pid | |
449 | ) | |
450 | ) | |
451 | ||
452 | self._sessiond.terminate() | |
453 | self._sessiond.wait() | |
454 | if self._sessiond_output_consumer: | |
455 | self._sessiond_output_consumer.join() | |
456 | self._sessiond_output_consumer = None | |
457 | ||
458 | self._log("Session daemon killed") | |
459 | self._sessiond = None | |
460 | ||
461 | self._lttng_home = None | |
462 | ||
463 | def __del__(self): | |
464 | self._cleanup() | |
465 | ||
466 | ||
467 | @contextlib.contextmanager | |
ce8470c9 MJ |
468 | def test_environment(with_sessiond, log=None): |
469 | # type: (bool, Optional[Callable[[str], None]]) -> Iterator[_Environment] | |
ef945e4d JG |
470 | env = _Environment(with_sessiond, log) |
471 | try: | |
472 | yield env | |
473 | finally: | |
474 | env._cleanup() |