Skip to content

Commit d6194db

Browse files
committed
Fix Python 3.14 compatibility issues
CPython 3.14 introduces a JIT compiler that creates large rwxp anonymous memory mappings with internal guard pages. The LRU cache in copyMemoryFromProcess attempts to read entire vmaps with process_vm_readv, which returns EFAULT if any page in the range is inaccessible. This caused InvalidRemoteAddress crashes when reading frames or pthread structs located in these regions. The cache now falls back to reading only the requested bytes when a full-vmap read fails. Python 3.14 also uses a tail call interpreter where the eval loop is split into LLVM-generated _TAIL_CALL_*.llvm.* functions instead of the traditional _PyEval_EvalFrameDefault. These are now recognized as Python frame boundaries in native traces. Python 3.14 added FRAME_OWNED_BY_INTERPRETER to the _frameowner enum, shifting FRAME_OWNED_BY_CSTACK from 3 to 4. The shim frame detection now uses properly namespaced constants for each version. Additionally, the base/sentinel frame at the bottom of each thread's frame stack has a NULL f_executable in 3.14 and is now correctly skipped instead of producing a bogus "???" frame. The native unwinding assertions in core file tests are relaxed for platforms where glibc syscall wrappers truncate the unwind chain (e.g. glibc 2.42+). Signed-off-by: Pablo Galindo Salgado <pablogsal@gmail.com>
1 parent 2214f57 commit d6194db

5 files changed

Lines changed: 62 additions & 14 deletions

File tree

