Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ endif()
message(WARNING "SOURCE_LIB_DIR: ${SOURCE_LIB_DIR}")
message(WARNING "LIBRARY_NAME: ${LIBRARY_NAME}")

# We expect the native extension to be built and installed the headers in the following directory. It is configured in
# setup.py by setting CARGO_TARGET_DIR environment variable.
set(SOURCE_INCLUDE_DIR
${CMAKE_SOURCE_DIR}/../../../../../src/native/target${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/include)
# Resolves NATIVE_HEADERS_DIR (libdatadog's generated C headers for the active Python minor). See NativeHeaders.cmake
# for the resolution policy.
include(NativeHeaders)
set(SOURCE_INCLUDE_DIR "${NATIVE_HEADERS_DIR}")

set(DEST_LIB_DIR ${CMAKE_CURRENT_BINARY_DIR})
set(DEST_INCLUDE_DIR ${DEST_LIB_DIR}/include)
Expand Down
28 changes: 28 additions & 0 deletions ddtrace/internal/datadog/profiling/cmake/NativeHeaders.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Resolves NATIVE_HEADERS_DIR — the absolute path to libdatadog's generated C headers (produced by the Rust crate under
# src/native/ and written to target<MAJOR>.<MINOR>/include).
#
# Primary source: setup.py passes -DRUST_GENERATED_HEADERS_DIR=<abs path> to every CMake invocation via
# _get_common_cmake_args. Whenever that variable is set, we trust it.
#
# Fallback: build_standalone.sh does NOT pass RUST_GENERATED_HEADERS_DIR, so we compute a path relative to this module's
# own location. Callers must have already invoked find_package(Python3) so that Python3_VERSION_MAJOR/_MINOR are
# defined; the fallback uses those to pick the right per-minor target directory (matching setup.py's CARGO_TARGET_DIR
# layout).
#
# Consumers must have "${CMAKE_CURRENT_SOURCE_DIR}/../cmake" on CMAKE_MODULE_PATH before calling include(NativeHeaders).

if(DEFINED RUST_GENERATED_HEADERS_DIR)
set(NATIVE_HEADERS_DIR "${RUST_GENERATED_HEADERS_DIR}")
else()
if(NOT DEFINED Python3_VERSION_MAJOR OR NOT DEFINED Python3_VERSION_MINOR)
message(
FATAL_ERROR
"NativeHeaders: RUST_GENERATED_HEADERS_DIR is not set and Python3_VERSION_MAJOR/_MINOR are undefined. "
"Call find_package(Python3) before include(NativeHeaders), or pass -DRUST_GENERATED_HEADERS_DIR "
"(as setup.py does).")
endif()
get_filename_component(
NATIVE_HEADERS_DIR
"${CMAKE_CURRENT_LIST_DIR}/../../../../../src/native/target${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/include"
ABSOLUTE)
endif()
10 changes: 7 additions & 3 deletions ddtrace/internal/datadog/profiling/dd_wrapper/src/sample.cpp
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
#include "sample.hpp"

// TODO(py-315): Python.h must be included first, before any system or project headers.
// CPython's pyconfig.h defines _POSIX_C_SOURCE and _XOPEN_SOURCE to their current
// POSIX standard values (202405L on 3.15+). If system headers (included transitively
// via libdatadog_helpers.hpp → features.h) are pulled in first, they define older
// values (200809L), and pyconfig.h's later redefinition triggers -Werror on GCC/Clang.
#define PY_SSIZE_T_CLEAN

#include <Python.h>
#include <frameobject.h>

#include "sample.hpp"

#include "libdatadog_helpers.hpp"
#include "profiler_state.hpp"
#include "pymacro.hpp"
Expand Down
13 changes: 7 additions & 6 deletions ddtrace/internal/datadog/profiling/ddup/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ add_library(${EXTENSION_NAME} SHARED ${DDUP_CPP_SRC})
add_ddup_config(${EXTENSION_NAME})

# Cython generates code that produces errors for the following, so relax compile options
target_compile_options(${EXTENSION_NAME} PRIVATE -Wno-old-style-cast -Wno-shadow -Wno-address)
# -Wno-missing-field-initializers: Python 3.15 added tp_iteritem to PyTypeObject; Cython doesn't initialize it yet
target_compile_options(${EXTENSION_NAME} PRIVATE -Wno-old-style-cast -Wno-shadow -Wno-address
-Wno-missing-field-initializers)

# cmake may mutate the name of the library (e.g., lib- and -.so for dynamic libraries). This suppresses that behavior,
# which is required to ensure all paths can be inferred correctly by setup.py.
Expand All @@ -87,11 +89,10 @@ elseif(UNIX)
endif()
endif()

target_include_directories(
${EXTENSION_NAME}
PRIVATE ../dd_wrapper/include
../../../../../src/native/target${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/include/
${Datadog_INCLUDE_DIRS} ${Python3_INCLUDE_DIRS})
include(NativeHeaders)

target_include_directories(${EXTENSION_NAME} PRIVATE ../dd_wrapper/include "${NATIVE_HEADERS_DIR}"
${Datadog_INCLUDE_DIRS} ${Python3_INCLUDE_DIRS})

target_link_libraries(${EXTENSION_NAME} PRIVATE dd_wrapper)

Expand Down
10 changes: 6 additions & 4 deletions ddtrace/internal/datadog/profiling/stack/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,16 @@ add_clangtidy_target(${EXTENSION_NAME})
# Never build with native unwinding, since this is not currently used
target_compile_definitions(${EXTENSION_NAME} PRIVATE UNWIND_NATIVE_DISABLE)

# Resolves NATIVE_HEADERS_DIR (libdatadog's generated C headers for the active Python minor). See
# cmake/NativeHeaders.cmake for the resolution policy.
include(NativeHeaders)

# Includes; echion and python are marked "system" to suppress warnings
target_include_directories(
${EXTENSION_NAME} PRIVATE .. # include dd_wrapper from the root in order to make its paths transparent in the code
include)
target_include_directories(
${EXTENSION_NAME} SYSTEM
PRIVATE ${Python3_INCLUDE_DIRS} echion include/vendored include/util
../../../../../src/native/target${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/include/)
target_include_directories(${EXTENSION_NAME} SYSTEM PRIVATE ${Python3_INCLUDE_DIRS} echion include/vendored
include/util "${NATIVE_HEADERS_DIR}")

# Echion sources need to be given the current platform
if(APPLE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,57 @@ extern "C"
#define RESUME_QUICK INSTRUMENTED_RESUME
#endif

#if PY_VERSION_HEX >= 0x030e0000
// Python 3.14+: Use stackpointer and _PyStackRef
#if PY_VERSION_HEX >= 0x030f0000
// Python 3.15+: FRAME_SUSPENDED_YIELD_FROM_LOCKED is a new frame state for
// generators that are locked during a yield-from in free-threaded builds.
// In GIL builds this state is unreachable, so we only check it under
// Py_GIL_DISABLED. All other logic is identical to 3.14 (stackpointer/_PyStackRef).

inline PyObject* PyGen_yf(PyGenObject* gen, PyObject* frame_addr)
{
if (gen->gi_frame_state != FRAME_SUSPENDED_YIELD_FROM
#ifdef Py_GIL_DISABLED
&& gen->gi_frame_state != FRAME_SUSPENDED_YIELD_FROM_LOCKED
#endif
) {
return nullptr;
}

_PyInterpreterFrame frame;
if (copy_type(frame_addr, frame)) {
return nullptr;
}

PyCodeObject code;
auto code_addr = reinterpret_cast<PyCodeObject*>(BITS_TO_PTR_MASKED(frame.f_executable));
if (copy_type(code_addr, code)) {
return nullptr;
}

uintptr_t frame_addr_uint = reinterpret_cast<uintptr_t>(frame_addr);
uintptr_t localsplus_addr = frame_addr_uint + offsetof(_PyInterpreterFrame, localsplus);
uintptr_t stackbase_addr = localsplus_addr + code.co_nlocalsplus * sizeof(_PyStackRef);

uintptr_t stackpointer_addr = reinterpret_cast<uintptr_t>(frame.stackpointer);
if (stackpointer_addr <= stackbase_addr) {
return nullptr;
}

int stacktop = static_cast<int>((stackpointer_addr - stackbase_addr) / sizeof(_PyStackRef));
if (stacktop < 1 || stacktop > MAX_STACK_SIZE) {
return nullptr;
}

_PyStackRef top_ref;
if (copy_type(reinterpret_cast<void*>(stackpointer_addr - sizeof(_PyStackRef)), top_ref)) {
return nullptr;
}

return BITS_TO_PTR_MASKED(top_ref);
}

#elif PY_VERSION_HEX >= 0x030e0000
// Python 3.14: Use stackpointer and _PyStackRef

inline PyObject* PyGen_yf(PyGenObject* gen, PyObject* frame_addr)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ function(add_fuzz_target TARGET_NAME)

# Include paths: ../.. is the profiling root (for "dd_wrapper/include/..." paths), ../include is for stack headers.
target_include_directories(${TARGET_NAME} PRIVATE ../.. ../include)
target_include_directories(
${TARGET_NAME} SYSTEM
PRIVATE ${Python3_INCLUDE_DIRS} ../echion ../include/vendored ../include/util
../../../../../../src/native/target${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/include/)
target_include_directories(${TARGET_NAME} SYSTEM PRIVATE ${Python3_INCLUDE_DIRS} ../echion ../include/vendored
../include/util "${NATIVE_HEADERS_DIR}")

# Ensure echion headers take the fuzz hook in vm.h
target_compile_definitions(${TARGET_NAME} PRIVATE ECHION_FUZZING)
Expand Down
39 changes: 24 additions & 15 deletions ddtrace/internal/datadog/profiling/stack/src/echion/frame.cc
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,31 @@ Frame::read(EchionSampler& echion, PyObject* frame_addr, PyObject** prev_addr)
frame_addr = &iframe;

#if PY_VERSION_HEX >= 0x030c0000
// _PyInterpreterFrame.owner is stored as char, not the _frameowner enum
// itself, so -Wswitch can't enforce exhaustiveness. test_cpython_layout
// _contracts static_asserts the enum values we rely on; an unknown owner
// here means CPython grew a new value and is treated as an error.
switch (frame_addr->owner) {
case FRAME_OWNED_BY_THREAD:
case FRAME_OWNED_BY_GENERATOR:
break; // valid live Python frame — proceed with frame reading
case FRAME_OWNED_BY_FRAME_OBJECT:
return ErrorKind::FrameError; // frame belongs to a PyFrameObject, not executing
#if PY_VERSION_HEX < 0x030f0000
case FRAME_OWNED_BY_CSTACK: // C shim frame (removed in 3.15)
#endif
#if PY_VERSION_HEX >= 0x030e0000
// Python 3.14 introduced FRAME_OWNED_BY_INTERPRETER, and frames of this
// type are also ignored by the upstream profiler.
// See
// https://github.com/python/cpython/blob/ebf955df7a89ed0c7968f79faec1de49f61ed7cb/Modules/_remote_debugging_module.c#L2134
if (frame_addr->owner == FRAME_OWNED_BY_CSTACK || frame_addr->owner == FRAME_OWNED_BY_INTERPRETER) {
#else
if (frame_addr->owner == FRAME_OWNED_BY_CSTACK) {
#endif // PY_VERSION_HEX >= 0x030e0000
*prev_addr = frame_addr->previous;
// This is a C frame, we just need to ignore it
return std::ref(C_FRAME);
}

if (frame_addr->owner != FRAME_OWNED_BY_THREAD && frame_addr->owner != FRAME_OWNED_BY_GENERATOR) {
return ErrorKind::FrameError;
case FRAME_OWNED_BY_INTERPRETER:
#endif
// C/interpreter-managed frame — skip it and follow the frame chain.
// FRAME_OWNED_BY_INTERPRETER introduced in 3.14; FRAME_OWNED_BY_CSTACK
// present in 3.12–3.14, removed in 3.15.
// See
// https://github.com/python/cpython/blob/ebf955df7a89ed0c7968f79faec1de49f61ed7cb/Modules/_remote_debugging_module.c#L2134
*prev_addr = frame_addr->previous;
return std::ref(C_FRAME);
default:
return ErrorKind::FrameError;
}
#endif // PY_VERSION_HEX >= 0x030c0000

Expand Down
33 changes: 29 additions & 4 deletions ddtrace/internal/datadog/profiling/stack/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ if(DO_VALGRIND)
endif()

function(dd_wrapper_add_test name)
# Optional keyword argument: INSTALL_SUBDIR <dir> Installs the test binary to test/<dir>/ instead of test/. Use for
# version-specific binaries (e.g. INSTALL_SUBDIR py315) to prevent CI artifact collisions when build_base_venvs runs
# in parallel across Python versions and GitLab merges all artifacts into a shared directory.
cmake_parse_arguments(_ARG "" "INSTALL_SUBDIR" "" ${ARGN})
set(_SOURCES ${_ARG_UNPARSED_ARGUMENTS})
add_executable(${name} ${_SOURCES})
target_include_directories(${name} PRIVATE ../include)
# Replicate the include dirs that ${EXTENSION_NAME} sets PRIVATE (so they don't propagate to test targets): stack's
# own headers, the profiling root (for "dd_wrapper/include/sample.hpp"), Python, echion, and the libdatadog
# Rust-generated headers (NATIVE_HEADERS_DIR, set by the parent CMakeLists).
target_include_directories(${name} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../include"
"${CMAKE_CURRENT_SOURCE_DIR}/../..")
target_include_directories(${name} SYSTEM PRIVATE ${Python3_INCLUDE_DIRS} "${CMAKE_CURRENT_SOURCE_DIR}/../echion"
"${NATIVE_HEADERS_DIR}")
# this has to refer to the stack extension name to properly link against
target_link_libraries(${name} PRIVATE gmock gtest_main ${EXTENSION_NAME})

Expand Down Expand Up @@ -71,6 +74,14 @@ function(dd_wrapper_add_test name)
gtest_discover_tests(${name} DISCOVERY_MODE PRE_TEST) # Delay test discovery until test execution to avoid running
# sanitizer-built executables during build

# Echion headers (vm.h, tasks.h) require PL_DARWIN or PL_LINUX to define proc_ref_t and copy_type. These are set
# PRIVATE on ${EXTENSION_NAME} and don't propagate to test targets, so we replicate the logic here.
if(APPLE)
target_compile_definitions(${name} PRIVATE PL_DARWIN)
else()
target_compile_definitions(${name} PRIVATE PL_LINUX)
endif()

# This is supplemental artifact so make sure to install it in the right place
if(INPLACE_LIB_INSTALL_DIR)
set(LIB_INSTALL_DIR "${INPLACE_LIB_INSTALL_DIR}")
Expand All @@ -92,3 +103,17 @@ dd_wrapper_add_test(test_origin_task_links test_origin_task_links.cpp)
dd_wrapper_add_test(test_alt_stack_ownership test_alt_stack_ownership.cpp)
# ThreadAltStack lives in the vendored echion header tree.
target_include_directories(test_alt_stack_ownership PRIVATE ../echion)
# test_frame_state_315 validates the PyFrameState renumbering and related frame-internals changes introduced in Python
# 3.15. All test bodies are gated on PY_VERSION_HEX >= 0x030f0000, so building on older versions would produce an empty
# test binary.
if(Python3_VERSION VERSION_GREATER_EQUAL "3.15")
# INSTALL_SUBDIR py315 keeps this binary out of the shared test/ directory. build_base_venvs runs in parallel for
# all Python versions; GitLab merges their artifacts into one directory for downstream jobs. Without isolation the
# py3.15-compiled binary (RPATH -> libpython3.15) would crash when the pytest gtest plugin tried to run it in a
# py3.10 environment.
dd_wrapper_add_test(test_frame_state_315 test_frame_state_315.cpp INSTALL_SUBDIR py315)
# Route copy_memory through echion_fuzz_copy_memory so tests can assert whether the state guard allows execution to
# reach the copy site.
target_compile_definitions(test_frame_state_315 PRIVATE ECHION_FUZZING)
endif()
dd_wrapper_add_test(test_cpython_layout_contracts test_cpython_layout_contracts.cpp)
Loading
Loading