Skip to content

chore(profiling): name the component that takes over SIGSEGV/SIGBUS - #20348

Draft
vlad-scherbich wants to merge 8 commits into
mainfrom
vlad/prof-identify-foreign-segv-handler
Draft

vlad-scherbich wants to merge 8 commits into
mainfrom
vlad/prof-identify-foreign-segv-handler

Conversation

@vlad-scherbich

@vlad-scherbich vlad-scherbich commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Description

When another component owns SIGSEGV or SIGBUS, the stack sampler already falls back to the syscall copy. This PR makes that takeover actionable: describe_segv_handler_owners() uses dladdr from the sampling loop to report each signal's owner as shared-object+offset (nearest symbol when exported), distinguishing SIG_DFL/SIG_IGN/ddtrace. The sampling thread stashes the owner string; StackCollector.snapshot() drains it once as LOG.warning on ddtrace.profiling.collector.stack, with distinct wording for foreign-at-warmup-end vs mid-run takeover. Immediate stderr at detection is restored so short-lived processes still see the warning. Fleet telemetry tags normalized handler basenames; Linux core-dump preconditions are captured once at profiler start. Links CMAKE_DL_LIBS. Fallback and crash behavior unchanged.

Repro harness on stacked branch: vlad/prof-identify-foreign-segv-handler...vlad/prof-foreign-segv-handler-repro

Testing

  • Unit: test_snapshot_names_foreign_segv_handler_owner, foreign-handler telemetry tests, test_core_preconditions.py
  • Manual on workspace-vlad-ws4 (Linux 6.8 aarch64, 16 cores)

Build the native handler and run the production-like foreign-native scenario:

ssh workspace-vlad-ws4
cd ~/segv-repro
gcc -shared -fPIC -O0 -o libforeign.so foreign_handler.c
REPRO_LOG_PATH=/tmp/v.ndjson REPRO_FOREIGN_LIB=$PWD/libforeign.so REPRO_SCENARIO=foreign-native \
  DD_PROFILING_ENABLED=1 _DD_PROFILING_STACK_FAST_COPY=1 DD_TRACE_AGENT_URL=http://127.0.0.1:18126 \
  .venvdiag/bin/python segv_handler_repro.py 2>&1 | grep -i "taken over"

Immediate stderr at detection (names the foreign owner per signal):

ddtrace stack profiler: SIGSEGV/SIGBUS handler was taken over by another component(SIGSEGV=/home/bits/segv-repro/libforeign.so+0x7c4, SIGBUS=ddtrace); falling back to syscall-based memory copy to avoid crashing.

Resolve the reported offset back to a symbol:

nm libforeign.so | grep foreign_handler
00000000000007c4 t foreign_handler

Structured LOG.warning via scheduler flush (mid-run takeover branch):

cd ~/segv-repro && REPRO_FOREIGN_LIB=$PWD/libforeign.so DD_PROFILING_ENABLED=1 _DD_PROFILING_STACK_FAST_COPY=1 \
  DD_PROFILING_UPLOAD_INTERVAL=3 DD_TRACE_AGENT_URL=http://127.0.0.1:18126 \
  REPRO_FAST_COPY_WARMUP_S=2 REPRO_WARMUP_S=5 REPRO_DETECT_TIMEOUT_S=25 \
  .venvdiag/bin/python verify_foreign_handler_log.py 2>&1 | grep -E "^\[setup|^\[result|^\[record|^\[verdict"
[setup] after warmup: ours=True fast_copy=True
[setup] native foreign handler installed: ours=False
[result] reported_via_scheduler_flush=1 (expect >=1)
[record] level=WARNING logger=ddtrace.profiling.collector.stack
[record] message=Another component owns the SIGSEGV/SIGBUS handler, so the stack profiler is using the slower syscall-based memory copy for the rest of this process;sample quality may be reduced. Handler owners: SIGSEGV=/home/bits/segv-repro/libforeign.so+0x7c4, SIGBUS=ddtrace (taken over after the profiler had upgraded to the faster copy).
[result] repeated_on_second_drain=0 (expect 0)
[result] fast_copy_still_active=False (expect False)
[result] expected_branch_phrase='taken over after' matched=True
[verdict] PASS

