Skip to content

Commit b1e8453

Browse files
committed
gh-98894: Restore function entry/exit DTrace probes
The function__entry and function__return probes stopped working in Python 3.11 when the interpreter was restructured around the new bytecode system. This change restores these probes by adding DTRACE_FUNCTION_ENTRY() at the start_frame label in bytecodes.c and DTRACE_FUNCTION_RETURN() in the RETURN_VALUE and YIELD_VALUE instructions. The helper functions are defined in ceval.c and extract the filename, function name, and line number from the frame before firing the probe. This builds on the approach from #125019 but avoids modifying the JIT template since the JIT does not currently support DTrace. The macros are conditionally compiled with WITH_DTRACE and are no-ops otherwise. The tests have been updated to use modern opcode names (CALL, CALL_KW, CALL_FUNCTION_EX) and a new bpftrace backend was added for Linux CI alongside the existing SystemTap tests. Line probe tests were removed since that probe was never restored after 3.11.
1 parent ef51a7c commit b1e8453

File tree

12 files changed

+237
-57
lines changed

12 files changed

+237
-57
lines changed

Lib/test/dtracedata/call_stack.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ def function_1():
55
def function_2():
66
function_1()
77

8-
# CALL_FUNCTION_VAR
8+
# CALL with positional args
99
def function_3(dummy, dummy2):
1010
pass
1111

12-
# CALL_FUNCTION_KW
12+
# CALL_KW (keyword arguments)
1313
def function_4(**dummy):
1414
return 1
1515
return 2 # unreachable
1616

17-
# CALL_FUNCTION_VAR_KW
17+
# CALL_FUNCTION_EX (unpacking)
1818
def function_5(dummy, dummy2, **dummy3):
1919
if False:
2020
return 7

Lib/test/dtracedata/call_stack.stp.expected

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
function__entry:call_stack.py:start:23
21
function__entry:call_stack.py:function_1:1
2+
function__entry:call_stack.py:function_3:9
3+
function__return:call_stack.py:function_3:10
34
function__return:call_stack.py:function_1:2
45
function__entry:call_stack.py:function_2:5
56
function__entry:call_stack.py:function_1:1
7+
function__return:call_stack.py:function_3:10
68
function__return:call_stack.py:function_1:2
79
function__return:call_stack.py:function_2:6
810
function__entry:call_stack.py:function_3:9
@@ -11,4 +13,3 @@ function__entry:call_stack.py:function_4:13
1113
function__return:call_stack.py:function_4:14
1214
function__entry:call_stack.py:function_5:18
1315
function__return:call_stack.py:function_5:21
14-
function__return:call_stack.py:start:28

Lib/test/dtracedata/line.d

Lines changed: 0 additions & 7 deletions
This file was deleted.

Lib/test/dtracedata/line.d.expected

Lines changed: 0 additions & 20 deletions
This file was deleted.

Lib/test/dtracedata/line.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

Lib/test/test_dtrace.py

