Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Lib/test/dtracedata/call_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ def function_1():
def function_2():
function_1()

# CALL_FUNCTION_VAR
# CALL with positional args
def function_3(dummy, dummy2):
pass

# CALL_FUNCTION_KW
# CALL_KW (keyword arguments)
def function_4(**dummy):
return 1
return 2 # unreachable

# CALL_FUNCTION_VAR_KW
# CALL_FUNCTION_EX (unpacking)
def function_5(dummy, dummy2, **dummy3):
if False:
return 7
Expand Down
5 changes: 3 additions & 2 deletions Lib/test/dtracedata/call_stack.stp.expected
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
function__entry:call_stack.py:start:23
function__entry:call_stack.py:function_1:1
function__entry:call_stack.py:function_3:9
function__return:call_stack.py:function_3:10
function__return:call_stack.py:function_1:2
function__entry:call_stack.py:function_2:5
function__entry:call_stack.py:function_1:1
function__return:call_stack.py:function_3:10
function__return:call_stack.py:function_1:2
function__return:call_stack.py:function_2:6
function__entry:call_stack.py:function_3:9
Expand All @@ -11,4 +13,3 @@ function__entry:call_stack.py:function_4:13
function__return:call_stack.py:function_4:14
function__entry:call_stack.py:function_5:18
function__return:call_stack.py:function_5:21
function__return:call_stack.py:start:28
7 changes: 0 additions & 7 deletions Lib/test/dtracedata/line.d

This file was deleted.

20 changes: 0 additions & 20 deletions Lib/test/dtracedata/line.d.expected

This file was deleted.

17 changes: 0 additions & 17 deletions Lib/test/dtracedata/line.py

This file was deleted.