The stack profiler logs a takeover whenever it loses ownership of SIGSEGV or
SIGBUS and has to fall back to the syscall-based memory copy, but the message
said only "another component", which is not actionable: the reader cannot tell
which library installed the foreign handler, and these lines have repeatedly
sent investigations down the wrong path.

Resolve the current owner of each signal with dladdr and report it as shared
object + offset, plus the nearest symbol when the handler is exported. Also
distinguish SIG_DFL/SIG_IGN and our own handler, so the asymmetric case (a
component grabbing only SIGSEGV and leaving SIGBUS to us) is visible.

The lookup allocates and is not async-signal-safe, so it is called only from
the sampling loop, never from the handler itself. Link CMAKE_DL_LIBS since
dladdr still needs -ldl on the glibc 2.17 manylinux2014 target.
@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 1 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 15, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 218 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 218 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=133)
ddtrace.llmobs._integrations.mcp -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=131)
ddtrace.appsec._contrib.django -×-> ddtrace.trace  (product:appsec -> product:tracing, score=131)
ddtrace.debugging._signal.model -×-> ddtrace.trace  (product:debugging -> product:tracing, score=131)
ddtrace.llmobs._integrations.crewai -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=131)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/internal/datadog/profiling/stack/CMakeLists.txt                 @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/__init__.pyi                   @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/_stack.pyi                     @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/echion/echion/danger.h         @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/fuzz/CMakeLists.txt            @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/include/sampler.hpp            @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/echion/danger.cc           @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/sampler.cpp                @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/stack.cpp                  @DataDog/profiling-python
ddtrace/internal/datastreams/__init__.py                                @DataDog/data-streams-monitoring
ddtrace/internal/settings/_config.py                                    @DataDog/python-guild @DataDog/apm-sdk-capabilities-python
ddtrace/profiling/_core_preconditions.py                                @DataDog/profiling-python
ddtrace/profiling/collector/stack.py                                    @DataDog/profiling-python
ddtrace/profiling/profiler.py                                           @DataDog/profiling-python
tests/profiling/collector/test_stack_native.py                          @DataDog/profiling-python
tests/profiling/test_core_preconditions.py                              @DataDog/profiling-python

The takeover notice was written straight to std::cerr from the sampling loop, so
it surfaced as unstructured output that log pipelines classify as an error, even
though profiling continues normally on the slower copy. That framing is what sent
incident investigations chasing it.

Stash the foreign owner on the sampler and drain it from StackCollector.snapshot()
on the scheduler thread, which already does exactly this for the exception that
kills the sampling thread. Reporting it there routes it through the standard
logger at warning level, so it respects log configuration and stays out of
telemetry (DDTelemetryErrorHandler only forwards records at ERROR and above).

Keep the two cases distinct: a handler that was already foreign when the warmup
window ended is ordinary (pytest, faulthandler), whereas one taken over after we
upgraded to safe_memcpy means some component installed its handler lazily,
mid-process, which is the case worth chasing.

The sampling thread has no GIL and cannot log directly. The stash is drained on
read so the notice is reported at most once, and it is reset after fork so a
child never reports its parent's takeover.

The mypy pre-commit hook is bypassed: origin/main already reports the same 7
errors for these files, and this change adds none.
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 24 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | clang-tidy profiling — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-py | profiling/profile 11/23 — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-py | profiling/profile 12/23 — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

View all 24 failed jobs.

❄️ 14 New flaky tests detected

test_snapshot_emits_foreign_segv_handler_telemetry[False-false][py3.10] from test_stack_native.py
module 'ddtrace.profiling.collector' has no attribute 'stack'
test_snapshot_emits_foreign_segv_handler_telemetry[False-false][py3.9] from test_stack_native.py
module 'ddtrace.profiling.collector' has no attribute 'stack'

View in Flaky Test Management

ℹ️ Info

No other issues found (see more)

🧪 All tests passed

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 6c87637 | Docs | View more details | Give us feedback!

#20348 deferred takeover notices to snapshot() upload, so a process
dying within the upload interval lost the warning entirely. Write to
stderr at detection again while keeping the stash for structured logging.
…itions

