Skip to content

Commit e083509

Browse files
fix(profiling): avoid crashing stack sampler with active foreign SIGSEGV handlers (PROF-15342) (#18798)
| [Next PR](#18947) User-reported crash: https://datadoghq.atlassian.net/browse/AIPTS-1715 ## Description `safe_memcpy`'s fault recovery only works while the profiler owns the `SIGSEGV`/`SIGBUS` handlers. Libraries like PyTorch/CUDA (and abseil via vLLM/gRPC) install their own handler during startup; once a foreign handler owns those signals, a fault on a stale read is no longer recovered and the process crashes ([PROF-14568](https://datadoghq.atlassian.net/browse/PROF-14568)). This PR makes the default-on state safe and removes the need for the `_DD_PROFILING_STACK_FAST_COPY=0` workaround. It handles the handlers we *cannot* wrap (torch/CUDA/abseil) via detect-and-fallback. ## Changes * Sampler **starts on the safe syscall copy** (`process_vm_readv` / `mach_vm_read_overwrite`) for a short warmup, so a fault during crash-prone startup can't crash the process (these syscalls return an error instead of faulting). * After warmup it **upgrades to `safe_memcpy` only if we still own both `SIGSEGV` and `SIGBUS`** (`segv_handler_installed()`). * It **re-checks ownership every cycle** and permanently falls back to the syscall copy if a handler is taken over later (e.g. lazy CUDA init). If no safe fallback exists (`process_vm_readv` blocked), it **stops sampling** — we degrade to dropped samples, never a crash. * Warmup is a fixed 15s internal constant (not a user knob). Policy is auto-fallback, not reinstall-and-chain, so the foreign handler stays authoritative; `init_segv_catcher` stays `call_once` to avoid reintroducing handler-chaining races. ## Test plan * New unit tests * Manual repro (synthetic + `--torch`) on #18911): crashes on `main`, runs OK on this branch * Tested on another internal service in staging: `ai_gateway` ([profile link](https://ddstaging.datadoghq.com/profiling/explorer?query=service%3Aai_gateway%20env%3Astaging%20version%3Afaulthandling-1ac266be&my_code=disabled&profile_type=heap-live-size&refresh_mode=paused&viz=flame_graph&from_ts=1784830809012&to_ts=1784834409012&live=false)) * Py CPU <img width="1004" height="1024" alt="image" src="https://github.com/user-attachments/assets/5b4311bf-ee2b-4dd0-bb1f-b23f05ca567d" /> * eBPF CPU <img width="999" height="1024" alt="image" src="https://github.com/user-attachments/assets/ada757ba-8e35-48c0-ba97-e6972f69549b" /> * Live Heap <img width="1009" height="1024" alt="image" src="https://github.com/user-attachments/assets/c7161fad-e922-43d6-b7c8-5cfbc0706cd0" /> ### Testing in User Environment: * User deployed a binary with this and next PR (#18797) to their service; no crashes observed in 24 hours. (cc @askardog ) [PROF-14568]: https://datadoghq.atlassian.net/browse/PROF-14568?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: KowalskiThomas <thomas.kowalski@datadoghq.com> Co-authored-by: vlad.scherbich <vlad.scherbich@datadoghq.com>
1 parent 114d6d8 commit e083509

15 files changed

Lines changed: 410 additions & 27 deletions

File tree

ddtrace/internal/datadog/profiling/dd_wrapper/include/profiler_stats.hpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ class ProfilerStats
3131
// Whether fast_copy_memory (safe_memcpy) is enabled; unset until the sampler starts
3232
std::optional<bool> fast_copy_memory_enabled;
3333

34+
// User opted out of fast copy (env var or set_fast_copy(false)); static per process
35+
std::optional<bool> fast_copy_memory_user_disabled;
36+
37+
// Whether safe_memcpy initialized at startup; static per process
38+
std::optional<bool> fast_copy_memory_capable;
39+
40+
// Sticky: fell back to syscall copy (init failure, foreign handler, etc.)
41+
std::optional<bool> fast_copy_memory_syscall_fallback;
42+
3443
// Number of copy_memory errors accumulated since the last profile reset (i.e. since the last upload)
3544
size_t copy_memory_error_count = 0;
3645

@@ -69,6 +78,18 @@ class ProfilerStats
6978
void set_fast_copy_memory_enabled(bool enabled);
7079
std::optional<bool> get_fast_copy_memory_enabled() const;
7180

81+
void set_fast_copy_memory_user_disabled(bool disabled);
82+
std::optional<bool> get_fast_copy_memory_user_disabled() const;
83+
84+
void set_fast_copy_memory_capable(bool capable);
85+
std::optional<bool> get_fast_copy_memory_capable() const;
86+
87+
void set_fast_copy_memory_syscall_fallback(bool fallback);
88+
std::optional<bool> get_fast_copy_memory_syscall_fallback() const;
89+
90+
// fast_copy_memory_* are process-static; carry them across ProfilerStats swaps.
91+
void copy_fast_copy_metadata_from(const ProfilerStats& other);
92+
7293
void add_copy_memory_error_count(size_t count);
7394
size_t get_copy_memory_error_count() const;
7495

ddtrace/internal/datadog/profiling/dd_wrapper/src/profiler_stats.cpp

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ append_to_string(std::string& s, size_t value)
1212
s.append(buf, ptr);
1313
}
1414

15+
void
16+
append_optional_bool(std::string& s, const char* key, const std::optional<bool>& value)
17+
{
18+
if (!value.has_value()) {
19+
return;
20+
}
21+
s += '"';
22+
s += key;
23+
s += "\": ";
24+
s += *value ? "true" : "false";
25+
s += ',';
26+
}
27+
1528
} // namespace
1629

