Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
21a9ae0
run language tests for profiler
realFlowControl Jul 28, 2025
c5b0c18
fix profiler language test CI build
realFlowControl Jun 16, 2026
27eeeed
stabilize profiler language test matrix
realFlowControl Jun 17, 2026
689f6a0
profiling: xfail curl_setopt_ssl.phpt in PHP language tests
realFlowControl Jun 17, 2026
95c505f
profiling: set O_CLOEXEC on duplicated stderr fd for logging
realFlowControl Jun 17, 2026
458bd76
profiling: xfail allow_url_include deprecation-ordering tests
realFlowControl Jun 17, 2026
2f6207a
profiling: add ZTS-only xfail list for PHP language tests
realFlowControl Jun 17, 2026
bf9ebc9
ci: remove rdkafka/memcached ini from active PHP scan dir in language…
realFlowControl Jun 17, 2026
e00f175
profiling: expand ZTS xfail list with all deprecation-ordering tests
realFlowControl Jun 17, 2026
09096e1
profiling: xfail remaining ZTS startup-warning-ordering tests
realFlowControl Jun 17, 2026
9074bec
profiling: document PHP language test xfail lists
realFlowControl Jun 17, 2026
45f4119
profiling: fix SKIP_ONLINE_TESTS typo, drop online tests from xfail
realFlowControl Jun 17, 2026
1f06966
profiling: trim tests README
realFlowControl Jun 17, 2026
80e0128
profiling: drop bug48203_multi from language test xfail list
realFlowControl Jun 17, 2026
dfc13d3
profiling: drop parallel ext in language tests instead of xfailing ZTS
realFlowControl Jun 17, 2026
291b95c
profiling: xfail opcache optimizer tests on PHP < 8.4
realFlowControl Jun 17, 2026
11d2ea0
profiling: guard against NULL file in timeline error observer
realFlowControl Jun 17, 2026
1d3fa3c
profiling: xfail bug60634 session test (parallel-run flake)
realFlowControl Jun 17, 2026
9d25d22
ci(profiler): install fixed parallel 1.2.14 in UBSAN job (temporary)
realFlowControl Jun 17, 2026
414c2e5
profiling: block all (non-fault) signals on helper threads
realFlowControl Jun 17, 2026
4ee700f
profiling: drop INVESTIGATE-opcache-do_icall.md
realFlowControl Jun 17, 2026
d98ec11
profiling: use per-version xfail lists for PHP language tests
realFlowControl Jun 17, 2026
44b3da8
Merge branch 'master' into florian/run-language-tests-for-profiler
morrisonlevi Jun 17, 2026
21a9a8f
profiling: replace per-version xfail symlinks with a version check
realFlowControl Jun 18, 2026
7d0a905
profiling: fail the language tests job if the profiler isn't loaded
realFlowControl Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/prof_asan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,25 @@ jobs:
cargo build --profile profiler-release
cp -v "$CARGO_TARGET_DIR/profiler-release/libdatadog_php_profiling.so" "$(php-config --extension-dir)/datadog-profiling.so"

# TODO(parallel): the php-8.5_bookworm-8 image ships parallel 1.2.13, which
# has a bug that intermittently trips UBSAN. Install the fixed 1.2.14 over
# it (ZTS-only; parallel requires ZTS). Remove this step once the CI images
# are rebuilt with parallel >= 1.2.14.
- name: Install fixed parallel 1.2.14 (ZTS only, temporary until images rebuilt)
if: matrix.php-build == 'zts'
run: |
set -eux
switch-php zts
scan_dir="$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')"
# pecl refuses to reinstall while the extension is loaded, so move its
# ini aside during the build, then restore it so the test run loads the
# freshly installed parallel.so. Use the direct package URL because the
# channel REST cache in the image can lag behind new releases.
mv "$scan_dir/parallel.ini" /tmp/parallel.ini.disabled
yes '' | pecl install -f https://pecl.php.net/get/parallel-1.2.14.tgz
mv /tmp/parallel.ini.disabled "$scan_dir/parallel.ini"
php --ri parallel | grep -i version

- name: Run phpt tests
run: |
set -eux
Expand Down
47 changes: 47 additions & 0 deletions .gitlab/generate-profiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
}
?>