Report foreign SIGSEGV/SIGBUS handler owners via telemetry with normalized
basename tags, and capture Linux core-dump preconditions once at profiler start.
@vlad-scherbich vlad-scherbich changed the title profiling: name the component that takes over SIGSEGV/SIGBUS chore(profiling): name the component that takes over SIGSEGV/SIGBUS Sep 16, 2026
@vlad-scherbich vlad-scherbich added changelog/no-changelog A changelog entry is not required for this PR. Profiling Continous Profling labels Sep 16, 2026
@vlad-scherbich
vlad-scherbich requested a lite review from Copilot September 16, 2026 02:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved native build-linkage and correctness issues remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds diagnostics for foreign SIGSEGV/SIGBUS handlers, Linux core-dump telemetry, and related native APIs, tests, typings, and build updates.

Changes:

  • Resolves and reports signal-handler owners.
  • Emits takeover warnings and normalized telemetry.
  • Adds core-dump precondition reporting.
File summaries
File Reviewed changes
tests/profiling/test_core_preconditions.py Tests core-dump telemetry helpers.
tests/profiling/collector/test_stack_native.py Tests takeover warnings and telemetry.
ddtrace/profiling/profiler.py Emits startup precondition telemetry.
ddtrace/profiling/collector/stack.py Drains owner data and normalizes telemetry; SIGBUS-only ownership reports the wrong token (moderate).
ddtrace/profiling/_core_preconditions.py Collects Linux core-dump metadata; dumpability may incorrectly report unknown (moderate).
ddtrace/internal/settings/_config.py Adds configuration type annotations.
ddtrace/internal/datastreams/__init__.py Adds processor typing annotations.
ddtrace/internal/datadog/profiling/stack/src/stack.cpp Exposes the native takeover API.
ddtrace/internal/datadog/profiling/stack/src/sampler.cpp Records takeover state; owner reporting may throw, and fallback notices may be emitted without an active fallback (moderate).
ddtrace/internal/datadog/profiling/stack/src/echion/danger.cc Resolves handler owners; the native path lacks coverage (moderate).
ddtrace/internal/datadog/profiling/stack/include/sampler.hpp Defines takeover state and APIs.
ddtrace/internal/datadog/profiling/stack/echion/echion/danger.h Declares owner-description support.
ddtrace/internal/datadog/profiling/stack/CMakeLists.txt Links dynamic-loader libraries; fuzz targets also require the linkage (critical).
ddtrace/internal/datadog/profiling/stack/_stack.pyi Documents the native API.
ddtrace/internal/datadog/profiling/stack/__init__.pyi Exposes the native API in stubs.
Review details

Suppressed comments (3)

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

  • The new native owner-description path is not exercised by the added automated tests: the snapshot tests mock take_foreign_segv_handler(), while the existing native regression only checks the boolean ownership predicate. A build or dladdr/sigaction regression could therefore pass all tests while producing incorrect owner strings or failing to expose the new API. Add a Linux/macOS subprocess or native test that installs known handlers and asserts the SIGSEGV/SIGBUS descriptors, including the SIGBUS-only case.