Lines changed: 173 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,17 @@ def normalize_trace_output(output):
3333
result = [
3434
row.split("\t")
3535
for row in output.splitlines()
36-
if row and not row.startswith('#')
36+
if row and not row.startswith('#') and not row.startswith('@')
3737
]
3838
result.sort(key=lambda row: int(row[0]))
3939
result = [row[1] for row in result]
40-
return "\n".join(result)
40+
# Normalize paths to basenames (bpftrace outputs full paths)
41+
normalized = []
42+
for line in result:
43+
# Replace full paths with just the filename
44+
line = re.sub(r'/[^:]+/([^/:]+\.py)', r'\1', line)
45+
normalized.append(line)
46+
return "\n".join(normalized)
4147
except (IndexError, ValueError):
4248
raise AssertionError(
4349
"tracer produced unparsable output:\n{}".format(output)
@@ -103,6 +109,156 @@ class SystemTapBackend(TraceBackend):
103109
COMMAND = ["stap", "-g"]
104110

105111

112+
class BPFTraceBackend(TraceBackend):
113+
EXTENSION = ".bt"
114+
COMMAND = ["bpftrace"]
115+
116+
# Inline bpftrace programs for each test case
117+
PROGRAMS = {
118+
"call_stack": """
119+
usdt:{python}:python:function__entry {{
120+
printf("%lld\\tfunction__entry:%s:%s:%d\\n",
121+
nsecs, str(arg0), str(arg1), arg2);
122+
}}
123+
usdt:{python}:python:function__return {{
124+
printf("%lld\\tfunction__return:%s:%s:%d\\n",
125+
nsecs, str(arg0), str(arg1), arg2);
126+
}}
127+
""",
128+
"gc": """
129+
usdt:{python}:python:function__entry {{
130+
if (str(arg1) == "start") {{ @tracing = 1; }}
131+
}}
132+
usdt:{python}:python:function__return {{
133+
if (str(arg1) == "start") {{ @tracing = 0; }}
134+
}}
135+
usdt:{python}:python:gc__start {{
136+
if (@tracing) {{
137+
printf("%lld\\tgc__start:%d\\n", nsecs, arg0);
138+
}}
139+
}}
140+
usdt:{python}:python:gc__done {{
141+
if (@tracing) {{
142+
printf("%lld\\tgc__done:%lld\\n", nsecs, arg0);
143+
}}
144+
}}
145+
END {{ clear(@tracing); }}
146+
""",
147+
}
148+
149+
# Which test scripts to filter by filename (None = use @tracing flag)
150+
FILTER_BY_FILENAME = {"call_stack": "call_stack.py"}
151+
152+
# Expected outputs for each test case
153+
# Note: bpftrace captures <module> entry/return and may have slight timing
154+
# differences compared to SystemTap due to probe firing order
155+
EXPECTED = {
156+
"call_stack": """function__entry:call_stack.py:<module>:0
157+
function__entry:call_stack.py:start:23
158+
function__entry:call_stack.py:function_1:1
159+
function__entry:call_stack.py:function_3:9
160+
function__return:call_stack.py:function_3:10
161+
function__return:call_stack.py:function_1:2
162+
function__entry:call_stack.py:function_2:5
163+
function__entry:call_stack.py:function_1:1
164+
function__return:call_stack.py:function_3:10
165+
function__return:call_stack.py:function_1:2
166+
function__return:call_stack.py:function_2:6
167+
function__entry:call_stack.py:function_3:9
168+
function__return:call_stack.py:function_3:10
169+
function__entry:call_stack.py:function_4:13
170+
function__return:call_stack.py:function_4:14
171+
function__entry:call_stack.py:function_5:18
172+
function__return:call_stack.py:function_5:21
173+
function__return:call_stack.py:start:28
174+
function__return:call_stack.py:<module>:30""",
175+
"gc": """gc__start:0
176+
gc__done:0
177+
gc__start:1
178+
gc__done:0
179+
gc__start:2
180+
gc__done:0
181+
gc__start:2
182+
gc__done:1""",
183+
}
184+
185+
def run_case(self, name, optimize_python=None):
186+
if name not in self.PROGRAMS:
187+
raise unittest.SkipTest(f"No bpftrace program for {name}")
188+
189+
python_file = abspath(name + ".py")
190+
python_flags = []
191+
if optimize_python:
192+
python_flags.extend(["-O"] * optimize_python)
193+
194+
subcommand = [sys.executable] + python_flags + [python_file]
195+
program = self.PROGRAMS[name].format(python=sys.executable)
196+
197+
try:
198+
proc = subprocess.Popen(
199+
["bpftrace", "-e", program, "-c", " ".join(subcommand)],
200+
stdout=subprocess.PIPE,
201+
stderr=subprocess.PIPE,
202+
universal_newlines=True,
203+
)
204+
stdout, stderr = proc.communicate(timeout=60)
205+
except subprocess.TimeoutExpired:
206+
proc.kill()
207+
raise AssertionError("bpftrace timed out")
208+
except (FileNotFoundError, PermissionError) as e:
209+
raise unittest.SkipTest(f"bpftrace not available: {e}")
210+
211+
if proc.returncode != 0:
212+
raise AssertionError(
213+
f"bpftrace failed with code {proc.returncode}:\n{stderr}"
214+
)
215+
216+
# Filter output by filename if specified (bpftrace captures everything)
217+
if name in self.FILTER_BY_FILENAME:
218+
filter_filename = self.FILTER_BY_FILENAME[name]
219+
filtered_lines = [
220+
line for line in stdout.splitlines()
221+
if filter_filename in line
222+
]
223+
stdout = "\n".join(filtered_lines)
224+
225+
actual_output = normalize_trace_output(stdout)
226+
expected_output = self.EXPECTED[name].strip()
227+
228+
return (expected_output, actual_output)
229+
230+
def assert_usable(self):
231+
# Check if bpftrace is available and can attach to USDT probes
232+
program = f'usdt:{sys.executable}:python:function__entry {{ printf("probe: success\\n"); exit(); }}'
233+
try:
234+
proc = subprocess.Popen(
235+
["bpftrace", "-e", program, "-c", f"{sys.executable} -c pass"],
236+
stdout=subprocess.PIPE,
237+
stderr=subprocess.PIPE,
238+
universal_newlines=True,
239+
)
240+
stdout, stderr = proc.communicate(timeout=10)
241+
except subprocess.TimeoutExpired:
242+
proc.kill()
243+
proc.communicate() # Clean up
244+
raise unittest.SkipTest("bpftrace timed out during usability check")
245+
except OSError as e:
246+
raise unittest.SkipTest(f"bpftrace not available: {e}")
247+
248+
# Check for permission errors (bpftrace usually requires root)
249+
if proc.returncode != 0:
250+
raise unittest.SkipTest(
251+
f"bpftrace(1) failed with code {proc.returncode}: {stderr}"
252+
)
253+
254+
if "probe: success" not in stdout:
255+
raise unittest.SkipTest(
256+
f"bpftrace(1) failed: stdout={stdout!r} stderr={stderr!r}"
257+
)
258+
259+
260+
261+
106262
class TraceTests:
107263
# unittest.TestCase options
108264
maxDiff = None
@@ -126,7 +282,8 @@ def test_function_entry_return(self):
126282
def test_verify_call_opcodes(self):
127283
"""Ensure our call stack test hits all function call opcodes"""
128284

129-
opcodes = set(["CALL_FUNCTION", "CALL_FUNCTION_EX", "CALL_FUNCTION_KW"])
285+
# Modern Python uses CALL, CALL_KW, and CALL_FUNCTION_EX
286+
opcodes = set(["CALL", "CALL_FUNCTION_EX", "CALL_KW"])
130287

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

154-
def test_line(self):
155-
self.run_case("line")
156-
157311

158312
class DTraceNormalTests(TraceTests, unittest.TestCase):
159313
backend = DTraceBackend()
@@ -174,6 +328,17 @@ class SystemTapOptimizedTests(TraceTests, unittest.TestCase):
174328
backend = SystemTapBackend()
175329
optimize_python = 2
176330

331+
332+
class BPFTraceNormalTests(TraceTests, unittest.TestCase):
333+
backend = BPFTraceBackend()
334+
optimize_python = 0
335+
336+
337+
class BPFTraceOptimizedTests(TraceTests, unittest.TestCase):
338+
backend = BPFTraceBackend()
339+
optimize_python = 2
340+
341+
177342
class CheckDtraceProbes(unittest.TestCase):
178343
@classmethod
179344
def setUpClass(cls):
@@ -234,6 +399,8 @@ def test_check_probes(self):
234399
"Name: audit",
235400
"Name: gc__start",
236401
"Name: gc__done",
402+
"Name: function__entry",
403+
"Name: function__return",
237404
]
238405

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

