Skip to content

Commit 6b35d5b

Browse files
fix(prof): Add running PHP language tests for profiler and fix discovered bugs (#3364)
1 parent ba2b056 commit 6b35d5b

12 files changed

Lines changed: 319 additions & 24 deletions

.github/workflows/prof_asan.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ jobs:
127127
cargo build --profile profiler-release
128128
cp -v "$CARGO_TARGET_DIR/profiler-release/libdatadog_php_profiling.so" "$(php-config --extension-dir)/datadog-profiling.so"
129129
130+
# TODO(parallel): the php-8.5_bookworm-8 image ships parallel 1.2.13, which
131+
# has a bug that intermittently trips UBSAN. Install the fixed 1.2.14 over
132+
# it (ZTS-only; parallel requires ZTS). Remove this step once the CI images
133+
# are rebuilt with parallel >= 1.2.14.
134+
- name: Install fixed parallel 1.2.14 (ZTS only, temporary until images rebuilt)
135+
if: matrix.php-build == 'zts'
136+
run: |
137+
set -eux
138+
switch-php zts
139+
scan_dir="$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')"
140+
# pecl refuses to reinstall while the extension is loaded, so move its
141+
# ini aside during the build, then restore it so the test run loads the
142+
# freshly installed parallel.so. Use the direct package URL because the
143+
# channel REST cache in the image can lag behind new releases.
144+
mv "$scan_dir/parallel.ini" /tmp/parallel.ini.disabled
145+
yes '' | pecl install -f https://pecl.php.net/get/parallel-1.2.14.tgz
146+
mv /tmp/parallel.ini.disabled "$scan_dir/parallel.ini"
147+
php --ri parallel | grep -i version
148+
130149
- name: Run phpt tests
131150
run: |
132151
set -eux

.gitlab/generate-profiler.php

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
}
1414
?>
1515

16+
# PHP 8.5 has a known tailcall VM crash; re-enable once PHP 8.5.8 is available.
17+
.php_language_profiler_targets: &php_language_profiler_targets
18+
<?php
19+
foreach ($profiler_minor_major_targets as $version) {
20+
if (version_compare($version, "8.5", "<")) {
21+
echo " - \"{$version}\"\n";
22+
}
23+
}
24+
?>
25+
1626
"profiling tests":
1727
stage: test
1828
tags: [ "arch:${ARCH}" ]
@@ -44,6 +54,8 @@
4454
script:
4555
- if [ -d '/opt/rh/devtoolset-7' ]; then set +eo pipefail; source scl_source enable devtoolset-7; set -eo pipefail; fi
4656
- if [ -d '/opt/rh/devtoolset-7' ] && [ "$(uname -m)" = "aarch64" ]; then export BINDGEN_EXTRA_CLANG_ARGS="-I$(clang --print-resource-dir)/include"; fi
57+
- if [ -f /sbin/apk ] && [ $(uname -m) = "aarch64" ]; then ln -sf ../lib/llvm17/bin/clang /usr/bin/clang; fi
58+
- export DD_PROFILING_OUTPUT_PPROF=/tmp/
4759