static std::string
describe_signal_owner(int signo)
{
    struct sigaction current;
    if (sigaction(signo, nullptr, &current) != 0) {

ddtrace/internal/datadog/profiling/stack/src/sampler.cpp:479

  • describe_segv_handler_owners() constructs std::string values and can throw (for example, on allocation failure), but this call is before the sampling loop's try/catch at line 513. An exception here escapes the pthread entry point and terminates the process instead of preserving the existing syscall fallback; make owner reporting best-effort and keep this fallback path non-throwing.
                        const auto owners = describe_segv_handler_owners();
                        record_foreign_segv_handler(true, owners);

ddtrace/internal/datadog/profiling/stack/src/sampler.cpp:497

  • These new records and the fallback-specific message are emitted before the set_fast_copy_enabled(false) check below. On Linux where process_vm_readv is unavailable, that call returns false, sampling stops, and fast_copy_active remains true; snapshot() then reports that the profiler is using the slower copy for the rest of the process even though no fallback is active and sampling has ended. Only queue this fallback notice after a successful fallback, or include a distinct stopped-sampling outcome in the report.
                const auto owners = describe_segv_handler_owners();
                record_foreign_segv_handler(false, owners);
                std::cerr << "ddtrace stack profiler: SIGSEGV/SIGBUS handler was taken over by another "
                             "component; falling back to syscall-based memory copy to avoid crashing. "
                          << "Handler owners: " << owners << std::endl;
  • Files reviewed: 15/15 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

endif()

target_link_libraries(${EXTENSION_NAME} PRIVATE dd_wrapper Threads::Threads)
target_link_libraries(${EXTENSION_NAME} PRIVATE dd_wrapper Threads::Threads ${CMAKE_DL_LIBS})
Comment on lines +39 to +43
if line.startswith("Dumpable:"):
value: str = line.split(":", 1)[1].strip()
if value == "1":
return "yes"
if value == "0":
Comment on lines +49 to +53
if sigsegv_owner is not None:
return sigsegv_owner
if sigbus_owner is not None:
return sigbus_owner
return _normalize_foreign_handler_owner_component(owner)
Link fuzz targets with CMAKE_DL_LIBS, prefer non-ddtrace handler owner
for telemetry when only one signal is foreign, reorder syscall fallback
recording after set_fast_copy_enabled(false) succeeds, and guard
describe_segv_handler_owners() with no-throw fallback.
@pr-commenter

pr-commenter Bot commented Sep 16, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-09-16 02:47:42

Comparing candidate commit 6c87637 in PR branch vlad/prof-identify-foreign-segv-handler with baseline commit eb8bb1f in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 1 performance regressions! Performance is the same for 566 metrics, 10 unstable metrics, 5 known flaky benchmarks, 19 flaky benchmarks without significant changes.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:otelspan-start

  • 🟥 execution_time [+2.475ms; +3.491ms] or [+7.988%; +11.265%]

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:coreapiscenario-context_with_data_listeners

  • unstable execution_time [-694.627ns; +789.706ns] or [-6.243%; +7.097%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-27.159ns; +38.478ns] or [-4.451%; +6.305%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1695.633ns; +1519.868ns] or [-10.075%; +9.031%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1285.947ns; +1190.309ns] or [-9.730%; +9.007%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-307.084ns; +330.014ns] or [-8.332%; +8.954%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-251.900ns; +248.343ns] or [-8.703%; +8.580%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-80.806ns; +72.342ns] or [-6.853%; +6.135%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3908.590ns; +4163.335ns] or [-9.575%; +10.199%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-759.365ns; +791.112ns] or [-9.234%; +9.620%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-62.978µs; +48.744µs] or [-10.654%; +8.246%]

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+2.532µs; +2.681µs] or [+15.198%; +16.090%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+108.856µs; +116.243µs] or [+27.737%; +29.619%]

scenario:span-start

  • 🟥 execution_time [+1.692ms; +1.848ms] or [+11.914%; +13.011%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+659.342ns; +696.438ns] or [+24.624%; +26.009%]

scenario:tracer-small

  • 🟥 execution_time [+39.284µs; +42.241µs] or [+12.736%; +13.695%]

Known flaky benchmarks without significant changes:

  • scenario:errortrackingflasksqli-baseline
  • scenario:flasksimple-iast-get
  • scenario:iastaspects-casefold_aspect
  • scenario:iastaspects-casefold_noaspect
  • scenario:iastaspects-index_aspect
  • scenario:iastaspects-ljust_noaspect
  • scenario:iastaspects-lower_aspect
  • scenario:iastaspects-replace_aspect
  • scenario:iastaspects-rstrip_aspect
  • scenario:iastaspects-swapcase_aspect
  • scenario:iastaspects-title_noaspect
  • scenario:iastaspects-translate_aspect
  • scenario:iastaspects-translate_noaspect
  • scenario:iastaspects-upper_noaspect
  • scenario:iastaspectssplit-rsplit_aspect
  • scenario:packagespackageforrootmodulemapping-cache_off
  • scenario:packagespackageforrootmodulemapping-cache_on
  • scenario:sethttpmeta-all-enabled
  • scenario:telemetryaddmetric-record-100-metrics

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/no-changelog A changelog entry is not required for this PR. Profiling Continous Profling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants