Skip to content

Commit 1b3741d

Browse files
fix(profiling): never enable fast copy memory if interpreter is embedded
Fast memory copy (safe_memcpy) relies on installing SIGSEGV/SIGBUS signal handlers to recover from faults. When Python is embedded in another process (e.g. the Datadog Agent), those handlers can displace signal handlers owned by the host process and cause crashes. This change detects whether the interpreter is embedded (by checking whether the process executable looks like a Python binary) and, if so, skips installing the signal handlers and disables fast copy on both the native (vm.cc) and Python config (profiling.py) sides.
1 parent 65e4cbc commit 1b3741d

7 files changed

Lines changed: 156 additions & 5 deletions

File tree

ddtrace/internal/datadog/profiling/stack/src/echion/vm.cc

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
#include <cstdio>
22
#include <cstring>
33

4+
#include <climits>
5+
6+
#if defined PL_DARWIN
7+
#include <mach-o/dyld.h>
8+
#endif
9+
410
#include <echion/vm.h>
511

612
// Returns true when _DD_PROFILING_STACK_FAST_COPY is set to a falsy value.
@@ -15,6 +21,50 @@ fast_copy_env_disabled()
1521
return strcmp(val, "0") == 0 || strcmp(val, "false") == 0 || strcmp(val, "False") == 0;
1622
}
1723

24+
// Checks whether Python is running as an embedded interpreter by checking
25+
// whether the process executable looks like a Python binary.
26+
//
27+
// When the executable path cannot be read (for example no procfs),
28+
// we report "embedded". This constructor is the only gate that runs
29+
// before the SIGSEGV/SIGBUS handlers are installed, so an indeterminate
30+
// process must not keep handlers that may belong to a host.
31+
static bool
32+
is_python_embedded()
33+
{
34+
char exe_path[PATH_MAX] = { 0 };
35+
36+
#if defined PL_LINUX
37+
ssize_t len = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1);
38+
if (len <= 0) {
39+
return true;
40+
}
41+
exe_path[len] = '\0';
42+
#elif defined PL_DARWIN
43+
uint32_t bufsize = sizeof(exe_path);
44+
if (_NSGetExecutablePath(exe_path, &bufsize) != 0) {
45+
return true;
46+
}
47+
#else
48+
return true;
49+
#endif
50+
51+
const char* base = strrchr(exe_path, '/');
52+
base = base ? base + 1 : exe_path;
53+
54+
// Case-insensitive substring search for "python" in the basename.
55+
size_t base_len = strlen(base);
56+
if (base_len >= 6) {
57+
for (size_t i = 0; i <= base_len - 6; ++i) {
58+
if ((base[i] | 0x20) == 'p' && (base[i + 1] | 0x20) == 'y' && (base[i + 2] | 0x20) == 't' &&
59+
(base[i + 3] | 0x20) == 'h' && (base[i + 4] | 0x20) == 'o' && (base[i + 5] | 0x20) == 'n') {
60+
return false;
61+
}
62+
}
63+
}
64+
65+
return true;
66+
}
67+
1868
#if defined PL_LINUX
1969
static bool
2070
probe_process_vm_readv()
@@ -38,9 +88,11 @@ init_safe_copy()
3888
// Always probe process_vm_readv so we know whether it is a valid fallback.
3989
process_vm_readv_available = probe_process_vm_readv();
4090