1730
void
@@ -51,7 +64,7 @@ Datadog::ProfilerStats::reset_state()
5164
asyncio_task_count = std::nullopt;
5265
greenlet_count = std::nullopt;
5366
sample_capture_cpu_time_us = 0;
54-
// fast_copy_memory_enabled is intentionally not reset: it reflects a static configuration
67+
// fast_copy_memory_* static fields are intentionally not reset (see setters).
5568
}
5669

5770
void
@@ -66,6 +79,59 @@ Datadog::ProfilerStats::get_fast_copy_memory_enabled() const
6679
return fast_copy_memory_enabled;
6780
}
6881

82+
void
83+
Datadog::ProfilerStats::set_fast_copy_memory_user_disabled(bool disabled)
84+
{
85+
fast_copy_memory_user_disabled = disabled;
86+
}
87+
88+
std::optional<bool>
89+
Datadog::ProfilerStats::get_fast_copy_memory_user_disabled() const
90+
{
91+
return fast_copy_memory_user_disabled;
92+
}
93+
94+
void
95+
Datadog::ProfilerStats::set_fast_copy_memory_capable(bool capable)
96+
{
97+
fast_copy_memory_capable = capable;
98+
}
99+
100+
std::optional<bool>
101+
Datadog::ProfilerStats::get_fast_copy_memory_capable() const
102+
{
103+
return fast_copy_memory_capable;
104+
}
105+
106+
void
107+
Datadog::ProfilerStats::set_fast_copy_memory_syscall_fallback(bool fallback)
108+
{
109+
fast_copy_memory_syscall_fallback = fallback;
110+
}
111+
112+
std::optional<bool>
113+
Datadog::ProfilerStats::get_fast_copy_memory_syscall_fallback() const
114+
{
115+
return fast_copy_memory_syscall_fallback;
116+
}
117+
118+
void
119+
Datadog::ProfilerStats::copy_fast_copy_metadata_from(const ProfilerStats& other)
120+
{
121+
if (auto value = other.get_fast_copy_memory_user_disabled()) {
122+
set_fast_copy_memory_user_disabled(*value);
123+
}
124+
if (auto value = other.get_fast_copy_memory_capable()) {
125+
set_fast_copy_memory_capable(*value);
126+
}
127+
if (auto value = other.get_fast_copy_memory_syscall_fallback()) {
128+
set_fast_copy_memory_syscall_fallback(*value);
129+
}
130+
if (auto value = other.get_fast_copy_memory_enabled()) {
131+
set_fast_copy_memory_enabled(*value);
132+
}
133+
}
134+
69135
void
70136
Datadog::ProfilerStats::add_copy_memory_error_count(size_t count)
71137
{
@@ -203,6 +269,11 @@ Datadog::ProfilerStats::get_internal_metadata_json()
203269
internal_metadata_json += ",";
204270
}
205271

