From 0d5479bcddea0ca36e5ad1af371424e568bd4899 Mon Sep 17 00:00:00 2001 From: Giandonn Date: Fri, 2 Oct 2026 21:09:13 +0000 Subject: [PATCH] ci: run the compiler PHPT suite under UBSan The compiler rejects every statically detectable undefined operation, but undefined behavior that only appears at runtime in the generated C++ (a shift count >= 64, a zero divisor, signed overflow, misaligned access, ...) is silently compiled into whatever the CPU happens to do. tpc already supports --sanitize, but nothing in CI used it. run-tests.php gains --sanitize : every test binary is compiled with the given sanitizers, and UBSAN_OPTIONS=halt_on_error=1 turns a report into a failing test instead of a line in its output. The option travels to the parallel workers with the other globals. The Linux PHPT job gets one more matrix entry, PHP 8.5 with sanitize=undefined. The current suite is clean under UBSan (1247 passed, 27 skipped locally), so the job starts green and guards against new undefined behavior in generated code. Release packaging still comes only from the unsanitized entries. --- .github/workflows/linux-x64.yml | 19 ++++++++++++----- run-tests.php | 36 ++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/.github/workflows/linux-x64.yml b/.github/workflows/linux-x64.yml index 1908e4b0..fd4bd87f 100644 --- a/.github/workflows/linux-x64.yml +++ b/.github/workflows/linux-x64.yml @@ -368,7 +368,7 @@ jobs: path: build/integration-* phpt: - name: PHPT - PHP ${{ matrix.php }} ZTS + name: PHPT - PHP ${{ matrix.php }} ZTS${{ matrix.sanitize && format(' ({0} sanitizer)', matrix.sanitize) || '' }} if: ${{ startsWith(github.ref, 'refs/tags/') || !contains(github.event.head_commit.message || '', '--skip-tests') }} runs-on: ubuntu-22.04 timeout-minutes: 180 @@ -377,6 +377,14 @@ jobs: fail-fast: false matrix: php: ["8.4", "8.5"] + # Empty means no sanitizer. The include below overrides it, so GitHub + # adds a separate job instead of merging into the PHP 8.5 one. + sanitize: [""] + include: + # Undefined behavior in the generated C++ (division by zero, shift + # counts, signed overflow, misaligned access...) aborts the test binary. + - php: "8.5" + sanitize: undefined env: PHPX_HOME: ${{ github.workspace }}/third_party/phpx NO_INTERACTION: 1 @@ -489,6 +497,7 @@ jobs: mkdir -p build/phpt-metrics .github/scripts/observe-command.sh build/phpt-metrics -- \ php run-tests.php -q -j8 --compiler .github/scripts/observe-tpc.sh \ + ${{ matrix.sanitize && format('--sanitize {0}', matrix.sanitize) || '' }} \ -w build/failed-tests.txt -W build/test-results.txt tests/compiler - name: Summarize PHPT compiler metrics @@ -503,13 +512,13 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: phpt-metrics-linux-x64-php-${{ matrix.php }}-zts + name: phpt-metrics-linux-x64-php-${{ matrix.php }}-zts${{ matrix.sanitize && format('-{0}', matrix.sanitize) || '' }} if-no-files-found: warn retention-days: 14 path: build/phpt-metrics - name: Package tested Linux compiler - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') && !matrix.sanitize shell: bash run: | composer install --no-dev --prefer-dist --no-progress --classmap-authoritative @@ -521,7 +530,7 @@ jobs: test "$(find . -maxdepth 1 -name 'tpc_v*_linux_x64_php${{ matrix.php }}.*-zts.tar.gz' -type f | wc -l)" -eq 1 - name: Upload Linux release package - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') && !matrix.sanitize uses: actions/upload-artifact@v4 with: name: release-linux-x64-php-${{ matrix.php }}-zts @@ -533,7 +542,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: phpt-failures-linux-x64-php-${{ matrix.php }}-zts + name: phpt-failures-linux-x64-php-${{ matrix.php }}-zts${{ matrix.sanitize && format('-{0}', matrix.sanitize) || '' }} if-no-files-found: ignore retention-days: 7 path: | diff --git a/run-tests.php b/run-tests.php index e5cbc226..cb92314e 100755 --- a/run-tests.php +++ b/run-tests.php @@ -132,6 +132,11 @@ function show_usage(): void --no-aot Run tests without AOT compilation (plain PHP mode). + --sanitize + Compile every test binary with the given sanitizers (passed to + the compiler as --sanitize), e.g. undefined or address,undefined. + A sanitizer report aborts the binary, so the test fails. + --compiler Use specified compiler binary (default: ./bin/tpc.php). For bootstrap testing, use: --compiler ./tpc @@ -169,7 +174,7 @@ function main(): void $temp_source, $temp_target, $test_cnt, $test_files, $test_idx, $test_results, $testfile, $valgrind, $sum_results, $shuffle, $file_cache, $num_repeats, - $show_progress, $aot_parallel_root, $test_target; + $show_progress, $aot_parallel_root, $test_target, $aot_sanitize; // Parallel testing global $workers, $workerID; global $context_line_count; @@ -610,6 +615,15 @@ function main(): void . ':print_suppressions=0'; } break; + case '--sanitize': + $aot_sanitize = parse_sanitize_option($argv[++$i] ?? ''); + // A report must fail the test instead of scrolling past in its output. + $environment['UBSAN_OPTIONS'] = 'halt_on_error=1:abort_on_error=0:print_stacktrace=1'; + if (in_array('address', $aot_sanitize, true)) { + $environment['ASAN_OPTIONS'] = 'halt_on_error=1:detect_leaks=0'; + $environment['SKIP_ASAN'] = 1; + } + break; case '--repeat': $num_repeats = (int) $argv[++$i]; $environment['SKIP_REPEAT'] = 1; @@ -2497,6 +2511,10 @@ function run_test(string $php, $file, array $env): string if (!$no_aot) { try { $aot_args = $test->hasSection('AOT_ARGS') ? trim($test->getSection('AOT_ARGS')) : ''; + global $aot_sanitize; + if (!empty($aot_sanitize)) { + $aot_args = trim($aot_args . ' --sanitize ' . implode(',', $aot_sanitize)); + } if ($test_target === 'native') { $bin_file = compile_php_file($test_file, $aot_args); } else { @@ -4534,6 +4552,22 @@ function normalize_wasm_test_output(string $output): string return str_replace("\r\n", "\n", trim($output)); } +/** + * Parse --sanitize: a comma-separated subset of the sanitizers the compiler supports. + * + * @return list + */ +function parse_sanitize_option(string $value): array +{ + $sanitizers = array_values(array_unique(array_filter(array_map('trim', explode(',', $value))))); + $unknown = array_diff($sanitizers, ['address', 'undefined']); + if ($sanitizers === [] || $unknown !== []) { + fwrite(STDERR, "--sanitize expects address and/or undefined, got: " . $value . "\n"); + exit(1); + } + return $sanitizers; +} + function compile_php_file(string $file, string $compiler_args = ''): string { global $compiler_path, $workerID, $aot_parallel_root;