4860
- cd profiling
4961
- 'echo "nproc: $(nproc)"'
@@ -86,8 +98,13 @@
8698
image: registry.ddbuild.io/images/mirror/datadog/dd-trace-ci:php-${PHP_MAJOR_MINOR}_bookworm-8
8799
variables:
88100
KUBERNETES_CPU_REQUEST: 5
101+
KUBERNETES_CPU_LIMIT: 5
89102
KUBERNETES_MEMORY_REQUEST: 3Gi
90-
KUBERNETES_MEMORY_LIMIT: 4Gi
103+
KUBERNETES_MEMORY_LIMIT: 3Gi
104+
KUBERNETES_HELPER_CPU_REQUEST: 1
105+
KUBERNETES_HELPER_CPU_LIMIT: 1
106+
KUBERNETES_HELPER_MEMORY_REQUEST: 2Gi
107+
KUBERNETES_HELPER_MEMORY_LIMIT: 2Gi
91108
# CARGO_TARGET_DIR: /mnt/ramdisk/cargo # ramdisk??
92109
libdir: /tmp/datadog-profiling
93110
parallel:
@@ -105,11 +122,56 @@
105122
image: registry.ddbuild.io/images/mirror/datadog/dd-trace-ci:php-8.5_bookworm-8
106123
variables:
107124
KUBERNETES_CPU_REQUEST: 5
125+
KUBERNETES_CPU_LIMIT: 5
108126
KUBERNETES_MEMORY_REQUEST: 3Gi
109-
KUBERNETES_MEMORY_LIMIT: 4Gi
127+
KUBERNETES_MEMORY_LIMIT: 3Gi
128+
KUBERNETES_HELPER_CPU_REQUEST: 1
129+
KUBERNETES_HELPER_CPU_LIMIT: 1
130+
KUBERNETES_HELPER_MEMORY_REQUEST: 2Gi
131+
KUBERNETES_HELPER_MEMORY_LIMIT: 2Gi
110132
# CARGO_TARGET_DIR: /mnt/ramdisk/cargo # ramdisk??
111133
libdir: /tmp/datadog-profiling
112134
script:
113135
- switch-php nts # not compatible with debug
114136
- cd profiling
115137
- cargo test --all-features
138+
139+
"PHP language tests":
140+
stage: test
141+
tags: [ "arch:${ARCH}" ]
142+
image: registry.ddbuild.io/images/mirror/datadog/dd-trace-ci:php-${PHP_MAJOR_MINOR}_bookworm-8
143+
variables:
144+
KUBERNETES_CPU_REQUEST: 5
145+
KUBERNETES_CPU_LIMIT: 5
146+
KUBERNETES_MEMORY_REQUEST: 3Gi
147+
KUBERNETES_MEMORY_LIMIT: 3Gi
148+
KUBERNETES_HELPER_CPU_REQUEST: 1
149+
KUBERNETES_HELPER_CPU_LIMIT: 1
150+
KUBERNETES_HELPER_MEMORY_REQUEST: 2Gi
151+
KUBERNETES_HELPER_MEMORY_LIMIT: 2Gi
152+
CARGO_TARGET_DIR: /tmp/cargo
153+
libdir: /tmp/datadog-profiling
154+
SKIP_ONLINE_TESTS: "1"
155+
REPORT_EXIT_STATUS: "1"
156+
DD_PROFILING_OUTPUT_PPROF: /tmp/
157+
XFAIL_LIST: dockerfiles/ci/xfail_tests/${PHP_MAJOR_MINOR}.list
158+
parallel:
159+
matrix:
160+
- PHP_MAJOR_MINOR: *php_language_profiler_targets
161+
ARCH: amd64
162+
FLAVOUR: [nts, zts]
163+
script:
164+
- unset DD_SERVICE; unset DD_ENV
165+
- command -v switch-php && switch-php "${FLAVOUR}"
166+
- cd profiling
167+
- cargo build --profile profiler-release
168+
- cd ..
169+
- echo "extension=/tmp/cargo/profiler-release/libdatadog_php_profiling.so" > /opt/php/${FLAVOUR}/conf.d/profiling.ini
170+
- php -v
171+
# Fail loudly if the profiler did not load: otherwise the language tests
172+
# would run profiler-less and pass, giving a false green.
173+
- php -m | grep -qx 'datadog-profiling' || { echo 'ERROR: datadog-profiling extension is not loaded'; exit 1; }
174+
- cat "${XFAIL_LIST}" profiling/tests/php-language-xfail.list > /tmp/profiler-php-language-xfail.list
175+
- 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
176+
- export XFAIL_LIST=/tmp/profiler-php-language-xfail.list
177+
- .gitlab/run_php_language_tests.sh

.gitlab/run_php_language_tests.sh

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,18 @@ set -eo pipefail
44
# Helper to parse version strings for comparison
55
function version { echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }'; }
66

