Commit 2b6075bddf9 for php.net

commit 2b6075bddf9b5963b79dd79192fd51bbaa45e5f5
Author: NickSdot <32384907+NickSdot@users.noreply.github.com>
Date:   Mon Aug 3 23:17:27 2026 +0700

    run-tests: run test subprocesses without a shell where possible (#22957)

diff --git a/NEWS b/NEWS
index 1af715a1bc7..1f3011b8e61 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,10 @@ PHP                                                                        NEWS
 |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
 ?? ??? ????, PHP 8.6.0beta1

+- Core:
+  . Changed run-tests.php to run test subprocesses without a shell where
+    possible. (NickSdot)
+
 - GMP:
   . Added optional $definitely_prime output parameter to gmp_prevprime().
     (Weilin Du)
diff --git a/run-tests.php b/run-tests.php
index 998e7e24c33..703dd643ca7 100755
--- a/run-tests.php
+++ b/run-tests.php
@@ -148,7 +148,7 @@ function main(): void
            $exts_skipped, $exts_tested, $exts_to_test, $failed_tests_file,
            $ignored_by_ext, $ini_overwrites, $colorize,
            $log_format, $no_clean, $no_file_cache,
-           $pass_options, $php, $php_cgi, $preload,
+           $pass_option_args, $php, $php_cgi, $preload,
            $result_tests_file, $slow_min_ms, $start_time,
            $temp_source, $temp_target, $test_cnt,
            $test_files, $test_idx, $test_results, $testfile,
@@ -306,7 +306,10 @@ function main(): void
         'date.timezone=UTC',
     ];

-    $no_file_cache = '-d opcache.file_cache= -d opcache.file_cache_only=0';
+    $no_file_cache = [
+        '-d', 'opcache.file_cache=',
+        '-d', 'opcache.file_cache_only=0',
+    ];

     // Determine the tests to be run.

@@ -327,7 +330,7 @@ function main(): void
     $result_tests_file = false;
     $failed_tests_file = false;
     $pass_option_n = false;
-    $pass_options = '';
+    $pass_option_args = [];

     $output_file = INIT_DIR . '/php_test_results_' . date('Ymd_Hi') . '.txt';

@@ -471,12 +474,12 @@ function main(): void
                     break;
                 case 'n':
                     if (!$pass_option_n) {
-                        $pass_options .= ' -n';
+                        $pass_option_args[] = '-n';
                     }
                     $pass_option_n = true;
                     break;
                 case 'e':
-                    $pass_options .= ' -e';
+                    $pass_option_args[] = '-e';
                     break;
                 case '--preload':
                     $preload = true;
@@ -681,9 +684,12 @@ function main(): void

     if ($conf_passed !== null) {
         if (IS_WINDOWS) {
-            $pass_options .= " -c " . escapeshellarg($conf_passed);
+            $pass_option_args[] = '-c';
+            $pass_option_args[] = $conf_passed;
         } else {
-            $pass_options .= " -c '" . realpath($conf_passed) . "'";
+            $configuration_file = realpath($conf_passed);
+            $pass_option_args[] = '-c';
+            $pass_option_args[] = (string) $configuration_file;
         }
     }