41-
// Honor the fast-copy opt-out: when disabled via env var, skip installing
42-
// the SIGSEGV/SIGBUS handlers and alt stack entirely.
43-
if (fast_copy_env_disabled()) {
91+
// Honor the fast-copy opt-out: when disabled via env var or when Python is
92+
// embedded, skip installing the SIGSEGV/SIGBUS handlers entirely.
93+
// Embedded interpreters must never use safe_memcpy because the host process
94+
// owns the signal handlers.
95+
if (fast_copy_env_disabled() || is_python_embedded()) {
4496
fast_copy_user_disabled = true;
4597
if (process_vm_readv_available) {
4698
safe_copy = process_vm_readv;
@@ -72,8 +124,9 @@ init_safe_copy()
72124
__attribute__((constructor)) void
73125
init_safe_copy()
74126
{
75-
// Honor the fast-copy opt-out: skip installing signal handlers when disabled.
76-
if (fast_copy_env_disabled()) {
127+
// Honor the fast-copy opt-out: skip installing signal handlers when
128+
// disabled or when Python is embedded (host owns signal handlers).
129+
if (fast_copy_env_disabled() || is_python_embedded()) {
77130
fast_copy_user_disabled = true;
78131
return;
79132
}

ddtrace/internal/datadog/profiling/stack/test/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,8 @@ configure_stack_internal_test(test_sample_lifecycle)
100100

101101
dd_wrapper_add_test(test_sampling_cycle_state test_sampling_cycle_state.cpp)
102102
configure_stack_internal_test(test_sampling_cycle_state)
103+
104+
# vm.cc is compiled into the test binary: the fast-copy state lives in inline variables, so the copy in the test
105+
# executable is distinct from the one in the _stack library and only the constructor linked here initializes it.
106+
dd_wrapper_add_test(test_embedded_fast_copy test_embedded_fast_copy.cpp ../src/echion/vm.cc)
107+
configure_stack_internal_test(test_embedded_fast_copy)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#include <echion/vm.h>
2+
#include <gtest/gtest.h>
3+
4+
// Test that fast memory copy is disabled when Python runs as an embedded
5+
// interpreter. This binary is not named "python*", so is_python_embedded()
6+
// returns true and init_safe_copy() never installs the SIGSEGV/SIGBUS
7+
// handlers. vm.cc is compiled into this binary, so the constructor has
8+
// already fired on the state that the assertions below read.
9+
TEST(EmbeddedFastCopy, FastCopyDisabledWhenEmbedded)
10+
{
11+
EXPECT_TRUE(fast_copy_user_disabled)
12+
<< "fast_copy_user_disabled must be true when the process exe is not a python binary";
13+
EXPECT_FALSE(fast_copy_active) << "fast_copy_active must be false in an embedded interpreter";
14+
}

ddtrace/internal/settings/profiling.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import itertools
44
import math
5+
import os
6+
import sys
57
import typing as t
68

79
from envier import Env
@@ -25,6 +27,19 @@
2527
logger = get_logger(__name__)
2628

2729

30+
def _is_python_embedded() -> bool:
31+
try:
32+
real_exe = os.readlink("/proc/self/exe") if sys.platform == "linux" else ""
33+
except OSError:
34+
real_exe = ""
35+
36+
exe = real_exe or (getattr(sys, "executable", "") or "")
37+
if not exe:
38+
return True
39+
40+
return "python" not in os.path.basename(exe).lower()
41+
42+
2843
def _derive_default_heap_sample_size(
2944
heap_config: Env,
3045
default_heap_sample_size: int = 1024 * 1024,
@@ -621,6 +636,13 @@ class ProfilingConfigException(DDConfig):
621636
)
622637
config.stack.enabled = False # pyright: ignore[reportAttributeAccessIssue]
623638

639+
# Fast memory copy is unsafe in embedded interpreters: the host process may
640+
# install its own signal handlers that conflict with the SIGSEGV/SIGBUS
641+
# recovery mechanism used by safe_memcpy.
642+
if config.stack.fast_copy and _is_python_embedded():
643+
logger.debug("Python is running as an embedded interpreter; disabling fast memory copy for stack profiling")
644+
config.stack.fast_copy = False # pyright: ignore[reportAttributeAccessIssue]
645+
624646
# Enrich tags with git metadata and DD_TAGS
625647
config.tags = _enrich_tags(config.tags) # pyright: ignore[reportAttributeAccessIssue]
626648

ddtrace/internal/settings/profiling.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,4 @@ stack_v2_failure_msg: Optional[str]
6969
stack_v2_is_available: bool
7070

7171
def config_str(config: ProfilingConfig) -> str: ...
72+
def _is_python_embedded() -> bool: ...
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fixes:
2+
- |
3+
Profiling: Fast memory copy is now automatically disabled when Python is running
4+
as an embedded interpreter.

tests/profiling/test_profiling_config.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,58 @@ def test_always_excluded_modules_contains_required_entries() -> None:
119119
assert "concurrent" in _ALWAYS_EXCLUDED_MODULES
120120

121121

122+
class TestEmbeddedInterpreterFastCopy:
123+
def test_is_python_embedded_returns_false_for_python(self, monkeypatch: pytest.MonkeyPatch) -> None:
124+
from ddtrace.internal.settings.profiling import _is_python_embedded
125+
126+
monkeypatch.setattr("os.readlink", lambda _: "/usr/bin/python3.12")
127+
monkeypatch.setattr("sys.platform", "linux")
128+
assert _is_python_embedded() is False
129+
130+
def test_is_python_embedded_returns_true_for_non_python(self, monkeypatch: pytest.MonkeyPatch) -> None:
131+
from ddtrace.internal.settings.profiling import _is_python_embedded
132+
133+
monkeypatch.setattr("os.readlink", lambda _: "/ucsr/bin/nginx")
134+
monkeypatch.setattr("sys.platform", "linux")
135+
assert _is_python_embedded() is True
136+
137+
def test_is_python_embedded_fallback_to_sys_executable(self, monkeypatch: pytest.MonkeyPatch) -> None:
138+
from ddtrace.internal.settings.profiling import _is_python_embedded
139+
140+
def _raise_oserror(_: str) -> str:
141+
raise OSError("no procfs")
142+
143+
monkeypatch.setattr("os.readlink", _raise_oserror)
144+
monkeypatch.setattr("sys.platform", "linux")
145+
monkeypatch.setattr("sys.executable", "/opt/myapp/bin/myapp")
146+
assert _is_python_embedded() is True
147+
148+
def test_is_python_embedded_fallback_empty_executable(self, monkeypatch: pytest.MonkeyPatch) -> None:
149+
from ddtrace.internal.settings.profiling import _is_python_embedded
150+
151+
def _raise_oserror(_: str) -> str:
152+
raise OSError("no procfs")
153+
154+
monkeypatch.setattr("os.readlink", _raise_oserror)
155+
monkeypatch.setattr("sys.platform", "linux")
156+
monkeypatch.setattr("sys.executable", "")
157+
assert _is_python_embedded() is True
158+
159+
def test_non_linux_falls_back_to_sys_executable(self, monkeypatch: pytest.MonkeyPatch) -> None:
160+
from ddtrace.internal.settings.profiling import _is_python_embedded
161+
162+
monkeypatch.setattr("sys.platform", "darwin")
163+
monkeypatch.setattr("sys.executable", "/usr/local/bin/python3")
164+
assert _is_python_embedded() is False
165+
166+
def test_non_linux_embedded(self, monkeypatch: pytest.MonkeyPatch) -> None:
167+
from ddtrace.internal.settings.profiling import _is_python_embedded
168+
169+
monkeypatch.setattr("sys.platform", "darwin")
170+
monkeypatch.setattr("sys.executable", "/Applications/MyGame.app/Contents/MacOS/mygame")
171+
assert _is_python_embedded() is True
172+
173+
122174
class TestDumpSettings:
123175
"""Tests for the profiler-settings serializer used in per-profile metadata."""
124176

0 commit comments

Comments
 (0)