7-
sudo rm -f /opt/php/debug/conf.d/memcached.ini
8-
sudo rm -f /opt/php/debug/conf.d/rdkafka.ini
7+
# Remove extensions whose mere presence pollutes test output or output
8+
# ordering:
9+
# - rdkafka emits CONFWARN lines, breaking Zend/tests/instantiate_all_classes.phpt
10+
# - parallel (ZTS-only) defers 'PHP Startup' diagnostics until after the
11+
# script's first output, breaking ~56 deprecation/warning-ordering tests
12+
# Derive the scan dir from the active PHP so this works for every build
13+
# variant (debug for the tracer job, nts/zts for the profiler job) instead
14+
# of hardcoding the debug path.
15+
scan_dir="$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')"
16+
sudo rm -f "${scan_dir}/memcached.ini"
17+
sudo rm -f "${scan_dir}/rdkafka.ini"
18+
sudo rm -f "${scan_dir}/parallel.ini"
919
if [[ ! "${XFAIL_LIST:-none}" == "none" ]]; then
1020
cp "${XFAIL_LIST}" /usr/local/src/php/xfail_tests.list
1121
(

profiling/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,10 @@ extern "C" fn minit(_type: c_int, module_number: c_int) -> ZendResult {
247247
use std::sync::Mutex;
248248

249249
let fd = loop {
250-
// SAFETY:
251-
let result = unsafe { libc::dup(libc::STDERR_FILENO) };
250+
// F_DUPFD_CLOEXEC (not plain dup) so the duplicate is not inherited
251+
// by child processes spawned via proc_open()/exec(). See logging.rs.
252+
// SAFETY: just a libc call.
253+
let result = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
252254
if result != -1 {
253255
break result;
254256
} else {

profiling/src/logging.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ pub fn log_init(level_filter: LevelFilter) {
1212
*/
1313

1414
// Safety: this is safe, it's just "unsafe" because it's a call into C.
15-
let fd = unsafe { libc::dup(libc::STDERR_FILENO) };
15+
// F_DUPFD_CLOEXEC (not plain dup) so the duplicate is not inherited by
16+
// child processes spawned via proc_open()/exec(). A leaked stderr dup
17+
// keeps pipes open and hangs. Observable in `run-tests.php` in PHP
18+
// (e.g. ext/curl/tests/curl_setopt_ssl.phpt spawning `openssl s_server`).
19+
let fd = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
1620
if fd != -1 {
1721
// Safety: the fd is a valid and open file descriptor, and the File has sole ownership.
1822
let target = Box::new(unsafe { File::from_raw_fd(fd) });

profiling/src/profiling/thread_utils.rs

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,38 @@ where
2020
let result = std::thread::Builder::new()
2121
.name(name.to_string())
2222
.spawn(move || {
23-
/* Thread must not handle signals intended for PHP threads.
24-
* See Zend/zend_signal.c for which signals it registers.
23+
/* This helper thread has no valid PHP/TSRM context, so it must not
24+
* run any PHP signal handler. The Zend Engine registers a fixed set
25+
* of signals (see Zend/zend_signal.c), but a PHP script can install
26+
* a handler for *any* signal via pcntl_signal() (e.g. SIGCHLD with
27+
* pcntl_async_signals(true)). If such a signal is delivered to this
28+
* thread, pcntl_signal_handler() dereferences PCNTL_G(spares) with
29+
* no thread context and segfaults
30+
* (see ext/pcntl/tests/waiting_on_sigchild_pcntl_wait.phpt).
31+
*
32+
* So block every signal here; async signals are then delivered to a
33+
* PHP thread instead. The synchronous fault signals are left
34+
* unblocked so a genuine fault on this thread is still reported
35+
* (e.g. by the crashtracker) rather than masked.
2536
*/
2637
unsafe {
2738
let mut sigset_mem = MaybeUninit::uninit();
2839
let sigset = sigset_mem.as_mut_ptr();
29-
libc::sigemptyset(sigset);
30-
31-
const SIGNALS: [libc::c_int; 6] = [
32-
libc::SIGPROF, // todo: SIGALRM on __CYGWIN__/__PHASE__
33-
libc::SIGHUP,
34-
libc::SIGINT,
35-
libc::SIGTERM,
36-
libc::SIGUSR1,
37-
libc::SIGUSR2,
40+
libc::sigfillset(sigset);
41+
42+
// Hardware/synchronous fault signals: keep them deliverable to
43+
// this thread.
44+
const KEEP_UNBLOCKED: [libc::c_int; 6] = [
45+
libc::SIGSEGV,
46+
libc::SIGBUS,
47+
libc::SIGFPE,
48+
libc::SIGILL,
49+
libc::SIGABRT,
50+
libc::SIGTRAP,
3851
];
3952

40-
for signal in SIGNALS {
41-
libc::sigaddset(sigset, signal);
53+
for signal in KEEP_UNBLOCKED {
54+
libc::sigdelset(sigset, signal);
4255
}
4356
libc::pthread_sigmask(libc::SIG_BLOCK, sigset, std::ptr::null_mut());
4457
}

profiling/src/timeline.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,23 @@ unsafe extern "C" fn ddog_php_prof_zend_error_observer(
245245
return;
246246
}
247247

248+
// The engine passes a NULL file for fatal errors with no active file
249+
// location, e.g. an uncaught exception reported via zend_exception_error
250+
// (`zend_error_va(..., file=NULL, ...)`). CStr::from_ptr(NULL) would
251+
// strlen(NULL) and segfault, so guard against it. See
252+
// Zend/tests/bug50005.phpt and Zend/tests/bug64821.3.phpt.
248253
#[cfg(zend_error_observer_80)]
249-
let filename_str = unsafe { core::ffi::CStr::from_ptr(file) };
254+
let filename = if file.is_null() {
255+
String::new()
256+
} else {
257+
unsafe { core::ffi::CStr::from_ptr(file) }
258+
.to_string_lossy()
259+
.into_owned()
260+
};
250261
#[cfg(not(zend_error_observer_80))]
251-
let filename_str = unsafe { zai_str_from_zstr(file.as_mut()) };
252-
253-
let filename = filename_str.to_string_lossy().into_owned();
262+
let filename = unsafe { zai_str_from_zstr(file.as_mut()) }
263+
.to_string_lossy()
264+
.into_owned();
254265

255266
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
256267
if let Some(profiler) = Profiler::get() {

profiling/tests/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# PHP language test xfail lists
2+
3+
The profiler's "PHP language tests" CI job runs the upstream PHP test suite
4+
with the profiling extension loaded, for every supported PHP version and for
5+
both the `nts` and `zts` builds. These lists exclude tests that cannot pass in
6+
that environment for reasons unrelated to profiler correctness.
7+
8+
`.gitlab/run_php_language_tests.sh` **deletes** every `.phpt` named in
9+
`XFAIL_LIST` before running, so listing a test means "do not run" it.
10+
11+
| File | Applies to |
12+
|------|------------|
13+
| `php-language-xfail.list` | all profiler runs (`nts` + `zts`, all versions) |
14+
| `php-language-xfail-pre84.list` | PHP < 8.4 (appended by the job via a version check) |
15+
16+
## `php-language-xfail.list` (all versions)
17+
18+
Fail with the profiler loaded regardless of version/flavour:
19+
20+
- `ext/ffi/tests/list.phpt` — aborts (`free(): invalid size`); allocation
21+
profiler conflicts with the test's FFI memory management.
22+
- `Zend/tests/concat_003.phpt` — perf-sensitive (2 s budget); allocation
23+
profiling overhead can exceed it on CI runners.
24+
- `ext/session/tests/bug60634.phpt` (also under `user_session_module/` on some
25+
versions; both paths are listed) — `die()` inside a session save handler.
26+
Fails intermittently in the parallel run with "Cannot call session save
27+
handler in a recursive manner". Not a profiler issue: it passes in isolation
28+
with the profiler enabled; it's a concurrency/session-save-path collision in
29+
the 64-worker run. Listed because it is flaky under parallelism.
30+
31+
## `php-language-xfail-pre84.list` (PHP < 8.4)
32+
33+
`php-language-xfail-pre84.list` contains opcache optimizer-output tests that
34+
fail only with the profiler on PHP ≤ 8.3. On PHP < 8.4 the profiler overrides
35+
`zend_execute_internal` (to handle VM interrupts while an internal function is
36+
on the stack); on 8.4+ that hook is not installed (frameless calls), so these
37+
pass. Internal calls therefore compile to `DO_FCALL` instead of `DO_ICALL`,
38+
changing the optimized opcodes.
39+
40+
- `opt/prop_types.phpt`, `opt/gh11170.phpt`, `opt/nullsafe_002.phpt` — cosmetic
41+
opcode-dump differences (`DO_ICALL``DO_FCALL`).
42+
- `bug66251.phpt` — same `< 8.4` condition: with the execute hook installed,
43+
opcache folds a same-file runtime constant that should stay dynamic.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ext/opcache/tests/opt/prop_types.phpt
2+
ext/opcache/tests/opt/gh11170.phpt
3+
ext/opcache/tests/opt/nullsafe_002.phpt
4+
ext/opcache/tests/bug66251.phpt
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Zend/tests/concat_003.phpt
2+
ext/ffi/tests/list.phpt
3+
ext/session/tests/bug60634.phpt
4+
ext/session/tests/user_session_module/bug60634.phpt

0 commit comments

Comments
 (0)