# PHP 8.5 has a known tailcall VM crash; re-enable once PHP 8.5.8 is available.
.php_language_profiler_targets: &php_language_profiler_targets
<?php
foreach ($profiler_minor_major_targets as $version) {
if (version_compare($version, "8.5", "<")) {
echo " - \"{$version}\"\n";
}
}
?>

"profiling tests":
stage: test
tags: [ "arch:${ARCH}" ]
Expand Down Expand Up @@ -44,6 +54,8 @@
script:
- if [ -d '/opt/rh/devtoolset-7' ]; then set +eo pipefail; source scl_source enable devtoolset-7; set -eo pipefail; fi
- if [ -d '/opt/rh/devtoolset-7' ] && [ "$(uname -m)" = "aarch64" ]; then export BINDGEN_EXTRA_CLANG_ARGS="-I$(clang --print-resource-dir)/include"; fi
- if [ -f /sbin/apk ] && [ $(uname -m) = "aarch64" ]; then ln -sf ../lib/llvm17/bin/clang /usr/bin/clang; fi
- export DD_PROFILING_OUTPUT_PPROF=/tmp/

- cd profiling
- 'echo "nproc: $(nproc)"'
Expand Down Expand Up @@ -113,3 +125,38 @@
- switch-php nts # not compatible with debug
- cd profiling
- cargo test --all-features

"PHP language tests":
stage: test
tags: [ "arch:${ARCH}" ]
image: registry.ddbuild.io/images/mirror/datadog/dd-trace-ci:php-${PHP_MAJOR_MINOR}_bookworm-8
variables:
KUBERNETES_CPU_REQUEST: 5
KUBERNETES_MEMORY_REQUEST: 3Gi
KUBERNETES_MEMORY_LIMIT: 4Gi
CARGO_TARGET_DIR: /tmp/cargo
libdir: /tmp/datadog-profiling
SKIP_ONLINE_TESTS: "1"
REPORT_EXIT_STATUS: "1"
DD_PROFILING_OUTPUT_PPROF: /tmp/
XFAIL_LIST: dockerfiles/ci/xfail_tests/${PHP_MAJOR_MINOR}.list
parallel:
matrix:
- PHP_MAJOR_MINOR: *php_language_profiler_targets
ARCH: amd64
FLAVOUR: [nts, zts]
script:
- unset DD_SERVICE; unset DD_ENV
- command -v switch-php && switch-php "${FLAVOUR}"
- cd profiling
- cargo build --profile profiler-release
- cd ..
- echo "extension=/tmp/cargo/profiler-release/libdatadog_php_profiling.so" > /opt/php/${FLAVOUR}/conf.d/profiling.ini
- php -v
# Fail loudly if the profiler did not load: otherwise the language tests
# would run profiler-less and pass, giving a false green.
- php -m | grep -qx 'datadog-profiling' || { echo 'ERROR: datadog-profiling extension is not loaded'; exit 1; }
- cat "${XFAIL_LIST}" profiling/tests/php-language-xfail.list > /tmp/profiler-php-language-xfail.list
- if php -r 'exit(PHP_VERSION_ID < 80400 ? 0 : 1);'; then cat profiling/tests/php-language-xfail-pre84.list >> /tmp/profiler-php-language-xfail.list; fi
- export XFAIL_LIST=/tmp/profiler-php-language-xfail.list
- .gitlab/run_php_language_tests.sh
14 changes: 12 additions & 2 deletions .gitlab/run_php_language_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,18 @@ set -eo pipefail
# Helper to parse version strings for comparison
function version { echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }'; }

