| import dis | |
| import os.path | |
| import re | |
| import subprocess | |
| import sys | |
| import sysconfig | |
| import types | |
| import unittest | |
| from test import support | |
| from test.support import findfile | |
| if not support.has_subprocess_support: | |
| raise unittest.SkipTest("test module requires subprocess") | |
| def abspath(filename): | |
| return os.path.abspath(findfile(filename, subdir="dtracedata")) | |
| def normalize_trace_output(output): | |
| """Normalize DTrace output for comparison. | |
| DTrace keeps a per-CPU buffer, and when showing the fired probes, buffers | |
| are concatenated. So if the operating system moves our thread around, the | |
| straight result can be "non-causal". So we add timestamps to the probe | |
| firing, sort by that field, then strip it from the output""" | |
| # When compiling with '--with-pydebug', strip '[# refs]' debug output. | |
| output = re.sub(r"\[[0-9]+ refs\]", "", output) | |
| try: | |
| result = [ | |
| row.split("\t") | |
| for row in output.splitlines() | |
| if row and not row.startswith('#') | |
| ] | |
| result.sort(key=lambda row: int(row[0])) | |
| result = [row[1] for row in result] | |
| return "\n".join(result) | |
| except (IndexError, ValueError): | |
| raise AssertionError( | |
| "tracer produced unparsable output:\n{}".format(output) | |
| ) | |
| class TraceBackend: | |
| EXTENSION = None | |
| COMMAND = None | |
| COMMAND_ARGS = [] | |
| def run_case(self, name, optimize_python=None): | |
| actual_output = normalize_trace_output(self.trace_python( | |
| script_file=abspath(name + self.EXTENSION), | |
| python_file=abspath(name + ".py"), | |
| optimize_python=optimize_python)) | |
| with open(abspath(name + self.EXTENSION + ".expected")) as f: | |
| expected_output = f.read().rstrip() | |
| return (expected_output, actual_output) | |
| def generate_trace_command(self, script_file, subcommand=None): | |
| command = self.COMMAND + [script_file] | |
| if subcommand: | |
| command += ["-c", subcommand] | |
| return command | |
| def trace(self, script_file, subcommand=None): | |
| command = self.generate_trace_command(script_file, subcommand) | |
| stdout, _ = subprocess.Popen(command, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| universal_newlines=True).communicate() | |
| return stdout | |
| def trace_python(self, script_file, python_file, optimize_python=None): | |
| python_flags = [] | |
| if optimize_python: | |
| python_flags.extend(["-O"] * optimize_python) | |
| subcommand = " ".join([sys.executable] + python_flags + [python_file]) | |
| return self.trace(script_file, subcommand) | |
| def assert_usable(self): | |
| try: | |
| output = self.trace(abspath("assert_usable" + self.EXTENSION)) | |
| output = output.strip() | |
| except (FileNotFoundError, NotADirectoryError, PermissionError) as fnfe: | |
| output = str(fnfe) | |
| if output != "probe: success": | |
| raise unittest.SkipTest( | |
| "{}(1) failed: {}".format(self.COMMAND[0], output) | |
| ) | |
| class DTraceBackend(TraceBackend): | |
| EXTENSION = ".d" | |
| COMMAND = ["dtrace", "-q", "-s"] | |
| class SystemTapBackend(TraceBackend): | |
| EXTENSION = ".stp" | |
| COMMAND = ["stap", "-g"] | |
| class TraceTests: | |
| # unittest.TestCase options | |
| maxDiff = None | |
| # TraceTests options | |
| backend = None | |
| optimize_python = 0 | |
| @classmethod | |
| def setUpClass(self): | |
| self.backend.assert_usable() | |
| def run_case(self, name): | |
| actual_output, expected_output = self.backend.run_case( | |
| name, optimize_python=self.optimize_python) | |
| self.assertEqual(actual_output, expected_output) | |
| def test_function_entry_return(self): | |
| self.run_case("call_stack") | |
| def test_verify_call_opcodes(self): | |
| """Ensure our call stack test hits all function call opcodes""" | |
| opcodes = set(["CALL_FUNCTION", "CALL_FUNCTION_EX", "CALL_FUNCTION_KW"]) | |
| with open(abspath("call_stack.py")) as f: | |
| code_string = f.read() | |
| def get_function_instructions(funcname): | |
| # Recompile with appropriate optimization setting | |
| code = compile(source=code_string, | |
| filename="<string>", | |
| mode="exec", | |
| optimize=self.optimize_python) | |
| for c in code.co_consts: | |
| if isinstance(c, types.CodeType) and c.co_name == funcname: | |
| return dis.get_instructions(c) | |
| return [] | |
| for instruction in get_function_instructions('start'): | |
| opcodes.discard(instruction.opname) | |
| self.assertEqual(set(), opcodes) | |
| def test_gc(self): | |
| self.run_case("gc") | |
| def test_line(self): | |
| self.run_case("line") | |
| class DTraceNormalTests(TraceTests, unittest.TestCase): | |
| backend = DTraceBackend() | |
| optimize_python = 0 | |
| class DTraceOptimizedTests(TraceTests, unittest.TestCase): | |
| backend = DTraceBackend() | |
| optimize_python = 2 | |
| class SystemTapNormalTests(TraceTests, unittest.TestCase): | |
| backend = SystemTapBackend() | |
| optimize_python = 0 | |
| class SystemTapOptimizedTests(TraceTests, unittest.TestCase): | |
| backend = SystemTapBackend() | |
| optimize_python = 2 | |
| class CheckDtraceProbes(unittest.TestCase): | |
| @classmethod | |
| def setUpClass(cls): | |
| if sysconfig.get_config_var('WITH_DTRACE'): | |
| readelf_major_version, readelf_minor_version = cls.get_readelf_version() | |
| if support.verbose: | |
| print(f"readelf version: {readelf_major_version}.{readelf_minor_version}") | |
| else: | |
| raise unittest.SkipTest("CPython must be configured with the --with-dtrace option.") | |
| @staticmethod | |
| def get_readelf_version(): | |
| try: | |
| cmd = ["readelf", "--version"] | |
| proc = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| universal_newlines=True, | |
| ) | |
| with proc: | |
| version, stderr = proc.communicate() | |
| if proc.returncode: | |
| raise Exception( | |
| f"Command {' '.join(cmd)!r} failed " | |
| f"with exit code {proc.returncode}: " | |
| f"stdout={version!r} stderr={stderr!r}" | |
| ) | |
| except OSError: | |
| raise unittest.SkipTest("Couldn't find readelf on the path") | |
| # Regex to parse: | |
| # 'GNU readelf (GNU Binutils) 2.40.0\n' -> 2.40 | |
| match = re.search(r"^(?:GNU) readelf.*?\b(\d+)\.(\d+)", version) | |
| if match is None: | |
| raise unittest.SkipTest(f"Unable to parse readelf version: {version}") | |
| return int(match.group(1)), int(match.group(2)) | |
| def get_readelf_output(self): | |
| command = ["readelf", "-n", sys.executable] | |
| stdout, _ = subprocess.Popen( | |
| command, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| universal_newlines=True, | |
| ).communicate() | |
| return stdout | |
| def test_check_probes(self): | |
| readelf_output = self.get_readelf_output() | |
| available_probe_names = [ | |
| "Name: import__find__load__done", | |
| "Name: import__find__load__start", | |
| "Name: audit", | |
| "Name: gc__start", | |
| "Name: gc__done", | |
| ] | |
| for probe_name in available_probe_names: | |
| with self.subTest(probe_name=probe_name): | |
| self.assertIn(probe_name, readelf_output) | |
| @unittest.expectedFailure | |
| def test_missing_probes(self): | |
| readelf_output = self.get_readelf_output() | |
| # Missing probes will be added in the future. | |
| missing_probe_names = [ | |
| "Name: function__entry", | |
| "Name: function__return", | |
| "Name: line", | |
| ] | |
| for probe_name in missing_probe_names: | |
| with self.subTest(probe_name=probe_name): | |
| self.assertIn(probe_name, readelf_output) | |
| if __name__ == '__main__': | |
| unittest.main() |