181 changes: 173 additions & 8 deletions Lib/test/test_dtrace.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,17 @@ def normalize_trace_output(output):
result = [
row.split("\t")
for row in output.splitlines()
if row and not row.startswith('#')
if row and not row.startswith('#') and not row.startswith('@')
]
result.sort(key=lambda row: int(row[0]))
result = [row[1] for row in result]
return "\n".join(result)
# Normalize paths to basenames (bpftrace outputs full paths)
normalized = []
for line in result:
# Replace full paths with just the filename
line = re.sub(r'/[^:]+/([^/:]+\.py)', r'\1', line)
normalized.append(line)
return "\n".join(normalized)
except (IndexError, ValueError):
raise AssertionError(
"tracer produced unparsable output:\n{}".format(output)
Expand Down Expand Up @@ -103,6 +109,156 @@ class SystemTapBackend(TraceBackend):
COMMAND = ["stap", "-g"]


class BPFTraceBackend(TraceBackend):
EXTENSION = ".bt"
COMMAND = ["bpftrace"]

# Inline bpftrace programs for each test case
PROGRAMS = {
"call_stack": """
usdt:{python}:python:function__entry {{
printf("%lld\\tfunction__entry:%s:%s:%d\\n",
nsecs, str(arg0), str(arg1), arg2);
}}
usdt:{python}:python:function__return {{
printf("%lld\\tfunction__return:%s:%s:%d\\n",
nsecs, str(arg0), str(arg1), arg2);
}}
""",
"gc": """
usdt:{python}:python:function__entry {{
if (str(arg1) == "start") {{ @tracing = 1; }}
}}
usdt:{python}:python:function__return {{
if (str(arg1) == "start") {{ @tracing = 0; }}
}}
usdt:{python}:python:gc__start {{
if (@tracing) {{
printf("%lld\\tgc__start:%d\\n", nsecs, arg0);
}}
}}
usdt:{python}:python:gc__done {{
if (@tracing) {{
printf("%lld\\tgc__done:%lld\\n", nsecs, arg0);
}}
}}
END {{ clear(@tracing); }}
""",
}

# Which test scripts to filter by filename (None = use @tracing flag)
FILTER_BY_FILENAME = {"call_stack": "call_stack.py"}

# Expected outputs for each test case
# Note: bpftrace captures <module> entry/return and may have slight timing
# differences compared to SystemTap due to probe firing order
EXPECTED = {
"call_stack": """function__entry:call_stack.py:<module>:0
function__entry:call_stack.py:start:23
function__entry:call_stack.py:function_1:1
function__entry:call_stack.py:function_3:9
function__return:call_stack.py:function_3:10
function__return:call_stack.py:function_1:2
function__entry:call_stack.py:function_2:5
function__entry:call_stack.py:function_1:1
function__return:call_stack.py:function_3:10
function__return:call_stack.py:function_1:2
function__return:call_stack.py:function_2:6
function__entry:call_stack.py:function_3:9
function__return:call_stack.py:function_3:10
function__entry:call_stack.py:function_4:13
function__return:call_stack.py:function_4:14
function__entry:call_stack.py:function_5:18
function__return:call_stack.py:function_5:21
function__return:call_stack.py:start:28
function__return:call_stack.py:<module>:30""",
"gc": """gc__start:0
gc__done:0
gc__start:1
gc__done:0
gc__start:2
gc__done:0
gc__start:2
gc__done:1""",
}

def run_case(self, name, optimize_python=None):
if name not in self.PROGRAMS:
raise unittest.SkipTest(f"No bpftrace program for {name}")

python_file = abspath(name + ".py")
python_flags = []
if optimize_python:
python_flags.extend(["-O"] * optimize_python)

subcommand = [sys.executable] + python_flags + [python_file]
program = self.PROGRAMS[name].format(python=sys.executable)

try:
proc = subprocess.Popen(
["bpftrace", "-e", program, "-c", " ".join(subcommand)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
stdout, stderr = proc.communicate(timeout=60)
except subprocess.TimeoutExpired:
proc.kill()
raise AssertionError("bpftrace timed out")
except (FileNotFoundError, PermissionError) as e:
raise unittest.SkipTest(f"bpftrace not available: {e}")

if proc.returncode != 0:
raise AssertionError(
f"bpftrace failed with code {proc.returncode}:\n{stderr}"
)

# Filter output by filename if specified (bpftrace captures everything)
if name in self.FILTER_BY_FILENAME:
filter_filename = self.FILTER_BY_FILENAME[name]
filtered_lines = [
line for line in stdout.splitlines()
if filter_filename in line
]
stdout = "\n".join(filtered_lines)

actual_output = normalize_trace_output(stdout)
expected_output = self.EXPECTED[name].strip()

return (expected_output, actual_output)

def assert_usable(self):
# Check if bpftrace is available and can attach to USDT probes
program = f'usdt:{sys.executable}:python:function__entry {{ printf("probe: success\\n"); exit(); }}'
try:
proc = subprocess.Popen(
["bpftrace", "-e", program, "-c", f"{sys.executable} -c pass"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
stdout, stderr = proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate() # Clean up
raise unittest.SkipTest("bpftrace timed out during usability check")
except OSError as e:
raise unittest.SkipTest(f"bpftrace not available: {e}")

# Check for permission errors (bpftrace usually requires root)
if proc.returncode != 0:
raise unittest.SkipTest(
f"bpftrace(1) failed with code {proc.returncode}: {stderr}"
)

if "probe: success" not in stdout:
raise unittest.SkipTest(
f"bpftrace(1) failed: stdout={stdout!r} stderr={stderr!r}"
)




class TraceTests:
# unittest.TestCase options
maxDiff = None
Expand All @@ -126,7 +282,8 @@ def test_function_entry_return(self):
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"])
# Modern Python uses CALL, CALL_KW, and CALL_FUNCTION_EX
opcodes = set(["CALL", "CALL_FUNCTION_EX", "CALL_KW"])

with open(abspath("call_stack.py")) as f:
code_string = f.read()
Expand All @@ -151,9 +308,6 @@ def get_function_instructions(funcname):
def test_gc(self):
self.run_case("gc")

def test_line(self):
self.run_case("line")


class DTraceNormalTests(TraceTests, unittest.TestCase):
backend = DTraceBackend()
Expand All @@ -174,6 +328,17 @@ class SystemTapOptimizedTests(TraceTests, unittest.TestCase):
backend = SystemTapBackend()
optimize_python = 2


class BPFTraceNormalTests(TraceTests, unittest.TestCase):
backend = BPFTraceBackend()
optimize_python = 0


class BPFTraceOptimizedTests(TraceTests, unittest.TestCase):
backend = BPFTraceBackend()
optimize_python = 2


class CheckDtraceProbes(unittest.TestCase):
@classmethod
def setUpClass(cls):
Expand Down Expand Up @@ -234,6 +399,8 @@ def test_check_probes(self):
"Name: audit",
"Name: gc__start",
"Name: gc__done",
"Name: function__entry",
"Name: function__return",
]

for probe_name in available_probe_names:
Expand All @@ -246,8 +413,6 @@ def test_missing_probes(self):

# Missing probes will be added in the future.
missing_probe_names = [
"Name: function__entry",
"Name: function__return",
"Name: line",
]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Restore ``function__entry`` and ``function__return`` DTrace/SystemTap probes
that were broken since Python 3.11.
12 changes: 11 additions & 1 deletion Python/bytecodes.c
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,7 @@ dummy_func(
DEAD(retval);
SAVE_STACK();
assert(STACK_LEVEL() == 0);
DTRACE_FUNCTION_RETURN();
_Py_LeaveRecursiveCallPy(tstate);
// GH-99729: We need to unlink the frame *before* clearing it:
_PyInterpreterFrame *dying = frame;
Expand Down Expand Up @@ -1418,6 +1419,7 @@ dummy_func(
_PyStackRef temp = retval;
DEAD(retval);
SAVE_STACK();
DTRACE_FUNCTION_RETURN();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've covered return and yield, but you'll need a probe point for unwinding as well.

tstate->exc_info = gen->gi_exc_state.previous_item;
gen->gi_exc_state.previous_item = NULL;
_Py_LeaveRecursiveCallPy(tstate);
Expand Down Expand Up @@ -5530,6 +5532,12 @@ dummy_func(
}

spilled label(exit_unwind) {
assert(_PyErr_Occurred(tstate));
DTRACE_FUNCTION_RETURN();
goto exit_unwind_notrace;
}

spilled label(exit_unwind_notrace) {
assert(_PyErr_Occurred(tstate));
_Py_LeaveRecursiveCallPy(tstate);
assert(frame->owner != FRAME_OWNED_BY_INTERPRETER);
Expand Down Expand Up @@ -5562,8 +5570,9 @@ dummy_func(
spilled label(start_frame) {
int too_deep = _Py_EnterRecursivePy(tstate);
if (too_deep) {
goto exit_unwind;
goto exit_unwind_notrace;
}
DTRACE_FUNCTION_ENTRY();
next_instr = frame->instr_ptr;
#ifdef Py_DEBUG
int lltrace = maybe_lltrace_resume_frame(frame, GLOBALS());
Expand Down Expand Up @@ -5651,6 +5660,7 @@ dummy_func(
error:
exception_unwind:
exit_unwind:
exit_unwind_notrace:
handle_eval_breaker:
resume_frame:
start_frame:
Expand Down
Loading