fix(profiling): stack-profiler SIGSEGV on shutdown (PROF-15342) - #18797
fix(profiling): stack-profiler SIGSEGV on shutdown (PROF-15342)#18797vlad-scherbich wants to merge 2 commits into
Conversation
Close the shutdown race where the final scheduler flush triggered one-time code-provenance imports while the native stack sampler was still walking live frames during interpreter teardown, reading freed objects and crashing. - Pre-warm code provenance at scheduler start so the final flush does no cold imports. - Pause the native sampler before the final flush (StackCollector exposes pause_for_shutdown_flush; Profiler invokes it pre-flush). - Add a Py_IsFinalizing guard to the native sampling loop so it bails the moment interpreter finalization begins. Adds a start/stop-under-load regression test and a deterministic repro script.
Codeowners resolved as |
|
There was a problem hiding this comment.
Pull request overview
Fixes a stack-profiler shutdown crash (SIGSEGV) by removing cold-import work from the final flush, quiescing the native sampler before the last upload, and adding a native-side guard to stop sampling as soon as interpreter finalization begins.
Changes:
- Pre-warm code-provenance resolution during scheduler startup so shutdown flush avoids one-time
importlib.metadataimports. - Pause the native stack sampler prior to the final flush; add
Py_IsFinalizing/_Py_IsFinalizingguard in the sampling loop. - Add a regression subprocess test, a standalone repro script, and a release note.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
ddtrace/profiling/scheduler.py |
Pre-warms code provenance at scheduler start to prevent cold imports during shutdown flush. |
ddtrace/profiling/profiler.py |
Pauses collectors (when supported) before the final scheduler flush. |
ddtrace/profiling/collector/stack.py |
Implements pause_for_shutdown_flush() for the stack collector via stack.pause_sampling(). |
ddtrace/internal/datadog/profiling/stack/src/sampler.cpp |
Stops sampling immediately when the interpreter is finalizing. |
tests/profiling/test_main.py |
Adds a regression subprocess test for repeated start/stop under load. |
scripts/profiling/repro_prof_14568_shutdown.py |
Adds a standalone reproduction script for the shutdown crash scenario. |
releasenotes/notes/profiling-fix-stack-sampler-shutdown-segv-9f3c1a7e54b2d806.yaml |
Documents the crash fix in release notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @pytest.mark.subprocess( | ||
| env=dict( | ||
| DD_PROFILING_STACK_V2_ENABLED="true", | ||
| DD_PROFILING_STACK_FAST_COPY="1", | ||
| DD_PROFILING_ENABLE_CODE_PROVENANCE="true", | ||
| ), | ||
| out="OK\n", | ||
| ) |
| # Force the next flush to recompute provenance (re-trigger cold imports). | ||
| code_provenance._code_provenance_file_path = None |
…EGV 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>
< Prev PR | Next PR
Summary
This PR closes the shutdown race (Layer 2).
Profiler._stop_serviceruns the finalscheduler.flush()before it stops thecollectors. That first flush resolves the code-provenance file, which the very
first time triggers one-time
importlib.metadatacold imports(setuptools /
_distutils_hack/ packaging). Those imports run while the nativestack sampler is still walking live frames during interpreter teardown, so the
sampler can read a freed
PyCodeObject*and fault insidesafe_memcpy.Changes
scheduler.py) so the finalflush performs no cold imports — done on a safe thread when nothing is being
torn down.
profiler.py+collector/stack.py'spause_for_shutdown_flush). Collectors are not stoppedyet, so memalloc's snapshot still works; the stack v2 sampler pushes directly
to
ddupand has no meaningful snapshot, so pausing it is safe.Py_IsFinalizingguard in the native sampling loop (sampler.cpp) so thesampler bails the moment finalization begins (covers teardown paths that don't
stop the thread first, e.g. uWSGI
--skip-atexit, hard exits).Stacked with #18798 (handler-ownership hardening), which depends on this branch.
Reproduction
Repro script included:
scripts/profiling/repro_prof_14568_shutdown.py. Itrepeatedly starts/stops the profiler while threads churn frames, clearing the
code-provenance cache each cycle so the final flush does the cold imports while
sampling.
# crashes (intermittently) on main, survives on this branch DD_PROFILING_STACK_FAST_COPY=1 python scripts/profiling/repro_prof_14568_shutdown.pyTest plan
pytest tests/profiling/test_main.py -k start_stop_under_loadmain(intermittent crash) vs this branch (clean) on Linux.