sudo rm -f /opt/php/debug/conf.d/memcached.ini
sudo rm -f /opt/php/debug/conf.d/rdkafka.ini
# Remove extensions whose mere presence pollutes test output or output
# ordering:
# - rdkafka emits CONFWARN lines, breaking Zend/tests/instantiate_all_classes.phpt
# - parallel (ZTS-only) defers 'PHP Startup' diagnostics until after the
# script's first output, breaking ~56 deprecation/warning-ordering tests
# Derive the scan dir from the active PHP so this works for every build
# variant (debug for the tracer job, nts/zts for the profiler job) instead
# of hardcoding the debug path.
scan_dir="$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')"
sudo rm -f "${scan_dir}/memcached.ini"
sudo rm -f "${scan_dir}/rdkafka.ini"
sudo rm -f "${scan_dir}/parallel.ini"
if [[ ! "${XFAIL_LIST:-none}" == "none" ]]; then
cp "${XFAIL_LIST}" /usr/local/src/php/xfail_tests.list
(
Expand Down
6 changes: 4 additions & 2 deletions profiling/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,10 @@ extern "C" fn minit(_type: c_int, module_number: c_int) -> ZendResult {
use std::sync::Mutex;

let fd = loop {
// SAFETY:
let result = unsafe { libc::dup(libc::STDERR_FILENO) };
// F_DUPFD_CLOEXEC (not plain dup) so the duplicate is not inherited
// by child processes spawned via proc_open()/exec(). See logging.rs.
// SAFETY: just a libc call.
let result = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
if result != -1 {
break result;
} else {
Expand Down
6 changes: 5 additions & 1 deletion profiling/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ pub fn log_init(level_filter: LevelFilter) {
*/

// Safety: this is safe, it's just "unsafe" because it's a call into C.
let fd = unsafe { libc::dup(libc::STDERR_FILENO) };
// F_DUPFD_CLOEXEC (not plain dup) so the duplicate is not inherited by
// child processes spawned via proc_open()/exec(). A leaked stderr dup
// keeps run-tests.php worker pipes open and hangs the language tests
// (e.g. ext/curl/tests/curl_setopt_ssl.phpt spawning `openssl s_server`).
let fd = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
if fd != -1 {
// Safety: the fd is a valid and open file descriptor, and the File has sole ownership.
let target = Box::new(unsafe { File::from_raw_fd(fd) });
Expand Down
39 changes: 26 additions & 13 deletions profiling/src/profiling/thread_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,38 @@ where
let result = std::thread::Builder::new()
.name(name.to_string())
.spawn(move || {
/* Thread must not handle signals intended for PHP threads.
* See Zend/zend_signal.c for which signals it registers.
/* This helper thread has no valid PHP/TSRM context, so it must not
* run any PHP signal handler. The Zend Engine registers a fixed set
* of signals (see Zend/zend_signal.c), but a PHP script can install
* a handler for *any* signal via pcntl_signal() (e.g. SIGCHLD with
* pcntl_async_signals(true)). If such a signal is delivered to this
* thread, pcntl_signal_handler() dereferences PCNTL_G(spares) with
* no thread context and segfaults
* (see ext/pcntl/tests/waiting_on_sigchild_pcntl_wait.phpt).
*
* So block every signal here; async signals are then delivered to a
* PHP thread instead. The synchronous fault signals are left
* unblocked so a genuine fault on this thread is still reported
* (e.g. by the crashtracker) rather than masked.
*/
unsafe {
let mut sigset_mem = MaybeUninit::uninit();
let sigset = sigset_mem.as_mut_ptr();
libc::sigemptyset(sigset);

const SIGNALS: [libc::c_int; 6] = [
libc::SIGPROF, // todo: SIGALRM on __CYGWIN__/__PHASE__
libc::SIGHUP,
libc::SIGINT,
libc::SIGTERM,
libc::SIGUSR1,
libc::SIGUSR2,
libc::sigfillset(sigset);

// Hardware/synchronous fault signals: keep them deliverable to
// this thread.
const KEEP_UNBLOCKED: [libc::c_int; 6] = [
libc::SIGSEGV,
libc::SIGBUS,
libc::SIGFPE,
libc::SIGILL,
libc::SIGABRT,
libc::SIGTRAP,
];

for signal in SIGNALS {
libc::sigaddset(sigset, signal);
for signal in KEEP_UNBLOCKED {
libc::sigdelset(sigset, signal);
}
libc::pthread_sigmask(libc::SIG_BLOCK, sigset, std::ptr::null_mut());
}
Expand Down
19 changes: 15 additions & 4 deletions profiling/src/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,23 @@ unsafe extern "C" fn ddog_php_prof_zend_error_observer(
return;
}

// The engine passes a NULL file for fatal errors with no active file
// location, e.g. an uncaught exception reported via zend_exception_error
// (`zend_error_va(..., file=NULL, ...)`). CStr::from_ptr(NULL) would
// strlen(NULL) and segfault, so guard against it. See
// Zend/tests/bug50005.phpt and Zend/tests/bug64821.3.phpt.
#[cfg(zend_error_observer_80)]
let filename_str = unsafe { core::ffi::CStr::from_ptr(file) };
let filename = if file.is_null() {
String::new()
} else {
unsafe { core::ffi::CStr::from_ptr(file) }
.to_string_lossy()
.into_owned()
};
#[cfg(not(zend_error_observer_80))]
let filename_str = unsafe { zai_str_from_zstr(file.as_mut()) };

let filename = filename_str.to_string_lossy().into_owned();
let filename = unsafe { zai_str_from_zstr(file.as_mut()) }
.to_string_lossy()
.into_owned();

let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
if let Some(profiler) = Profiler::get() {
Expand Down
43 changes: 43 additions & 0 deletions profiling/tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# PHP language test xfail lists

The profiler's "PHP language tests" CI job runs the upstream PHP test suite
with the profiling extension loaded, for every supported PHP version and for
both the `nts` and `zts` builds. These lists exclude tests that cannot pass in
that environment for reasons unrelated to profiler correctness.

`.gitlab/run_php_language_tests.sh` **deletes** every `.phpt` named in
`XFAIL_LIST` before running, so listing a test means "do not run" it.

| File | Applies to |
|------|------------|
| `php-language-xfail.list` | all profiler runs (`nts` + `zts`, all versions) |
| `php-language-xfail-pre84.list` | PHP < 8.4 (appended by the job via a version check) |

## `php-language-xfail.list` (all versions)

Fail with the profiler loaded regardless of version/flavour:

- `ext/ffi/tests/list.phpt` — aborts (`free(): invalid size`); allocation
profiler conflicts with the test's FFI memory management.
- `Zend/tests/concat_003.phpt` — perf-sensitive (2 s budget); allocation
profiling overhead can exceed it on CI runners.
- `ext/session/tests/bug60634.phpt` (also under `user_session_module/` on some
versions; both paths are listed) — `die()` inside a session save handler.
Fails intermittently in the parallel run with "Cannot call session save
handler in a recursive manner". Not a profiler issue: it passes in isolation
with the profiler enabled; it's a concurrency/session-save-path collision in
the 64-worker run. Listed because it is flaky under parallelism.

## `php-language-xfail-pre84.list` (PHP < 8.4)

`php-language-xfail-pre84.list` contains opcache optimizer-output tests that
fail only with the profiler on PHP ≤ 8.3. On PHP < 8.4 the profiler overrides
`zend_execute_internal` (to handle VM interrupts while an internal function is
on the stack); on 8.4+ that hook is not installed (frameless calls), so these
pass. Internal calls therefore compile to `DO_FCALL` instead of `DO_ICALL`,
changing the optimized opcodes.

- `opt/prop_types.phpt`, `opt/gh11170.phpt`, `opt/nullsafe_002.phpt` — cosmetic
opcode-dump differences (`DO_ICALL` → `DO_FCALL`).
- `bug66251.phpt` — same `< 8.4` condition: with the execute hook installed,
opcache folds a same-file runtime constant that should stay dynamic.
4 changes: 4 additions & 0 deletions profiling/tests/php-language-xfail-pre84.list
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ext/opcache/tests/opt/prop_types.phpt
ext/opcache/tests/opt/gh11170.phpt
ext/opcache/tests/opt/nullsafe_002.phpt
ext/opcache/tests/bug66251.phpt
4 changes: 4 additions & 0 deletions profiling/tests/php-language-xfail.list
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Zend/tests/concat_003.phpt
ext/ffi/tests/list.phpt
ext/session/tests/bug60634.phpt
ext/session/tests/user_session_module/bug60634.phpt
77 changes: 77 additions & 0 deletions profiling/tests/phpt/pcntl_async_signal_helper_thread.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
--TEST--
[profiling] async PHP signal handlers must not run on profiler helper threads
--DESCRIPTION--
Regression test for a crash when a PHP script installs an async signal handler
(pcntl_async_signals(true) + pcntl_signal()) for a signal the Zend Engine does
not itself register, e.g. SIGCHLD.

The profiler's helper threads (`ddprof_time`, `ddprof_upload`) only masked the
fixed set of signals the Zend Engine uses, so the kernel could deliver SIGCHLD
to one of them. pcntl's handler then ran on a thread with no valid PHP/TSRM
context and dereferenced the thread-local PCNTL_G, segfaulting. The fix is to
block every (non-fault) signal on the helper threads so async signals are
delivered to a PHP thread.

Only crashes on ZTS, where PCNTL_G is thread-local. Observed on PHP 8.4 ZTS,
reproduced by ext/pcntl/tests/waiting_on_sigchild_pcntl_wait.phpt:

Thread 2 "ddprof_time" received signal SIGSEGV, Segmentation fault.
#0 pcntl_signal_handler (signo=17, ...) at ext/pcntl/pcntl.c:1289
1289 struct php_pcntl_pending_signal *psig = PCNTL_G(spares);
#1 <signal handler called>
#2 syscall ()
#3 std::sys::pal::unix::futex::futex_wait ()
#4 std::sys::sync::thread_parking::futex::Parker::park_timeout ()
...
#15 run () at profiling/src/profiling/uploader.rs:157
#16 {closure#3} () at profiling/src/profiling/mod.rs:898
#17 ... at profiling/src/profiling/thread_utils.rs:45
--SKIPIF--
<?php
foreach (['datadog-profiling', 'pcntl'] as $extension)
if (!extension_loaded($extension))
echo "skip: test requires {$extension}\n";
if (!ZEND_THREAD_SAFE)
echo "skip: ZTS only (the crash is a thread-local PCNTL_G access from a helper thread)\n";
if (PHP_OS_FAMILY !== 'Linux')
echo "skip: Linux only\n";
if (getenv('SKIP_ASAN'))
die('skip: the profiler leaks on purpose in child of a fork');
?>
--ENV--
DD_PROFILING_ENABLED=yes
DD_PROFILING_LOG_LEVEL=off
--FILE--
<?php

pcntl_async_signals(true);

$processes = [];
pcntl_signal(SIGCHLD, function () use (&$processes) {
while (($pid = pcntl_wait($status, WUNTRACED | WNOHANG)) > 0) {
unset($processes[$pid]);
}
});

// Spawn bursts of short-lived children. They exit at roughly the same time, so
// a burst of SIGCHLD arrives while the main thread is idle in usleep(). If the
// profiler's helper threads do not block SIGCHLD the kernel may deliver it to
// one of them, where pcntl's handler runs without a PHP/TSRM context and
// crashes. Several rounds widen the (timing-dependent) race window.
for ($round = 0; $round < 4; $round++) {
for ($i = 0; $i < 8; $i++) {
$proc = proc_open('sleep 0.3', [], $pipes);
if ($proc !== false) {
$processes[proc_get_status($proc)['pid']] = $proc;
}
}
$iters = 50;
while (!empty($processes) && $iters-- > 0) {
usleep(100000);
}
}

echo empty($processes) ? "OK\n" : "leftover children\n";
?>
--EXPECT--
OK
46 changes: 46 additions & 0 deletions profiling/tests/phpt/timeline_fatal_error_null_filename.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
--TEST--
[profiling] fatal error with a NULL file location must not crash the timeline error observer
--DESCRIPTION--
Regression test for a NULL pointer dereference in the timeline error observer.

When an uncaught exception whose `file` property is NULL is reported, the
engine calls the error notification with file=NULL (location "Unknown"). The
profiler's timeline error observer used to call CStr::from_ptr(NULL) (PHP 8.0,
where the observer receives a raw C string), which segfaulted in strlen().

Reproduces the upstream Zend/tests/bug50005.phpt and bug64821.3.phpt crashes
that only triggered with the profiler loaded and timeline enabled.
--SKIPIF--
<?php
if (!extension_loaded('datadog-profiling'))
echo "skip: test requires Datadog Continuous Profiler\n";
// The crash is specific to PHP 8.0: the error observer receives a raw C
// string there (NULL-unsafe), while 8.1+ gets a NULL-safe zend_string. Also,
// Exception::$file became a typed `string` in 8.1, so `$this->file = null`
// throws a TypeError and can no longer produce a NULL-file fatal at all.
if (PHP_VERSION_ID < 80000 || PHP_VERSION_ID >= 80100)
echo "skip: NULL-file fatal error observer crash is specific to PHP 8.0\n";
?>
--ENV--
DD_PROFILING_ENABLED=yes
DD_PROFILING_TIMELINE_ENABLED=yes
DD_PROFILING_ALLOCATION_ENABLED=no
DD_PROFILING_EXCEPTION_ENABLED=no
DD_PROFILING_LOG_LEVEL=off
--FILE--
<?php

class a extends exception {
public function __construct() {
$this->file = null;
}
}

throw new a;

?>
--EXPECTF--
Fatal error: Uncaught a in :%d
Stack trace:
#0 {main}
thrown in Unknown on line %d
Loading