247414
# Missing probes will be added in the future.
248415
missing_probe_names = [
249-
"Name: function__entry",
250-
"Name: function__return",
251416
"Name: line",
252417
]
253418

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Restore ``function__entry`` and ``function__return`` DTrace/SystemTap probes
2+
that were broken since Python 3.11.

Python/bytecodes.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,7 @@ dummy_func(
12421242
DEAD(retval);
12431243
SAVE_STACK();
12441244
assert(STACK_LEVEL() == 0);
1245+
DTRACE_FUNCTION_RETURN();
12451246
_Py_LeaveRecursiveCallPy(tstate);
12461247
// GH-99729: We need to unlink the frame *before* clearing it:
12471248
_PyInterpreterFrame *dying = frame;
@@ -1418,6 +1419,7 @@ dummy_func(
14181419
_PyStackRef temp = retval;
14191420
DEAD(retval);
14201421
SAVE_STACK();
1422+
DTRACE_FUNCTION_RETURN();
14211423
tstate->exc_info = gen->gi_exc_state.previous_item;
14221424
gen->gi_exc_state.previous_item = NULL;
14231425
_Py_LeaveRecursiveCallPy(tstate);
@@ -5564,6 +5566,7 @@ dummy_func(
55645566
if (too_deep) {
55655567
goto exit_unwind;
55665568
}
5569+
DTRACE_FUNCTION_ENTRY();
55675570
next_instr = frame->instr_ptr;
55685571
#ifdef Py_DEBUG
55695572
int lltrace = maybe_lltrace_resume_frame(frame, GLOBALS());

Python/ceval.c

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,38 @@ stop_tracing_and_jit(PyThreadState *tstate, _PyInterpreterFrame *frame)
14521452
#define DONT_SLP_VECTORIZE
14531453
#endif
14541454

1455+
#ifdef WITH_DTRACE
1456+
static void
1457+
dtrace_function_entry(_PyInterpreterFrame *frame)
1458+
{
1459+
const char *filename;
1460+
const char *funcname;
1461+
int lineno;
1462+
1463+
PyCodeObject *code = _PyFrame_GetCode(frame);
1464+
filename = PyUnicode_AsUTF8(code->co_filename);
1465+
funcname = PyUnicode_AsUTF8(code->co_name);
1466+
lineno = PyUnstable_InterpreterFrame_GetLine(frame);
1467+
1468+
PyDTrace_FUNCTION_ENTRY(filename, funcname, lineno);
1469+
}
1470+
1471+
static void
1472+
dtrace_function_return(_PyInterpreterFrame *frame)
1473+
{
1474+
const char *filename;
1475+
const char *funcname;
1476+
int lineno;
1477+
1478+
PyCodeObject *code = _PyFrame_GetCode(frame);
1479+
filename = PyUnicode_AsUTF8(code->co_filename);
1480+
funcname = PyUnicode_AsUTF8(code->co_name);
1481+
lineno = PyUnstable_InterpreterFrame_GetLine(frame);
1482+
1483+
PyDTrace_FUNCTION_RETURN(filename, funcname, lineno);
1484+
}
1485+
#endif
1486+
14551487
typedef struct {
14561488
_PyInterpreterFrame frame;
14571489
_PyStackRef stack[1];
@@ -1531,6 +1563,7 @@ _PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int
15311563
if (_Py_EnterRecursivePy(tstate)) {
15321564
goto early_exit;
15331565
}
1566+
DTRACE_FUNCTION_ENTRY();
15341567
#ifdef Py_GIL_DISABLED
15351568
/* Load thread-local bytecode */
15361569
if (frame->tlbc_index != ((_PyThreadStateImpl *)tstate)->tlbc_index) {

0 commit comments

Comments
 (0)