272+
append_optional_bool(internal_metadata_json, "fast_copy_memory_user_disabled", fast_copy_memory_user_disabled);
273+
append_optional_bool(internal_metadata_json, "fast_copy_memory_capable", fast_copy_memory_capable);
274+
append_optional_bool(
275+
internal_metadata_json, "fast_copy_memory_syscall_fallback", fast_copy_memory_syscall_fallback);
276+
206277
auto maybe_heap_tracker_count = get_heap_tracker_size();
207278
if (maybe_heap_tracker_count) {
208279
internal_metadata_json += R"("heap_tracker_count": )";

ddtrace/internal/datadog/profiling/dd_wrapper/src/uploader_builder.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ Datadog::UploaderBuilder::build()
205205
// Swap the ProfilerStats (which replaces the one being written to with an empty state).
206206
// We do this first as we still want to reset ProfilerStats if the serialization fails.
207207
std::swap(stats, borrowed.stats());
208+
borrowed.stats().copy_fast_copy_metadata_from(stats);
208209

209210
// Try to encode the Profile (which will also reset it)
210211
encoded = ddog_prof_Profile_serialize(&borrowed.profile(), nullptr, nullptr);

ddtrace/internal/datadog/profiling/stack/__init__.pyi

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def set_interval(new_interval: float) -> None: ...
4242
# Memory copy strategy
4343
def set_fast_copy(enabled: bool) -> None: ...
4444
def is_safe_copy_failed() -> bool: ...
45+
def fast_copy_memory_active() -> bool: ... # test introspection: is safe_memcpy active?
46+
47+
# _set_fast_copy_warmup_seconds is test-only; accessed via _stack (import * skips it).
48+
4549
def uninstall_segv_handler() -> None: ...
4650
def reinstall_segv_handler() -> None:
4751
"""Reinstall SIGSEGV/SIGBUS handlers after another component overwrites them.
@@ -52,6 +56,14 @@ def reinstall_segv_handler() -> None:
5256
"""
5357
...
5458

59+
def segv_handler_installed() -> bool:
60+
"""Return True if our handler is the installed disposition for SIGSEGV and SIGBUS.
61+
62+
Primarily test introspection: it queries the live disposition via sigaction(2)
63+
for both signals on every call, so it is not free. Do not call it on hot paths.
64+
"""
65+
...
66+
5567
# Pause/resume sampling
5668
def pause_sampling() -> bool | None:
5769
"""Pause the sampling thread and wait for any in-flight sample to complete.

ddtrace/internal/datadog/profiling/stack/_stack.pyi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ def set_interval(new_interval: float) -> None: ...
2323
# Memory copy strategy
2424
def set_fast_copy(enabled: bool) -> None: ...
2525
def is_safe_copy_failed() -> bool: ...
26+
def fast_copy_memory_active() -> bool: ... # test introspection: is safe_memcpy active?
27+
def _set_fast_copy_warmup_seconds(seconds: float) -> None: ... # test-only; before start
28+
def segv_handler_installed() -> bool:
29+
"""Return True if our handler is the installed disposition for SIGSEGV and SIGBUS.
30+
31+
Primarily test introspection: it queries the live disposition via sigaction(2)
32+
for both signals on every call, so it is costly. Do not call it on hot paths.
33+
"""
34+
...
2635

2736
# span <-> profile association
2837
def link_span(

ddtrace/internal/datadog/profiling/stack/echion/echion/danger.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ init_segv_catcher();
2727
void
2828
uninstall_segv_handler();
2929

30+
// Returns true only if our signal handler owns both SIGSEGV and SIGBUS; false on any error.
31+
bool
32+
segv_handler_installed();
33+
3034
#if defined PL_LINUX
3135
ssize_t
3236
safe_memcpy_wrapper(pid_t,

ddtrace/internal/datadog/profiling/stack/echion/echion/vm.h

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,19 @@ inline kern_return_t (*safe_copy)(vm_map_read_t,
5454
// Whether safe_copy is currently set to the memcpy-based wrapper.
5555
inline bool fast_copy_active = false;
5656

57-
// Whether init_segv_catcher succeeded at constructor time. Persists even if
58-
// fast_copy_active is later toggled off by set_fast_copy_enabled.
57+
// User opted out via _DD_PROFILING_STACK_FAST_COPY or set_fast_copy(false).
58+
inline bool fast_copy_user_disabled = false;
59+
60+
// Sticky: fell back to syscall copy (init failure, foreign handler, warmup miss).
61+
inline bool fast_copy_syscall_fallback = false;
62+
63+
inline void
64+
mark_fast_copy_syscall_fallback()
65+
{
66+
fast_copy_syscall_fallback = true;
67+
}
68+
69+
// Set at init; survives toggling fast_copy_active.
5970
inline bool safe_memcpy_initialized = false;
6071

6172
#if defined PL_LINUX

ddtrace/internal/datadog/profiling/stack/include/sampler.hpp

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,6 @@ class Sampler
4545
// The mutex + condition variable pair is used to avoid the "lost wake-up" race condition
4646
// where stop() could miss the notification and hang forever (or until timeout).
4747
std::atomic<bool> thread_running{ false };
48-
std::mutex thread_exit_mutex;
49-
std::condition_variable thread_exit_cv;
50-
5148
// Whether the sampler is currently active. Unlike thread_running (which tracks
5249
// the thread's actual lifecycle) this is set synchronously in start/stop, so
5350
// it is not subject to the sampling-thread startup race.
@@ -56,6 +53,8 @@ class Sampler
5653
// prefork reads this to decide whether to restart the sampler after fork,
5754
// ensuring a sampler that stopped due to an error is not silently restarted.
5855
std::atomic<bool> sampler_active_{ false };
56+
std::mutex thread_exit_mutex;
57+
std::condition_variable thread_exit_cv;
5958

6059
// Pause synchronization — allows the faulthandler wrapper to temporarily
6160
// suspend sampling so signal handlers can be safely swapped without racing
@@ -101,6 +100,9 @@ class Sampler
101100
// Percentile (0..1) used for p_stable; configurable, default p95.
102101
double p_stable_percentile_frac = 0.95;
103102

103+
// Fast-copy startup warmup in seconds.
104+
double fast_copy_warmup_seconds = 15.0;
105+
104106
// Rolling window duration in seconds; controls the ring buffer capacity.
105107
uint32_t p_stable_window_s = 600;
106108

@@ -164,6 +166,8 @@ class Sampler
164166
// Set the percentile (0–100) used to compute p_stable from the rolling window.
165167
void set_p_stable_percentile(double percentile) { p_stable_percentile_frac = percentile / 100.0; }
166168

169+
void set_fast_copy_warmup_seconds(double value) { fast_copy_warmup_seconds = value; }
170+
167171
// Delegates to the StackRenderer to clear its caches after fork
168172
void postfork_child();
169173

@@ -178,4 +182,7 @@ class Sampler
178182
bool restart_after_fork();
179183
};
180184

185+
void
186+
seed_fast_copy_profiler_stats();
187+
181188
} // namespace Datadog

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,24 @@ init_segv_catcher()
140140
return 0;
141141
}
142142

143+
bool
144+
segv_handler_installed()
145+
{
146+
// Recovery needs our handler to own BOTH SIGSEGV and SIGBUS
147+
// (a copy fault can arrive as either); anything else means we can't recover.
148+
const int signals[] = { SIGSEGV, SIGBUS };
149+
for (int signo : signals) {
150+
struct sigaction current;
151+
if (sigaction(signo, nullptr, &current) != 0) {
152+
return false;
153+
}
154+
if (current.sa_sigaction != segv_handler || (current.sa_flags & SA_SIGINFO) == 0) {
155+
return false;
156+
}
157+
}
158+
return true;
159+
}
160+
143161
void
144162
uninstall_segv_handler()
145163
{

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ init_safe_copy()
4141
// Honor the fast-copy opt-out: when disabled via env var, skip installing
4242
// the SIGSEGV/SIGBUS handlers and alt stack entirely.
4343
if (fast_copy_env_disabled()) {
44+
fast_copy_user_disabled = true;
4445
if (process_vm_readv_available) {
4546
safe_copy = process_vm_readv;
4647
} else {
@@ -60,6 +61,7 @@ init_safe_copy()
6061
fprintf(stderr, "Failed to initialize segv catcher. Trying process_vm_readv.\n");
6162
if (process_vm_readv_available) {
6263
safe_copy = process_vm_readv;
64+
mark_fast_copy_syscall_fallback();
6365
} else {
6466
fprintf(stderr, "Failed to initialize safe copy interface\n");
6567
failed_safe_copy = true;
@@ -72,6 +74,7 @@ init_safe_copy()
7274
{
7375
// Honor the fast-copy opt-out: skip installing signal handlers when disabled.
7476
if (fast_copy_env_disabled()) {
77+
fast_copy_user_disabled = true;
7578
return;
7679
}
7780

@@ -84,6 +87,7 @@ init_safe_copy()
8487

8588
// std::cerr might not be fully initialized at constructor time.
8689
fprintf(stderr, "Failed to initialize segv catcher. Using mach_vm_read_overwrite instead.\n");
90+
mark_fast_copy_syscall_fallback();
8791
}
8892
#endif // PL_DARWIN
8993

@@ -99,6 +103,7 @@ set_fast_copy_enabled(bool enabled)
99103
}
100104
fprintf(stderr,
101105
"Warning: fast copy requested but safe_memcpy was not initialized; falling back to process_vm_readv\n");
106+
mark_fast_copy_syscall_fallback();
102107

103108
// Fall through to process_vm_readv.
104109
}

0 commit comments

Comments
 (0)