From c15a9328ec835a74a4bd7c3f43b21ae0ef375230 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Fri, 22 May 2026 14:45:56 +0200 Subject: [PATCH 1/2] feat(profiler): Support PHP DEBUG builds for ASAN tests --- .github/workflows/prof_asan.yml | 8 ++- profiling/build.rs | 66 ++++++++++++++++++ profiling/src/allocation/allocation_ge84.rs | 76 +++++++++++++++++++++ profiling/src/allocation/allocation_le83.rs | 10 +++ profiling/src/bindings/mod.rs | 18 +++++ profiling/src/php_ffi.c | 6 +- profiling/src/php_ffi.h | 16 ++++- 7 files changed, 191 insertions(+), 9 deletions(-) diff --git a/.github/workflows/prof_asan.yml b/.github/workflows/prof_asan.yml index 80d385a4e6f..01dd11a838f 100644 --- a/.github/workflows/prof_asan.yml +++ b/.github/workflows/prof_asan.yml @@ -5,10 +5,12 @@ on: jobs: prof-asan: + name: PHP ${{ matrix.php-version }} ${{ matrix.php-build }} (${{ matrix.runner }}) runs-on: ${{ matrix.runner }} strategy: matrix: php-version: [8.3, 8.4, 8.5] + php-build: [nts-asan, debug-zts-asan] runner: [arm-8core-linux, ubuntu-8-core-latest] env: CARGO_HOME: /rust/cargo @@ -42,12 +44,12 @@ jobs: uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: /tmp/build-cargo/ - key: ${{ runner.os }}-${{ runner.arch }}-cargo-target-asan-${{ matrix.php-version }}-${{ env.RUST_TOOLCHAIN }}-${{ github.sha }}-${{ hashFiles('.github/workflows/prof_asan.yml') }} + key: ${{ runner.os }}-${{ runner.arch }}-cargo-target-asan-${{ matrix.php-version }}-${{ matrix.php-build }}-${{ env.RUST_TOOLCHAIN }}-${{ github.sha }}-${{ hashFiles('.github/workflows/prof_asan.yml') }} - name: Build and install profiler run: | set -eux - switch-php nts-asan + switch-php ${{ matrix.php-build }} cd profiling export CC=clang-19 export CFLAGS='-fsanitize=address -fno-omit-frame-pointer' @@ -60,7 +62,7 @@ jobs: - name: Run phpt tests run: | set -eux - switch-php nts-asan + switch-php ${{ matrix.php-build }} cd profiling/tests cp -v $(php-config --prefix)/lib/php/build/run-tests.php . export DD_PROFILING_OUTPUT_PPROF=/tmp/pprof diff --git a/profiling/build.rs b/profiling/build.rs index 491366c824e..b953194521b 100644 --- a/profiling/build.rs +++ b/profiling/build.rs @@ -54,6 +54,7 @@ fn main() { cfg_php_major_version(vernum); cfg_php_feature_flags(vernum); cfg_zts(); + cfg_php_debug(); apple_linker_flags(); } @@ -462,6 +463,71 @@ int main() { } } +fn cfg_php_debug() { + println!("cargo::rustc-check-cfg=cfg(php_debug)"); + + let output = Command::new("php-config") + .arg("--include-dir") + .output() + .expect("Unable to run `php-config`. Is it in your PATH?"); + + if !output.status.success() { + match String::from_utf8(output.stderr) { + Ok(stderr) => panic!("`php-config --include-dir` failed: {stderr}"), + Err(err) => panic!("`php-config --include-dir` failed, not utf8: {err}"), + } + } + + let include_dir = std::str::from_utf8(output.stdout.as_slice()) + .expect("`php-config`'s stdout to be valid utf8") + .trim(); + + let out_dir = env::var("OUT_DIR").unwrap(); + let probe_path = Path::new(&out_dir).join("php_debug_probe.c"); + fs::write( + &probe_path, + r#" +#include "main/php_config.h" +#include +int main() { +#if ZEND_DEBUG + printf("1"); +#else + printf("0"); +#endif + return 0; +} +"#, + ) + .expect("Failed to write PHP debug probe file"); + + let compiler = cc::Build::new().get_compiler(); + let probe_exe = Path::new(&out_dir).join("php_debug_probe"); + let compile_status = Command::new(compiler.path()) + .arg(format!("-I{}", include_dir)) + .arg(&probe_path) + .arg("-o") + .arg(&probe_exe) + .status() + .expect("Failed to compile PHP debug probe"); + + if !compile_status.success() { + panic!("Failed to compile PHP debug probe"); + } + + let probe_output = Command::new(&probe_exe) + .output() + .expect("Failed to run PHP debug probe"); + + let debug_value = std::str::from_utf8(&probe_output.stdout) + .expect("PHP debug probe output not UTF-8") + .trim(); + + if debug_value == "1" { + println!("cargo:rustc-cfg=php_debug"); + } +} + /// On macOS (Apple targets), the cdylib has undefined symbols that are /// resolved at load time by the PHP process. In debug builds, LTO is off /// which produces more unresolved symbols--we fall back to diff --git a/profiling/src/allocation/allocation_ge84.rs b/profiling/src/allocation/allocation_ge84.rs index b73fae86272..71a60144095 100644 --- a/profiling/src/allocation/allocation_ge84.rs +++ b/profiling/src/allocation/allocation_ge84.rs @@ -7,6 +7,9 @@ use libc::{c_char, c_int, c_void, size_t}; use log::{debug, trace, warn}; use std::sync::atomic::Ordering::Relaxed; +#[cfg(php_debug)] +use libc::c_uint; + #[cfg(feature = "debug_stats")] use crate::allocation::{ALLOCATION_PROFILING_COUNT, ALLOCATION_PROFILING_SIZE}; @@ -271,7 +274,23 @@ unsafe fn restore_zend_heap(heap: *mut zend::_zend_mm_heap, custom_heap: c_int) ptr::write(heap as *mut c_int, custom_heap); } +#[cfg(not(php_debug))] unsafe extern "C" fn alloc_prof_malloc(len: size_t) -> *mut c_void { + alloc_prof_malloc_impl(len) +} + +#[cfg(php_debug)] +unsafe extern "C" fn alloc_prof_malloc( + len: size_t, + _file: *const c_char, + _line: c_uint, + _orig_file: *const c_char, + _orig_line: c_uint, +) -> *mut c_void { + alloc_prof_malloc_impl(len) +} + +unsafe fn alloc_prof_malloc_impl(len: size_t) -> *mut c_void { #[cfg(feature = "debug_stats")] ALLOCATION_PROFILING_COUNT.fetch_add(1, Relaxed); #[cfg(feature = "debug_stats")] @@ -300,6 +319,11 @@ unsafe fn alloc_prof_prev_alloc(len: size_t) -> *mut c_void { // neighboring extension could misbehave. If that happens, we want a proper // panic with backtrace for debugging rather than undefined behavior. let alloc = tls_zend_mm_state_get!(prev_custom_mm_alloc).unwrap(); + #[cfg(php_debug)] + { + return alloc(len, ptr::null(), 0, ptr::null(), 0); + } + #[cfg(not(php_debug))] alloc(len) } @@ -308,6 +332,9 @@ unsafe fn alloc_prof_orig_alloc(len: size_t) -> *mut c_void { // handlers only point to this function after successful init. Using `unwrap_unchecked()` is // safe here as we have full control over ZendMM with no neighboring extensions. let heap = tls_zend_mm_state_get!(heap).unwrap_unchecked(); + #[cfg(php_debug)] + return zend::_zend_mm_alloc(heap, len, ptr::null(), 0, ptr::null(), 0); + #[cfg(not(php_debug))] zend::_zend_mm_alloc(heap, len) } @@ -315,7 +342,23 @@ unsafe fn alloc_prof_orig_alloc(len: size_t) -> *mut c_void { /// you need to pass a pointer to a `free()` function as well, otherwise your /// custom handlers won't be installed. We cannot just point to the original /// `zend::_zend_mm_free()` as the function definitions differ. +#[cfg(not(php_debug))] unsafe extern "C" fn alloc_prof_free(ptr: *mut c_void) { + alloc_prof_free_impl(ptr); +} + +#[cfg(php_debug)] +unsafe extern "C" fn alloc_prof_free( + ptr: *mut c_void, + _file: *const c_char, + _line: c_uint, + _orig_file: *const c_char, + _orig_line: c_uint, +) { + alloc_prof_free_impl(ptr); +} + +unsafe fn alloc_prof_free_impl(ptr: *mut c_void) { tls_zend_mm_state_get!(free)(ptr); } @@ -327,6 +370,11 @@ unsafe fn alloc_prof_prev_free(ptr: *mut c_void) { // neighboring extension could misbehave. If that happens, we want a proper // panic with backtrace for debugging rather than undefined behavior. let free = tls_zend_mm_state_get!(prev_custom_mm_free).unwrap(); + #[cfg(php_debug)] + { + return free(ptr, core::ptr::null(), 0, core::ptr::null(), 0); + } + #[cfg(not(php_debug))] free(ptr) } @@ -335,10 +383,30 @@ unsafe fn alloc_prof_orig_free(ptr: *mut c_void) { // handlers only point to this function after successful init. Using `unwrap_unchecked()` is // safe here as we have full control over ZendMM with no neighboring extensions. let heap = tls_zend_mm_state_get!(heap).unwrap_unchecked(); + #[cfg(php_debug)] + return zend::_zend_mm_free(heap, ptr, core::ptr::null(), 0, core::ptr::null(), 0); + #[cfg(not(php_debug))] zend::_zend_mm_free(heap, ptr); } +#[cfg(not(php_debug))] unsafe extern "C" fn alloc_prof_realloc(prev_ptr: *mut c_void, len: size_t) -> *mut c_void { + alloc_prof_realloc_impl(prev_ptr, len) +} + +#[cfg(php_debug)] +unsafe extern "C" fn alloc_prof_realloc( + prev_ptr: *mut c_void, + len: size_t, + _file: *const c_char, + _line: c_uint, + _orig_file: *const c_char, + _orig_line: c_uint, +) -> *mut c_void { + alloc_prof_realloc_impl(prev_ptr, len) +} + +unsafe fn alloc_prof_realloc_impl(prev_ptr: *mut c_void, len: size_t) -> *mut c_void { #[cfg(feature = "debug_stats")] ALLOCATION_PROFILING_COUNT.fetch_add(1, Relaxed); #[cfg(feature = "debug_stats")] @@ -367,6 +435,11 @@ unsafe fn alloc_prof_prev_realloc(prev_ptr: *mut c_void, len: size_t) -> *mut c_ // neighboring extension could misbehave. If that happens, we want a proper // panic with backtrace for debugging rather than undefined behavior. let realloc = tls_zend_mm_state_get!(prev_custom_mm_realloc).unwrap(); + #[cfg(php_debug)] + { + return realloc(prev_ptr, len, ptr::null(), 0, ptr::null(), 0); + } + #[cfg(not(php_debug))] realloc(prev_ptr, len) } @@ -375,6 +448,9 @@ unsafe fn alloc_prof_orig_realloc(prev_ptr: *mut c_void, len: size_t) -> *mut c_ // handlers only point to this function after successful init. Using `unwrap_unchecked()` is // safe here as we have full control over ZendMM with no neighboring extensions. let heap = tls_zend_mm_state_get!(heap).unwrap_unchecked(); + #[cfg(php_debug)] + return zend::_zend_mm_realloc(heap, prev_ptr, len, ptr::null(), 0, ptr::null(), 0); + #[cfg(not(php_debug))] zend::_zend_mm_realloc(heap, prev_ptr, len) } diff --git a/profiling/src/allocation/allocation_le83.rs b/profiling/src/allocation/allocation_le83.rs index 9f13b0a42d9..aac0ff0017c 100644 --- a/profiling/src/allocation/allocation_le83.rs +++ b/profiling/src/allocation/allocation_le83.rs @@ -320,6 +320,9 @@ unsafe fn alloc_prof_orig_alloc(len: size_t) -> *mut c_void { let heap = tls_zend_mm_state_get!(heap).unwrap_unchecked(); let (prepare, restore) = tls_zend_mm_state_get!(prepare_restore_zend_heap); let custom_heap = prepare(heap); + #[cfg(php_debug)] + let ptr: *mut c_void = zend::_zend_mm_alloc(heap, len, ptr::null(), 0, ptr::null(), 0); + #[cfg(not(php_debug))] let ptr: *mut c_void = zend::_zend_mm_alloc(heap, len); restore(heap, custom_heap); ptr @@ -345,6 +348,9 @@ unsafe fn alloc_prof_orig_free(ptr: *mut c_void) { // Safety: `ZEND_MM_STATE.heap` will be initialised in `alloc_prof_rinit()` and custom ZendMM // handlers are only installed and pointing to this function if initialization was succesful. let heap = tls_zend_mm_state_get!(heap).unwrap_unchecked(); + #[cfg(php_debug)] + zend::_zend_mm_free(heap, ptr, core::ptr::null(), 0, core::ptr::null(), 0); + #[cfg(not(php_debug))] zend::_zend_mm_free(heap, ptr); } @@ -383,6 +389,10 @@ unsafe fn alloc_prof_orig_realloc(prev_ptr: *mut c_void, len: size_t) -> *mut c_ let heap = tls_zend_mm_state_get!(heap).unwrap_unchecked(); let (prepare, restore) = tls_zend_mm_state_get!(prepare_restore_zend_heap); let custom_heap = prepare(heap); + #[cfg(php_debug)] + let ptr: *mut c_void = + zend::_zend_mm_realloc(heap, prev_ptr, len, ptr::null(), 0, ptr::null(), 0); + #[cfg(not(php_debug))] let ptr: *mut c_void = zend::_zend_mm_realloc(heap, prev_ptr, len); restore(heap, custom_heap); ptr diff --git a/profiling/src/bindings/mod.rs b/profiling/src/bindings/mod.rs index ac60a6bdbc2..220be2cd123 100644 --- a/profiling/src/bindings/mod.rs +++ b/profiling/src/bindings/mod.rs @@ -40,9 +40,27 @@ pub type VmZendThrowExceptionHook = unsafe extern "C" fn(*mut zval); #[cfg(php8)] pub type VmZendThrowExceptionHook = unsafe extern "C" fn(*mut zend_object); +#[cfg(not(all(php_debug, php_zend_mm_set_custom_handlers_ex)))] pub type VmMmCustomAllocFn = unsafe extern "C" fn(size_t) -> *mut c_void; +#[cfg(all(php_debug, php_zend_mm_set_custom_handlers_ex))] +pub type VmMmCustomAllocFn = + unsafe extern "C" fn(size_t, *const c_char, c_uint, *const c_char, c_uint) -> *mut c_void; +#[cfg(not(all(php_debug, php_zend_mm_set_custom_handlers_ex)))] pub type VmMmCustomReallocFn = unsafe extern "C" fn(*mut c_void, size_t) -> *mut c_void; +#[cfg(all(php_debug, php_zend_mm_set_custom_handlers_ex))] +pub type VmMmCustomReallocFn = unsafe extern "C" fn( + *mut c_void, + size_t, + *const c_char, + c_uint, + *const c_char, + c_uint, +) -> *mut c_void; +#[cfg(not(all(php_debug, php_zend_mm_set_custom_handlers_ex)))] pub type VmMmCustomFreeFn = unsafe extern "C" fn(*mut c_void); +#[cfg(all(php_debug, php_zend_mm_set_custom_handlers_ex))] +pub type VmMmCustomFreeFn = + unsafe extern "C" fn(*mut c_void, *const c_char, c_uint, *const c_char, c_uint); #[cfg(php_zend_mm_set_custom_handlers_ex)] pub type VmMmCustomGcFn = unsafe extern "C" fn() -> size_t; #[cfg(php_zend_mm_set_custom_handlers_ex)] diff --git a/profiling/src/php_ffi.c b/profiling/src/php_ffi.c index 00f684e9534..b53ff1687e4 100644 --- a/profiling/src/php_ffi.c +++ b/profiling/src/php_ffi.c @@ -229,9 +229,9 @@ void ddog_php_prof_copy_long_into_zval(zval *dest, long num) { } void ddog_php_prof_zend_mm_set_custom_handlers(zend_mm_heap *heap, - void* (*_malloc)(size_t), - void (*_free)(void*), - void* (*_realloc)(void*, size_t)) { + ddog_php_prof_zend_mm_malloc _malloc, + ddog_php_prof_zend_mm_free _free, + ddog_php_prof_zend_mm_realloc _realloc) { zend_mm_set_custom_handlers(heap, _malloc, _free, _realloc); #if PHP_VERSION_ID < 70300 if (!_malloc && !_free && !_realloc) { diff --git a/profiling/src/php_ffi.h b/profiling/src/php_ffi.h index f9aef1a21ec..cd61b95407d 100644 --- a/profiling/src/php_ffi.h +++ b/profiling/src/php_ffi.h @@ -146,10 +146,20 @@ void ddog_php_prof_copy_long_into_zval(zval *dest, long num); * `use_custom_heap` flag back to normal when null pointers are being passed * in on those PHP versions. */ +#if PHP_VERSION_ID >= 80400 && ZEND_DEBUG +typedef void *(*ddog_php_prof_zend_mm_malloc)(size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC); +typedef void (*ddog_php_prof_zend_mm_free)(void * ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC); +typedef void *(*ddog_php_prof_zend_mm_realloc)(void *, size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC); +#else +typedef void *(*ddog_php_prof_zend_mm_malloc)(size_t); +typedef void (*ddog_php_prof_zend_mm_free)(void *); +typedef void *(*ddog_php_prof_zend_mm_realloc)(void *, size_t); +#endif + void ddog_php_prof_zend_mm_set_custom_handlers(zend_mm_heap *heap, - void* (*_malloc)(size_t), - void (*_free)(void*), - void* (*_realloc)(void*, size_t)); + ddog_php_prof_zend_mm_malloc _malloc, + ddog_php_prof_zend_mm_free _free, + ddog_php_prof_zend_mm_realloc _realloc); zend_execute_data* ddog_php_prof_get_current_execute_data(); From e7932ed9b4389518ecc7e35bc06337a0431e8d23 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Tue, 26 May 2026 12:29:26 +0200 Subject: [PATCH 2/2] perf(profiler): force-inline alloc_prof_*_impl wrappers The extern "C" alloc/free/realloc handlers delegate to a shared *_impl function to avoid duplicating the body across the cfg(php_debug) / cfg(not(php_debug)) variants. While LTO=fat + codegen-units=1 in release builds would inline these single-call-site wrappers anyway, this makes the perf contract explicit and removes any doubt about an extra call surviving in the allocator hot path. Co-Authored-By: Claude Opus 4.7 (1M context) --- profiling/src/allocation/allocation_ge84.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/profiling/src/allocation/allocation_ge84.rs b/profiling/src/allocation/allocation_ge84.rs index 71a60144095..0ad1c5ed12f 100644 --- a/profiling/src/allocation/allocation_ge84.rs +++ b/profiling/src/allocation/allocation_ge84.rs @@ -290,6 +290,7 @@ unsafe extern "C" fn alloc_prof_malloc( alloc_prof_malloc_impl(len) } +#[inline(always)] unsafe fn alloc_prof_malloc_impl(len: size_t) -> *mut c_void { #[cfg(feature = "debug_stats")] ALLOCATION_PROFILING_COUNT.fetch_add(1, Relaxed); @@ -358,6 +359,7 @@ unsafe extern "C" fn alloc_prof_free( alloc_prof_free_impl(ptr); } +#[inline(always)] unsafe fn alloc_prof_free_impl(ptr: *mut c_void) { tls_zend_mm_state_get!(free)(ptr); } @@ -406,6 +408,7 @@ unsafe extern "C" fn alloc_prof_realloc( alloc_prof_realloc_impl(prev_ptr, len) } +#[inline(always)] unsafe fn alloc_prof_realloc_impl(prev_ptr: *mut c_void, len: size_t) -> *mut c_void { #[cfg(feature = "debug_stats")] ALLOCATION_PROFILING_COUNT.fetch_add(1, Relaxed);