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.
3 changes: 3 additions & 0 deletions 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 @@ -5564,6 +5566,7 @@ dummy_func(
if (too_deep) {
goto exit_unwind;
}
DTRACE_FUNCTION_ENTRY();
next_instr = frame->instr_ptr;
#ifdef Py_DEBUG
int lltrace = maybe_lltrace_resume_frame(frame, GLOBALS());
Expand Down
33 changes: 33 additions & 0 deletions Python/ceval.c
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,38 @@ stop_tracing_and_jit(PyThreadState *tstate, _PyInterpreterFrame *frame)
#define DONT_SLP_VECTORIZE
#endif

#ifdef WITH_DTRACE
static void
dtrace_function_entry(_PyInterpreterFrame *frame)
{
const char *filename;
const char *funcname;
int lineno;

PyCodeObject *code = _PyFrame_GetCode(frame);
filename = PyUnicode_AsUTF8(code->co_filename);
funcname = PyUnicode_AsUTF8(code->co_name);
lineno = PyUnstable_InterpreterFrame_GetLine(frame);
Copy link
Member

Choose a reason for hiding this comment

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

This is expensive. In sys.monitoring there is a cache of line numbers created for line events.
You could reuse that, but it does require allocating monitoring data for the code object.

Ultimately, I'd like to redesign the location table to use a series of frames. That way finding the location info could be done with a binary search followed by a short linear scan.


PyDTrace_FUNCTION_ENTRY(filename, funcname, lineno);
}

static void
dtrace_function_return(_PyInterpreterFrame *frame)
{
const char *filename;
const char *funcname;
int lineno;

PyCodeObject *code = _PyFrame_GetCode(frame);
filename = PyUnicode_AsUTF8(code->co_filename);
funcname = PyUnicode_AsUTF8(code->co_name);
lineno = PyUnstable_InterpreterFrame_GetLine(frame);

PyDTrace_FUNCTION_RETURN(filename, funcname, lineno);
}
#endif

typedef struct {
_PyInterpreterFrame frame;
_PyStackRef stack[1];
Expand Down Expand Up @@ -1531,6 +1563,7 @@ _PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int
if (_Py_EnterRecursivePy(tstate)) {
goto early_exit;
}
DTRACE_FUNCTION_ENTRY();
Copy link
Member

Choose a reason for hiding this comment

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

This duplicates the probe point in start_frame:

#ifdef Py_GIL_DISABLED
/* Load thread-local bytecode */
if (frame->tlbc_index != ((_PyThreadStateImpl *)tstate)->tlbc_index) {
Expand Down
Loading
Loading