@@ -816,8 +822,10 @@ function verify_config(string $php): void
  */
 function write_information(array $user_tests, $phpdbg): void
 {
-    global $php, $php_cgi, $php_info, $ini_overwrites, $pass_options, $exts_to_test, $valgrind, $no_file_cache;
+    global $php, $php_cgi, $php_info, $ini_overwrites, $pass_option_args, $exts_to_test, $valgrind, $no_file_cache;
     $php_escaped = escapeshellarg($php);
+    $escaped_pass_options = escaped_shell_string_from($pass_option_args);
+    $escaped_no_file_cache = escaped_shell_string_from($no_file_cache);

     // Get info from php
     $info_file = __DIR__ . '/run-test-info.php';
@@ -833,12 +841,12 @@ function write_information(array $user_tests, $phpdbg): void
     $info_params = [];
     settings2array($ini_overwrites, $info_params);
     $info_params = settings2params($info_params);
-    $php_info = shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\"");
+    $php_info = shell_exec("$php_escaped $escaped_pass_options $info_params $escaped_no_file_cache \"$info_file\"");
     define('TESTED_PHP_VERSION', shell_exec("$php_escaped -n -r \"echo PHP_VERSION;\""));

     if ($php_cgi && $php != $php_cgi) {
         $php_cgi_escaped = escapeshellarg($php_cgi);
-        $php_info_cgi = shell_exec("$php_cgi_escaped $pass_options $info_params $no_file_cache -q \"$info_file\"");
+        $php_info_cgi = shell_exec("$php_cgi_escaped $escaped_pass_options $info_params $escaped_no_file_cache -q \"$info_file\"");
         $php_info_sep = "\n---------------------------------------------------------------------";
         $php_cgi_info = "$php_info_sep\nPHP         : $php_cgi $php_info_cgi$php_info_sep";
     } else {
@@ -847,7 +855,7 @@ function write_information(array $user_tests, $phpdbg): void

     if ($phpdbg) {
         $phpdbg_escaped = escapeshellarg($phpdbg);
-        $phpdbg_info = shell_exec("$phpdbg_escaped $pass_options $info_params $no_file_cache -qrr \"$info_file\"");
+        $phpdbg_info = shell_exec("$phpdbg_escaped $escaped_pass_options $info_params $escaped_no_file_cache -qrr \"$info_file\"");
         $php_info_sep = "\n---------------------------------------------------------------------";
         $phpdbg_info = "$php_info_sep\nPHP         : $phpdbg $phpdbg_info$php_info_sep";
     } else {
@@ -873,7 +881,7 @@ function write_information(array $user_tests, $phpdbg): void
         }
         echo implode(',', $exts);
         PHP);