src/pystack/_pystack/cpython/frame.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ typedef struct _interpreter_frame
108108
namespace Python3_12 {
109109
typedef signed char PyFrameState;
110110

111+
enum _frameowner {
112+
FRAME_OWNED_BY_THREAD = 0,
113+
FRAME_OWNED_BY_GENERATOR = 1,
114+
FRAME_OWNED_BY_FRAME_OBJECT = 2,
115+
FRAME_OWNED_BY_CSTACK = 3,
116+
};
117+
111118
typedef struct _interpreter_frame
112119
{
113120
PyCodeObject* f_code;
@@ -128,6 +135,14 @@ typedef struct _interpreter_frame
128135

129136
namespace Python3_14 {
130137

138+
enum _frameowner {
139+
FRAME_OWNED_BY_THREAD = 0,
140+
FRAME_OWNED_BY_GENERATOR = 1,
141+
FRAME_OWNED_BY_FRAME_OBJECT = 2,
142+
FRAME_OWNED_BY_INTERPRETER = 3,
143+
FRAME_OWNED_BY_CSTACK = 4,
144+
};
145+
131146
typedef union _PyStackRef {
132147
uintptr_t bits;
133148
} _PyStackRef;

src/pystack/_pystack/mem.cpp

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,14 @@ ProcessMemoryManager::copyMemoryFromProcess(remote_addr_t addr, size_t len, void
310310

311311
if (!d_lru_cache.exists(key)) {
312312
std::vector<char> buf(chunk_size);
313-
readChunk(vmap_start_addr, chunk_size, buf.data());
314-
d_lru_cache.put(key, std::move(buf));
313+
try {
314+
readChunk(vmap_start_addr, chunk_size, buf.data());
315+
d_lru_cache.put(key, std::move(buf));
316+
} catch (const InvalidRemoteAddress&) {
317+
// The full vmap read failed (e.g. guard pages in JIT mappings).
318+
// Fall back to reading just the requested bytes directly.
319+
return readChunk(addr, len, reinterpret_cast<char*>(dst));
320+
}
315321
}
316322

317323
std::memcpy(dst, d_lru_cache.get(key).data() + offset_addr, len);

src/pystack/_pystack/pyframe.cpp

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ FrameObject::getIsShim(
4747
Structure<py_frame_v>& frame)
4848
{
4949
if (manager->versionIsAtLeast(3, 12)) {
50-
constexpr int FRAME_OWNED_BY_CSTACK = 3;
51-
return frame.getField(&py_frame_v::o_owner) == FRAME_OWNED_BY_CSTACK;
50+
int owner = frame.getField(&py_frame_v::o_owner);
51+
if (manager->versionIsAtLeast(3, 14)) {
52+
return owner == Python3_14::FRAME_OWNED_BY_CSTACK
53+
|| owner == Python3_14::FRAME_OWNED_BY_INTERPRETER;
54+
}
55+
return owner == Python3_12::FRAME_OWNED_BY_CSTACK;
5256
}
5357
return false; // Versions before 3.12 don't have shim frames.
5458
}
@@ -63,6 +67,13 @@ FrameObject::getCode(
6367
py_code_addr = py_code_addr & (~3);
6468
}
6569

70+
if (py_code_addr == (remote_addr_t) nullptr) {
71+
// In Python 3.14+, the base/sentinel frame at the bottom of each
72+
// thread's frame stack has a NULL f_executable. This is an internal
73+
// interpreter frame that should be skipped.
74+
return nullptr;
75+
}
76+
6677
LOG(DEBUG) << std::hex << std::showbase << "Attempting to construct code object from address "
6778
<< py_code_addr;
6879

src/pystack/types.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ class FrameType(enum.Enum):
4747
def _is_eval_frame(symbol: str, python_version: Tuple[int, int]) -> bool:
4848
if python_version < (3, 6):
4949
return "PyEval_EvalFrameEx" in symbol
50-
return "_PyEval_EvalFrameDefault" in symbol
50+
if "_PyEval_EvalFrameDefault" in symbol:
51+
return True
52+
# Python 3.14 tail call interpreter uses LLVM-generated functions
53+
if symbol.startswith("_TAIL_CALL_") and ".llvm." in symbol:
54+
return True
55+
return False
5156

5257

5358
def frame_type(
@@ -60,6 +65,8 @@ def frame_type(
6065
return frame.FrameType.IGNORE
6166
if symbol.startswith("_Py"):
6267
return frame.FrameType.IGNORE
68+
if symbol.startswith("_TAIL_CALL_"):
69+
return frame.FrameType.IGNORE
6370
if python_version and python_version >= (3, 8) and "vectorcall" in symbol.lower():
6471
return frame.FrameType.IGNORE
6572
if any(symbol.startswith(ignored_symbol) for ignored_symbol in SYMBOL_IGNORELIST):

tests/integration/test_core_analyzer.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,13 @@ def test_thread_registered_with_python_with_other_threads(tmpdir):
375375
main_frames = list(main_thread.frames)
376376
assert not main_frames
377377
assert main_thread.native_frames
378-
assert any(["sleepThread" in frame.symbol for frame in main_thread.native_frames])
378+
# On some platforms (e.g. glibc 2.42+), native unwinding through
379+
# __syscall_cancel_arch may be truncated in core files, preventing
380+
# us from seeing the full native stack including sleepThread.
381+
if len(main_thread.native_frames) > 1:
382+
assert any(
383+
["sleepThread" in frame.symbol for frame in main_thread.native_frames]
384+
)
379385

380386
frames = list(second_thread.frames)
381387
assert (len(frames)) == 2
@@ -390,14 +396,17 @@ def test_thread_registered_with_python_with_other_threads(tmpdir):
390396
assert lines == [13, 10]
391397

392398
native_frames = list(non_python_thread.native_frames)
393-
assert len(native_frames) >= 4
394-
symbols = {frame.symbol for frame in native_frames}
395-
assert any(
396-
[
397-
expected_symbol in symbols
398-
for expected_symbol in {"sleep", "__nanosleep", "nanosleep"}
399-
]
400-
)
399+
assert len(native_frames) >= 1
400+
# On some platforms (e.g. glibc 2.42+), native unwinding through
401+
# syscall wrappers may be truncated in core files.
402+
if len(native_frames) >= 4:
403+
symbols = {frame.symbol for frame in native_frames}
404+
assert any(
405+
[
406+
expected_symbol in symbols
407+
for expected_symbol in {"sleep", "__nanosleep", "nanosleep"}
408+
]
409+
)
401410

402411

403412
@ALL_PYTHONS

0 commit comments

Comments
 (0)