cpython/Lib/test/test_c_stack_unwind.py

408 lines
14 KiB
Python
Raw Permalink Normal View History

"""Test in-process C stack unwinders against Python and JIT frames.
The tests build a recursive Python call stack, ask each _testinternalcapi
unwinder for return addresses, and classify those addresses as Python, JIT, or
other frames. The backends include CPython's manual stack-chain unwinder and
GNU backtrace(), so this module is about in-process C stack unwinding rather
than a single unwind mechanism. GDB integration tests live in test_gdb.
"""
import json
import os
import platform
import subprocess
import sys
import sysconfig
import unittest
from test import support
from test.support import import_helper
_testinternalcapi = import_helper.import_module("_testinternalcapi")
if not support.has_subprocess_support:
raise unittest.SkipTest("test requires subprocess support")
STACK_DEPTH = 10
def _manual_unwind_expected(machine):
gh-149202: Fix frame pointer unwinding on s390x and ARM (GH-149362) -fno-omit-frame-pointer is not enough to make every target walkable by the simple manual frame pointer unwinder. The helper used by test_frame_pointer_unwind used to assume the frame pointer named a two-word record where fp[0] was the previous frame pointer and fp[1] was the return address. That is only the generic layout used by some targets. This patch keeps that default, but moves the slots behind named offsets so architecture-specific layouts can describe where the backchain and return address really live. On s390x, GCC and Clang do not emit a usable backchain unless -mbackchain is enabled. Without it, the unwinder stops at the current C frame and the test reports no Python frames. Once backchains are present, the helper must also stop at the current thread's known C stack bounds; otherwise it can follow the final backchain far enough to dereference an invalid frame and segfault. For Linux s390x backchain frames, the documented z/Architecture stack-frame layout saves r14, the return-address register, at byte offset 112 from the frame pointer, so read the return address from that named slot instead of fp[1]. The 112-byte offset comes from Linux's s390 debugging documentation: its Stack Frame Layout table shows z/Architecture backchain frames with the backchain at offset 0 and saved r14 of the caller function at offset 112: https://www.kernel.org/doc/html/v5.3/s390/debugging390.html#stack-frame-layout This helper remains scoped to Linux s390x backchain frames. GNU SFrame's s390x notes state that the s390x ELF ABI does not generally mandate where RA and FP are saved, or whether they are saved at all: https://sourceware.org/binutils/docs/sframe-spec.html#s390x As Jens Remus noted, -fno-omit-frame-pointer is not needed when -mbackchain is present. On 32-bit ARM, GCC defaults to Thumb mode on common armhf toolchains. The Thumb prologue keeps the saved frame pointer and link register at offsets that depend on the generated frame, which breaks the fp[0]/fp[1] walk used by the helper. Use -marm when it is supported for frame-pointer builds, and teach the helper the GCC ARM-mode slots where the previous frame pointer is at fp[-1] and the saved LR return address is at fp[0]. Co-authored-by: Petr Viktorin <encukou@gmail.com> Co-authored-by: Victor Stinner <vstinner@python.org>
2026-05-06 16:03:37 +01:00
_Py_WITH_FRAME_POINTERS = getattr(
_testinternalcapi,
"_Py_WITH_FRAME_POINTERS",
-1,
)
if _Py_WITH_FRAME_POINTERS > 0:
return True
if _Py_WITH_FRAME_POINTERS == 0:
return False
cflags = " ".join(
value for value in (
sysconfig.get_config_var("PY_CORE_CFLAGS"),
sysconfig.get_config_var("CFLAGS"),
)
if value
)
if "no-omit-frame-pointer" in cflags:
# For example, configure adds -fno-omit-frame-pointer by default on
# supported GCC-compatible builds.
return True
if "omit-frame-pointer" in cflags:
return False
if sys.platform == "darwin":
# macOS x86_64/ARM64 always have frame pointer by default.
return True
if sys.platform == "linux":
if machine in {"aarch64", "arm64"}:
# 32-bit Linux is not supported
if sys.maxsize < 2**32:
return None
return True
if machine == "ppc64le":
# The power ABI specification requires that compilers maintain a
# back chain by default, so unwinding already works without a
# dedicated frame pointer.
# https://openpowerfoundation.org/specifications/64bitelfabi/
return True
if machine == "x86_64":
final_opt = ""
for opt in cflags.split():
if opt.startswith('-O'):
final_opt = opt
if final_opt in ("-O0", "-Og", "-O1"):
# Unwinding works if the optimization level is low
return True
Py_ENABLE_SHARED = int(sysconfig.get_config_var('Py_ENABLE_SHARED') or '0')
if Py_ENABLE_SHARED:
# Unwinding does crash using gcc -O2 or gcc -O3
# when Python is built with --enable-shared
return "crash"
return False
if sys.platform == "win32":
# MSVC ignores /Oy and /Oy- on x64/ARM64.
if machine == "arm64":
# Windows ARM64 guidelines recommend frame pointers (x29) for stack walking.
return True
elif machine == "x86_64":
# Windows x64 uses unwind metadata; frame pointers are not required.
return None
return None
def _is_arm32_build():
if sys.maxsize >= 2**32:
return False
abi = " ".join(
value for value in (
sysconfig.get_config_var("MULTIARCH"),
sysconfig.get_config_var("HOST_GNU_TYPE"),
sysconfig.get_config_var("SOABI"),
)
if value
).lower()
return "arm" in abi
def _build_stack_and_unwind(unwinder):
import operator
def build_stack(n, unwinder, warming_up_caller=False):
if warming_up_caller:
return
if n == 0:
return unwinder()
warming_up = True
while warming_up:
# Can't branch on JIT state inside JITted code, so compute here.
warming_up = (
hasattr(sys, "_jit")
and sys._jit.is_enabled()
and not sys._jit.is_active()
)
result = operator.call(build_stack, n - 1, unwinder, warming_up)
return result
stack = build_stack(STACK_DEPTH, unwinder)
return stack
def _classify_stack(stack, jit_enabled):
labels = _testinternalcapi.classify_stack_addresses(stack, jit_enabled)
annotated = []
jit_frames = 0
python_frames = 0
other_frames = 0
for idx, (frame, tag) in enumerate(zip(stack, labels)):
addr = int(frame)
if tag == "jit":
jit_frames += 1
elif tag == "python":
python_frames += 1
else:
other_frames += 1
annotated.append((idx, addr, tag))
return annotated, python_frames, jit_frames, other_frames
def _summarize_unwind(stack, unwinder_name):
jit_enabled = hasattr(sys, "_jit") and sys._jit.is_enabled()
jit_backend = _testinternalcapi.get_jit_backend()
ranges = _testinternalcapi.get_jit_code_ranges() if jit_enabled else []
if jit_enabled and ranges:
print("JIT ranges:")
for start, end in ranges:
print(f" {int(start):#x}-{int(end):#x}")
annotated, python_frames, jit_frames, other_frames = _classify_stack(
stack, jit_enabled
)
for idx, addr, tag in annotated:
print(f"#{idx:02d} {addr:#x} -> {tag}")
return {
"length": len(stack),
"python_frames": python_frames,
"jit_frames": jit_frames,
"other_frames": other_frames,
"jit_backend": jit_backend,
"unwinder": unwinder_name,
}
def _annotate_unwind(unwinder_name="manual_frame_pointer_unwind"):
unwinder = getattr(_testinternalcapi, unwinder_name)
stack = _build_stack_and_unwind(unwinder)
return json.dumps(_summarize_unwind(stack, unwinder_name))
def _annotate_unwind_after_executor_free(unwinder_name="gnu_backtrace_unwind"):
# The first unwind runs at the bottom of _build_stack_and_unwind(), while
# the recursive helper may be executing in JIT code. After it returns, this
# helper is back in normal test code; clearing executor caches should remove
# the old JIT ranges, so the second unwind must not report stale JIT frames.
live = json.loads(_annotate_unwind(unwinder_name))
sys._clear_internal_caches()
_testinternalcapi.clear_executor_deletion_list()
unwinder = getattr(_testinternalcapi, unwinder_name)
after_free = _summarize_unwind(unwinder(), unwinder_name)
return json.dumps({
"live": live,
"after_free": after_free,
})
def _run_unwind_helper(helper_name, unwinder_name, **env):
code = (
f"from test.test_c_stack_unwind import {helper_name}; "
f"print({helper_name}({unwinder_name!r}));"
)
run_env = os.environ.copy()
run_env.update(env)
proc = subprocess.run(
[sys.executable, "-c", code],
env=run_env,
capture_output=True,
text=True,
)
# Surface the output for debugging/visibility when running this test
if proc.stdout:
print(proc.stdout, end="")
if proc.returncode:
raise RuntimeError(
f"unwind helper failed (rc={proc.returncode}): {proc.stderr or proc.stdout}"
)
stdout_lines = proc.stdout.strip().splitlines()
if not stdout_lines:
raise RuntimeError("unwind helper produced no output")
try:
return json.loads(stdout_lines[-1])
except ValueError as exc:
raise RuntimeError(
f"unexpected output from unwind helper: {proc.stdout!r}"
) from exc
def _unwind_result(unwinder_name, **env):
return _run_unwind_helper("_annotate_unwind", unwinder_name, **env)
def _unwind_after_executor_free_result(unwinder_name, **env):
return _run_unwind_helper(
"_annotate_unwind_after_executor_free", unwinder_name, **env)
@support.requires_gil_enabled("test requires the GIL enabled")
@unittest.skipIf(support.is_wasi, "test not supported on WASI")
class ManualStackUnwindTests(unittest.TestCase):
def setUp(self):
super().setUp()
machine = platform.machine().lower()
expected = _manual_unwind_expected(machine)
if expected is None:
self.skipTest(
f"unsupported architecture for manual stack unwind check: {machine}"
)
if expected == "crash":
self.skipTest(f"test does crash on {machine}")
try:
_testinternalcapi.manual_frame_pointer_unwind()
except RuntimeError as exc:
if "not supported" in str(exc):
self.skipTest(
"manual stack unwinding not supported on this platform"
)
raise
self.machine = machine
self.manual_unwind_expected = expected
def test_manual_unwind_finds_expected_frames(self):
jit_available = hasattr(sys, "_jit") and sys._jit.is_available()
envs = [({"PYTHON_JIT": "0"}, False)]
if jit_available:
envs.append(({"PYTHON_JIT": "1"}, True))
for env, using_jit in envs:
with self.subTest(env=env):
result = _unwind_result("manual_frame_pointer_unwind", **env)
jit_frames = result["jit_frames"]
python_frames = result.get("python_frames", 0)
jit_backend = result.get("jit_backend")
if self.manual_unwind_expected:
self.assertGreaterEqual(
python_frames,
STACK_DEPTH,
f"expected to find Python frames on {self.machine} with env {env}",
)
if using_jit:
if jit_backend == "jit":
self.assertGreater(
jit_frames,
0,
f"expected to find JIT frames on {self.machine} with env {env}",
)
else:
# jit_backend is "interpreter" or not present
self.assertEqual(
jit_frames,
0,
f"unexpected JIT frames counted on {self.machine} with env {env}",
)
else:
self.assertEqual(
jit_frames,
0,
f"unexpected JIT frames counted on {self.machine} with env {env}",
)
else:
self.assertLessEqual(
python_frames,
1,
f"unexpected Python frames counted on {self.machine} with env {env}",
)
self.assertEqual(
jit_frames,
0,
f"unexpected JIT frames counted on {self.machine} with env {env}",
)
@support.requires_gil_enabled("test requires the GIL enabled")
@unittest.skipIf(support.is_wasi, "test not supported on WASI")
@unittest.skipUnless(sys.platform == "linux", "GNU backtrace unwinding test requires Linux")
@unittest.skipIf(
_is_arm32_build(),
"GNU backtrace unwinding skipped on Arm 32-bit",
)
class GnuBacktraceUnwindTests(unittest.TestCase):
def setUp(self):
super().setUp()
try:
_testinternalcapi.gnu_backtrace_unwind()
except RuntimeError as exc:
if "not supported" in str(exc):
self.skipTest("gnu backtrace unwinding not supported on this platform")
raise
def test_gnu_backtrace_unwinds_through_jit_frames(self):
jit_available = hasattr(sys, "_jit") and sys._jit.is_available()
envs = [({"PYTHON_JIT": "0"}, False)]
if jit_available:
envs.append(({"PYTHON_JIT": "1"}, True))
for env, using_jit in envs:
with self.subTest(env=env):
result = _unwind_result("gnu_backtrace_unwind", **env)
python_frames = result.get("python_frames", 0)
jit_frames = result.get("jit_frames", 0)
jit_backend = result.get("jit_backend")
self.assertGreaterEqual(
python_frames,
STACK_DEPTH,
f"expected to find Python frames in GNU backtrace with env {env}",
)
if using_jit and jit_backend == "jit":
self.assertGreater(
jit_frames,
0,
f"expected GNU backtrace to include JIT frames with env {env}",
)
else:
self.assertEqual(
jit_frames,
0,
f"unexpected JIT frames counted in GNU backtrace with env {env}",
)
def test_gnu_backtrace_jit_frames_disappear_after_executor_free(self):
if not (hasattr(sys, "_jit") and sys._jit.is_available()):
self.skipTest("JIT is not available")
result = _unwind_after_executor_free_result(
"gnu_backtrace_unwind", PYTHON_JIT="1")
live = result["live"]
if live.get("jit_backend") != "jit":
self.skipTest("JIT backend is not active")
self.assertGreaterEqual(
live.get("python_frames", 0),
STACK_DEPTH,
"expected live GNU backtrace to include recursive Python frames",
)
self.assertGreater(
live.get("jit_frames", 0),
0,
"expected live GNU backtrace to include JIT frames",
)
after_free = result["after_free"]
self.assertGreater(
after_free.get("python_frames", 0),
0,
"expected GNU backtrace after executor free to include Python frames",
)
self.assertEqual(
after_free.get("jit_frames", 0),
0,
"unexpected JIT frames in GNU backtrace after executor free",
)
if __name__ == "__main__":
unittest.main()