-    $extensionsNames = explode(',', shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\""));
+    $extensionsNames = explode(',', shell_exec("$php_escaped $escaped_pass_options $info_params $escaped_no_file_cache \"$info_file\""));
     $exts_to_test = array_unique(remap_loaded_extensions_names($extensionsNames));
     // check for extensions that need special handling and regenerate
     $info_params_ex = [
@@ -1173,20 +1181,22 @@ function error_report(string $testname, string $logname, string $tested): void
  * @return false|string
  */
 function system_with_timeout(
-    string $commandline,
+    array|string $command,
     ?array $env = null,
     ?string $stdin = null,
+    ?string $stdinFile = null,
     bool $captureStdIn = true,
     bool $captureStdOut = true,
-    bool $captureStdErr = true
+    bool $captureStdErr = true,
+    bool $mergeStdErr = false
 ) {
     global $valgrind;

     // when proc_open cmd is passed as a string (without bypass_shell=true option) the cmd goes thru shell
     // and on Windows quotes are discarded, this is a fix to honor the quotes and allow values containing
     // spaces like '"C:\Program Files\PHP\php.exe"' to be passed as 1 argument correctly
-    if (IS_WINDOWS) {
-        $commandline = 'start "" /b /wait ' . $commandline . ' & exit';
+    if (IS_WINDOWS && is_string($command)) {
+        $command = 'start "" /b /wait ' . $command . ' & exit';
     }

     $data = '';
@@ -1197,22 +1207,26 @@ function system_with_timeout(
     }

     $descriptorspec = [];
-    if ($captureStdIn) {
+    if ($stdinFile !== null) {
+        $descriptorspec[0] = ['file', $stdinFile, 'r'];
+    } elseif ($captureStdIn) {
         $descriptorspec[0] = ['pipe', 'r'];
     }
     if ($captureStdOut) {
         $descriptorspec[1] = ['pipe', 'w'];
     }
     if ($captureStdErr) {
-        $descriptorspec[2] = ['pipe', 'w'];
+        $descriptorspec[2] = $mergeStdErr
+            ? ['redirect', 1]
+            : ['pipe', 'w'];
     }
-    $proc = proc_open($commandline, $descriptorspec, $pipes, TEST_PHP_SRCDIR, $bin_env, ['suppress_errors' => true]);
+    $proc = proc_open($command, $descriptorspec, $pipes, TEST_PHP_SRCDIR, $bin_env, ['suppress_errors' => true]);

     if (!$proc) {
         return false;
     }

-    if ($captureStdIn) {
+    if (isset($pipes[0])) {
         if (!is_null($stdin)) {
             fwrite($pipes[0], $stdin);
         }
@@ -1277,6 +1291,36 @@ function system_with_timeout(
     return $data;
 }

+function create_test_command(
+    string $php,
+    array $optionArgs,
+    array $iniSettings,
+    string $testFile,
+    int $numRepeats
+): array {
+    $command = [$php, ...$optionArgs];
+    if ($numRepeats > 1) {
+        $command[] = '--repeat';
+        $command[] = (string) $numRepeats;
+    }
+
+    return [
+        ...$command,
+        ...settings2arguments($iniSettings),
+        '-f',
+        $testFile,
+    ];
+}
+
+function create_shell_invocation(string $command): array|string
+{
+    if (IS_WINDOWS) {
+        return $command;
+    }
+
+    return ['/bin/sh', '-c', "exec $command"];
+}
+
 function run_all_tests(array $test_files, array $env, ?string $redir_tested = null): void
 {
     global $test_results, $failed_tests_file, $result_tests_file, $php, $test_idx, $file_cache, $shuffle;
@@ -1837,7 +1881,7 @@ function skip_test(string $tested, string $tested_file, string $shortname, strin
 function run_test(string $php, $file, array $env): string
 {
     global $log_format, $ini_overwrites, $PHP_FAILED_TESTS;
-    global $pass_options, $DETAILED, $IN_REDIRECT, $test_cnt, $test_idx;
+    global $pass_option_args, $DETAILED, $IN_REDIRECT, $test_cnt, $test_idx;
     global $valgrind, $temp_source, $temp_target, $cfg, $environment;
     global $no_clean;
     global $SHOW_ONLY_GROUPS;
@@ -1859,8 +1903,8 @@ function run_test(string $php, $file, array $env): string
         $skipCache = new SkipCache($enableSkipCache, $cfg['keep']['skip']);
     }

-    $php = escapeshellarg($php);
-    $orig_php = $php;
+    $php_path = $php;
+    $sapi_option_args = [];

     $retried = false;
 retry:
@@ -1918,19 +1962,14 @@ function run_test(string $php, $file, array $env): string
         $captureStdOut = true;
         $captureStdErr = true;
     }
-    if ($captureStdOut && $captureStdErr) {
-        $cmdRedirect = ' 2>&1';
-    } else {
-        $cmdRedirect = '';
-    }
-
     /* For GET/POST/PUT tests, check if cgi sapi is available and if it is, use it. */
     $uses_cgi = false;
     if ($test->isCGI()) {
         if (!$php_cgi) {
             return skip_test($tested, $tested_file, $shortname, 'CGI not available');
         }
-        $php = escapeshellarg($php_cgi) . ' -C ';
+        $php_path = $php_cgi;
+        $sapi_option_args[] = '-C';
         $uses_cgi = true;
         if ($num_repeats > 1) {
             return skip_test($tested, $tested_file, $shortname, 'CGI does not support --repeat');
@@ -1938,14 +1977,15 @@ function run_test(string $php, $file, array $env): string
     }

     /* For phpdbg tests, check if phpdbg sapi is available and if it is, use it. */
-    $extra_options = '';
+    $extra_option_args = [];
     if ($test->hasSection('PHPDBG')) {
         if (isset($phpdbg)) {
-            $php = escapeshellarg($phpdbg) . ' -qIb';
+            $php_path = $phpdbg;
+            $sapi_option_args = ['-qIb'];

             // Additional phpdbg command line options for sections that need to
             // be run straight away. For example, EXTENSIONS, SKIPIF, CLEAN.
-            $extra_options = '-rr';
+            $extra_option_args[] = '-rr';
         } else {
             return skip_test($tested, $tested_file, $shortname, 'phpdbg not available');
         }
@@ -2070,8 +2110,16 @@ function run_test(string $php, $file, array $env): string
     if ($extensions != []) {
         $ext_params = [];
         settings2array($ini_overwrites, $ext_params);
-        $ext_params = settings2params($ext_params);
-        [$ext_dir, $loaded] = $skipCache->getExtensions("$orig_php $pass_options $extra_options $ext_params $no_file_cache");
+        $ext_params = settings2arguments($ext_params);
+
+        [$ext_dir, $loaded] = $skipCache->getExtensions([
+            $php,
+            ...$pass_option_args,
+            ...$extra_option_args,
+            ...$ext_params,
+            ...$no_file_cache,
+        ]);
+
         $ext_prefix = IS_WINDOWS ? "php_" : "";
         $missing = [];
         foreach ($extensions as $req_ext) {
@@ -2099,8 +2147,7 @@ function run_test(string $php, $file, array $env): string
     //$ini_overwrites[] = 'setting=value';
     settings2array($ini_overwrites, $ini_settings);

-    $orig_ini_settings = settings2params($ini_settings);
-
+    $orig_ini_settings_args = settings2arguments($ini_settings);
     if ($file_cache !== null) {
         $ini_settings['opcache.file_cache'] = get_file_cache_dir();
         // Make sure warnings still show up on the second run.
@@ -2146,9 +2193,11 @@ function run_test(string $php, $file, array $env): string
         }
     }

+    $test_ini_settings = $ini_settings;
     $ini_settings = settings2params($ini_settings);

-    $env['TEST_PHP_EXTRA_ARGS'] = $pass_options . ' ' . $ini_settings;
+    $escaped_pass_options = escaped_shell_string_from($pass_option_args);
+    $env['TEST_PHP_EXTRA_ARGS'] = $escaped_pass_options . ' ' . $ini_settings;

     // Check if test should be skipped.
     $info = '';
@@ -2156,19 +2205,41 @@ function run_test(string $php, $file, array $env): string

     if ($test->sectionNotEmpty('SKIPIF')) {
         show_file_block('skip', $test->getSection('SKIPIF'));
-        $extra = !IS_WINDOWS ?
-            "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
+        $skip_env = $env;
+        if (!IS_WINDOWS) {
+            unset(
+                $skip_env['REQUEST_METHOD'],
+                $skip_env['QUERY_STRING'],
+                $skip_env['PATH_TRANSLATED'],
+                $skip_env['SCRIPT_FILENAME'],
+            );
+        }

         if ($valgrind) {
-            $env['USE_ZEND_ALLOC'] = '0';
-            $env['ZEND_DONT_UNLOAD_MODULES'] = 1;
+            $skip_env['USE_ZEND_ALLOC'] = '0';
+            $skip_env['ZEND_DONT_UNLOAD_MODULES'] = 1;
         }

         $junit->startTimer($shortname);

         $startTime = microtime(true);
-        $commandLine = "$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0";
-        $output = $skipCache->checkSkip($commandLine, $test->getSection('SKIPIF'), $test_skipif, $temp_skipif, $env);
+        $commandLine = [
+            $php_path,
+            ...$sapi_option_args,
+            ...$pass_option_args,
+            ...$extra_option_args,
+            '-q',
+            ...$orig_ini_settings_args,
+            '-d',
+            'opcache.file_cache=',
+            '-d',
+            'opcache.file_cache_only=0',
+            '-d',
+            'display_errors=1',
+            '-d',
+            'display_startup_errors=0',
+        ];
+        $output = $skipCache->checkSkip($commandLine, $test->getSection('SKIPIF'), $test_skipif, $temp_skipif, $skip_env);

         $time = microtime(true) - $startTime;
         $junit->stopTimer($shortname);
@@ -2338,16 +2409,18 @@ function run_test(string $php, $file, array $env): string
         $env['HTTP_COOKIE'] = '';
     }

-    $args = $test->hasSection('ARGS') ? ' -- ' . $test->getSection('ARGS') : '';
-
+    $test_option_args = [
+        ...$sapi_option_args,
+        ...$pass_option_args,
+    ];
     if ($preload && !empty($test_file)) {
         save_text($preload_filename, "<?php opcache_compile_file('$test_file');");
-        $local_pass_options = $pass_options;
-        unset($pass_options);
-        $pass_options = $local_pass_options;
-        $pass_options .= " -d opcache.preload=" . $preload_filename;
+        $test_option_args[] = '-d';
+        $test_option_args[] = "opcache.preload=$preload_filename";
     }

+    $stdin = $test->hasSection('STDIN') ? $test->getSection('STDIN') : null;
+    $request = null;
     if ($test->sectionNotEmpty('POST_RAW')) {
         $post = trim($test->getSection('POST_RAW'));
         $raw_lines = explode("\n", $post);
@@ -2378,9 +2451,6 @@ function run_test(string $php, $file, array $env): string
             $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request');
             return 'BORKED';
         }
-
-        save_text($tmp_post, $request);
-        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
     } elseif ($test->sectionNotEmpty('PUT')) {
         $post = trim($test->getSection('PUT'));
         $raw_lines = explode("\n", $post);
@@ -2409,13 +2479,9 @@ function run_test(string $php, $file, array $env): string
             $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request');
             return 'BORKED';
         }
-
-        save_text($tmp_post, $request);
-        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
     } elseif ($test->sectionNotEmpty('POST')) {
-        $post = trim($test->getSection('POST'));
-        $content_length = strlen($post);
-        save_text($tmp_post, $post);
+        $request = trim($test->getSection('POST'));
+        $content_length = strlen($request);

         $env['REQUEST_METHOD'] = 'POST';
         if (empty($env['CONTENT_TYPE'])) {
@@ -2425,48 +2491,67 @@ function run_test(string $php, $file, array $env): string
         if (empty($env['CONTENT_LENGTH'])) {
             $env['CONTENT_LENGTH'] = $content_length;
         }
-
-        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
     } elseif ($test->sectionNotEmpty('GZIP_POST')) {
-        $post = trim($test->getSection('GZIP_POST'));
-        $post = gzencode($post, 9, FORCE_GZIP);
+        $request = trim($test->getSection('GZIP_POST'));
+        $request = gzencode($request, 9, FORCE_GZIP);
         $env['HTTP_CONTENT_ENCODING'] = 'gzip';

-        save_text($tmp_post, $post);
-        $content_length = strlen($post);
+        $content_length = strlen($request);

         $env['REQUEST_METHOD'] = 'POST';
         $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
         $env['CONTENT_LENGTH'] = $content_length;
-
-        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
     } elseif ($test->sectionNotEmpty('DEFLATE_POST')) {
-        $post = trim($test->getSection('DEFLATE_POST'));
-        $post = gzcompress($post, 9);
+        $request = trim($test->getSection('DEFLATE_POST'));
+        $request = gzcompress($request, 9);
         $env['HTTP_CONTENT_ENCODING'] = 'deflate';
-        save_text($tmp_post, $post);
-        $content_length = strlen($post);
+        $content_length = strlen($request);

         $env['REQUEST_METHOD'] = 'POST';
         $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
         $env['CONTENT_LENGTH'] = $content_length;
-
-        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
     } else {
         $env['REQUEST_METHOD'] = 'GET';
         $env['CONTENT_TYPE'] = '';
         $env['CONTENT_LENGTH'] = '';
+    }

-        $repeat_option = $num_repeats > 1 ? "--repeat $num_repeats" : "";
-        $cmd = "$php $pass_options $repeat_option $ini_settings -f \"$test_file\" $args$cmdRedirect";
+    $request_file = null;
+    if ($request !== null) {
+        // A file descriptor avoids blocking while writing large request bodies to a pipe.
+        save_text($tmp_post, $request);
+        $request_file = $tmp_post;
+    }
+
+    $test_command = create_test_command(
+        $php_path,
+        $test_option_args,
+        $test_ini_settings,
+        $test_file,
+        $num_repeats,
+    );
+    $orig_cmd = escaped_shell_string_from($test_command);
+    if ($test->hasSection('ARGS')) {
+        // Preserve the existing shell parsing of the raw ARGS section.
+        $orig_cmd .= ' -- ' . $test->getSection('ARGS');
+        $test_command = create_shell_invocation($orig_cmd);
+    }
+    if ($request_file !== null) {
+        $orig_cmd .= ' < ' . escapeshellarg($request_file);
+    }
+    if ($captureStdOut && $captureStdErr) {
+        $orig_cmd .= ' 2>&1';
     }

-    $orig_cmd = $cmd;
     if ($valgrind) {
         $env['USE_ZEND_ALLOC'] = '0';
         $env['ZEND_DONT_UNLOAD_MODULES'] = 1;

-        $cmd = $valgrind->wrapCommand($cmd, $memcheck_filename, strpos($test_file, "pcre") !== false);
+        $test_command = $valgrind->wrapCommand(
+            $test_command,
+            $memcheck_filename,
+            strpos($test_file, "pcre") !== false,
+        );
     }

     if ($test->hasSection('XLEAK')) {
@@ -2489,7 +2574,7 @@ function run_test(string $php, $file, array $env): string
 REQUEST_METHOD  = " . $env['REQUEST_METHOD'] . "
 SCRIPT_FILENAME = " . $env['SCRIPT_FILENAME'] . "
 HTTP_COOKIE     = " . $env['HTTP_COOKIE'] . "
-COMMAND $cmd
+COMMAND $orig_cmd
 ";
     }

@@ -2497,8 +2582,16 @@ function run_test(string $php, $file, array $env): string
     $hrtime = hrtime();
     $startTime = $hrtime[0] * 1000000000 + $hrtime[1];

-    $stdin = $test->hasSection('STDIN') ? $test->getSection('STDIN') : null;
-    $out = system_with_timeout($cmd, $env, $stdin, $captureStdIn, $captureStdOut, $captureStdErr);
+    $out = system_with_timeout(
+        $test_command,
+        $env,
+        $stdin,
+        $request_file,
+        $captureStdIn,
+        $captureStdOut,
+        $captureStdErr,
+        $captureStdOut && $captureStdErr,
+    );

     $junit->stopTimer($shortname);
     $hrtime = hrtime();
@@ -2520,9 +2613,27 @@ function run_test(string $php, $file, array $env): string
         save_text($test_clean, trim($test->getSection('CLEAN')), $temp_clean);

         if (!$no_clean) {
-            $extra = !IS_WINDOWS ?
-                "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
-            $clean_output = system_with_timeout("$extra $orig_php $pass_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env);
+            $clean_command = [
+                $php,
+                ...$pass_option_args,
+                '-q',
+                ...$orig_ini_settings_args,
+                '-d',
+                'opcache.file_cache=',
+                '-d',
+                'opcache.file_cache_only=0',
+                $test_clean,
+            ];
+            $clean_env = $env;
+            if (!IS_WINDOWS) {
+                unset(
+                    $clean_env['REQUEST_METHOD'],
+                    $clean_env['QUERY_STRING'],
+                    $clean_env['PATH_TRANSLATED'],
+                    $clean_env['SCRIPT_FILENAME'],
+                );
+            }
+            $clean_output = system_with_timeout($clean_command, $clean_env);
         }

         if (!$cfg['keep']['clean']) {
@@ -3033,6 +3144,25 @@ function settings2params(array $ini_settings): string
     return $settings;
 }

+function settings2arguments(array $ini_settings): array
+{
+    $arguments = [];
+
+    foreach ($ini_settings as $name => $value) {
+        foreach ((array) $value as $item) {
+            $arguments[] = '-d';
+            $arguments[] = "$name=$item";
+        }
+    }
+
+    return $arguments;
+}
+
+function escaped_shell_string_from(array $arguments): string
+{
+    return implode(' ', array_map(escapeshellarg(...), $arguments));
+}
+
 function compute_summary(): void
 {
     global $n_total, $test_results, $ignored_by_ext, $sum_results, $percent_results;
@@ -3612,12 +3742,12 @@ public function __construct(bool $enable, bool $keepFile)
         $this->keepFile = $keepFile;
     }

-    public function checkSkip(string $php, string $code, string $checkFile, string $tempFile, array $env): string
+    public function checkSkip(array $command, string $code, string $checkFile, string $tempFile, array $env): string
     {
         // Extension tests frequently use something like <?php require 'skipif.inc';
         // for skip checks. This forces us to cache per directory to avoid pollution.
         $dir = dirname($checkFile);
-        $key = "$php => $dir";
+        $key = implode("\0", $command) . " => $dir";

         if (isset($this->skips[$key][$code])) {
             $this->hits++;
@@ -3628,7 +3758,8 @@ public function checkSkip(string $php, string $code, string $checkFile, string $
         }

         save_text($checkFile, $code, $tempFile);
-        $result = trim(system_with_timeout("$php \"$checkFile\"", $env));
+        $command[] = $checkFile;
+        $result = trim(system_with_timeout($command, $env));
         if (strpos($result, 'nocache') === 0) {
             $result = '';
         } else if ($this->enable) {
@@ -3643,19 +3774,37 @@ public function checkSkip(string $php, string $code, string $checkFile, string $
         return $result;
     }

-    public function getExtensions(string $php): array
+    public function getExtensions(array $command): array
     {
-        if (isset($this->extensions[$php])) {
+        $key = implode("\0", $command);
+        if (isset($this->extensions[$key])) {
             $this->extHits++;
-            return $this->extensions[$php];
+            return $this->extensions[$key];
+        }
+
+        $probeCommand = escaped_shell_string_from([
+            ...$command,
+            '-d',
+            'display_errors=0',
+            '-r',
+        ]);
+
+        $output = shell_exec(
+            $probeCommand
+            . ' "echo ini_get(\'extension_dir\'), chr(0), implode(\',\', get_loaded_extensions());"'
+        );
+
+        if (!is_string($output) || !str_contains($output, "\0")) {
+            error("Unable to query loaded PHP extensions.");
         }

-        $extDir = shell_exec("$php -d display_errors=0 -r \"echo ini_get('extension_dir');\"");
-        $extensionsNames = explode(",", shell_exec("$php -d display_errors=0 -r \"echo implode(',', get_loaded_extensions());\""));
+        [$extDir, $extensionsNames] = explode("\0", $output, 2);
+
+        $extensionsNames = explode(",", $extensionsNames);
         $extensions = remap_loaded_extensions_names($extensionsNames);

         $result = [$extDir, $extensions];
-        $this->extensions[$php] = $result;
+        $this->extensions[$key] = $result;
         $this->extMisses++;

         return $result;
@@ -3676,7 +3825,11 @@ public function getHeader(): string
     public function __construct(array $environment, string $tool = 'memcheck')
     {
         $this->tool = $tool;
-        $header = system_with_timeout("valgrind --tool={$this->tool} --version", $environment);
+        $header = system_with_timeout([
+            'valgrind',
+            "--tool={$this->tool}",
+            '--version',
+        ], $environment);
         if (!$header) {
             error("Valgrind returned no version info for {$this->tool}, cannot proceed.\n".
                 "Please check if Valgrind is installed and the tool is named correctly.");
@@ -3690,18 +3843,28 @@ public function __construct(array $environment, string $tool = 'memcheck')
         $this->version_3_8_0 = version_compare($version, '3.8.0', '>=');
     }

-    public function wrapCommand(string $cmd, string $memcheck_filename, bool $check_all): string
+    public function wrapCommand(array $command, string $memcheck_filename, bool $check_all): array
     {
-        $vcmd = "valgrind -q --tool={$this->tool} --trace-children=yes";
+        $valgrind_arguments = [
+            'valgrind',
+            '-q',
+            "--tool={$this->tool}",
+            '--trace-children=yes',
+        ];
+
         if ($check_all) {
-            $vcmd .= ' --smc-check=all';
+            $valgrind_arguments[] = '--smc-check=all';
         }

-        /* --vex-iropt-register-updates=allregs-at-mem-access is necessary for phpdbg watchpoint tests */
-        if ($this->version_3_8_0) {
-            return "$vcmd --vex-iropt-register-updates=allregs-at-mem-access --log-file=$memcheck_filename $cmd";
-        }
-        return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file=$memcheck_filename $cmd";
+        $valgrind_arguments[] = $this->version_3_8_0
+            ? '--vex-iropt-register-updates=allregs-at-mem-access' // necessary for phpdbg watchpoint tests
+            : '--vex-iropt-precise-memory-exns=yes';
+
+        return [
+            ...$valgrind_arguments,
+            "--log-file=$memcheck_filename",
+            ...$command,
+        ];
     }
 }

diff --git a/tests/basic/req60524-win.phpt b/tests/basic/req60524-win.phpt
index 26fa9d9c5c7..d75eb8c7dcf 100644
--- a/tests/basic/req60524-win.phpt
+++ b/tests/basic/req60524-win.phpt
@@ -10,4 +10,4 @@
 --FILE--
 <?php echo sys_get_temp_dir(); ?>
 --EXPECT--
-C:\\Windows
+C:\Windows
diff --git a/tests/run-test/clean_environment.phpt b/tests/run-test/clean_environment.phpt
new file mode 100644
index 00000000000..cf2d3c4b4f5
--- /dev/null
+++ b/tests/run-test/clean_environment.phpt
@@ -0,0 +1,26 @@
+--TEST--
+SKIPIF and CLEAN do not inherit request environment variables on POSIX
+--SKIPIF--
+<?php
+if (PHP_OS_FAMILY !== 'Windows') {
+    foreach (['REQUEST_METHOD', 'QUERY_STRING', 'PATH_TRANSLATED', 'SCRIPT_FILENAME'] as $name) {
+        if (getenv($name) !== false) {
+            die("$name was inherited\n");
+        }
+    }
+}
+?>
+--FILE--
+<?php
+?>
+--CLEAN--
+<?php
+if (PHP_OS_FAMILY !== 'Windows') {
+    foreach (['REQUEST_METHOD', 'QUERY_STRING', 'PATH_TRANSLATED', 'SCRIPT_FILENAME'] as $name) {
+        if (getenv($name) !== false) {
+            echo "$name was inherited\n";
+        }
+    }
+}
+?>
+--EXPECT--