xref: /php-src/run-tests.php (revision e7af08d6)
1#!/usr/bin/env php
2<?php
3/*
4   +----------------------------------------------------------------------+
5   | Copyright (c) The PHP Group                                          |
6   +----------------------------------------------------------------------+
7   | This source file is subject to version 3.01 of the PHP license,      |
8   | that is bundled with this package in the file LICENSE, and is        |
9   | available through the world-wide-web at the following url:           |
10   | https://www.php.net/license/3_01.txt                                 |
11   | If you did not receive a copy of the PHP license and are unable to   |
12   | obtain it through the world-wide-web, please send a note to          |
13   | license@php.net so we can mail you a copy immediately.               |
14   +----------------------------------------------------------------------+
15   | Authors: Ilia Alshanetsky <iliaa@php.net>                            |
16   |          Preston L. Bannister <pbannister@php.net>                   |
17   |          Marcus Boerger <helly@php.net>                              |
18   |          Derick Rethans <derick@php.net>                             |
19   |          Sander Roobol <sander@php.net>                              |
20   |          Andrea Faulds <ajf@ajf.me>                                  |
21   | (based on version by: Stig Bakken <ssb@php.net>)                     |
22   | (based on the PHP 3 test framework by Rasmus Lerdorf)                |
23   +----------------------------------------------------------------------+
24 */
25
26/* Let there be no top-level code beyond this point:
27 * Only functions and classes, thanks!
28 *
29 * Minimum required PHP version: 8.0.0
30 */
31
32function show_usage(): void
33{
34    echo <<<HELP
35Synopsis:
36    php run-tests.php [options] [files] [directories]
37
38Options:
39    -j<workers> Run up to <workers> simultaneous testing processes in parallel for
40                quicker testing on systems with multiple logical processors.
41                Note that this is experimental feature.
42
43    -l <file>   Read the testfiles to be executed from <file>. After the test
44                has finished all failed tests are written to the same <file>.
45                If the list is empty and no further test is specified then
46                all tests are executed (same as: -r <file> -w <file>).
47
48    -r <file>   Read the testfiles to be executed from <file>.
49
50    -w <file>   Write a list of all failed tests to <file>.
51
52    -a <file>   Same as -w but append rather then truncating <file>.
53
54    -W <file>   Write a list of all tests and their result status to <file>.
55
56    -c <file>   Look for php.ini in directory <file> or use <file> as ini.
57
58    -n          Pass -n option to the php binary (Do not use a php.ini).
59
60    -d foo=bar  Pass -d option to the php binary (Define INI entry foo
61                with value 'bar').
62
63    -g          Comma separated list of groups to show during test run
64                (possible values: PASS, FAIL, XFAIL, XLEAK, SKIP, BORK, WARN, LEAK, REDIRECT).
65
66    -m          Test for memory leaks with Valgrind (equivalent to -M memcheck).
67
68    -M <tool>   Test for errors with Valgrind tool.
69
70    -p <php>    Specify PHP executable to run.
71
72    -P          Use PHP_BINARY as PHP executable to run (default).
73
74    -q          Quiet, no user interaction (same as environment NO_INTERACTION).
75
76    -s <file>   Write output to <file>.
77
78    -x          Sets 'SKIP_SLOW_TESTS' environment variable.
79
80    --online    Prevents setting the 'SKIP_ONLINE_TESTS' environment variable.
81
82    --offline   Sets 'SKIP_ONLINE_TESTS' environment variable (default).
83
84    --verbose
85    -v          Verbose mode.
86
87    --help
88    -h          This Help.
89
90    --temp-source <sdir>  --temp-target <tdir> [--temp-urlbase <url>]
91                Write temporary files to <tdir> by replacing <sdir> from the
92                filenames to generate with <tdir>. In general you want to make
93                <sdir> the path to your source files and <tdir> some patch in
94                your web page hierarchy with <url> pointing to <tdir>.
95
96    --keep-[all|php|skip|clean]
97                Do not delete 'all' files, 'php' test file, 'skip' or 'clean'
98                file.
99
100    --set-timeout <n>
101                Set timeout for individual tests, where <n> is the number of
102                seconds. The default value is 60 seconds, or 300 seconds when
103                testing for memory leaks.
104
105    --context <n>
106                Sets the number of lines of surrounding context to print for diffs.
107                The default value is 3.
108
109    --show-[all|php|skip|clean|exp|diff|out|mem]
110                Show 'all' files, 'php' test file, 'skip' or 'clean' file. You
111                can also use this to show the output 'out', the expected result
112                'exp', the difference between them 'diff' or the valgrind log
113                'mem'. The result types get written independent of the log format,
114                however 'diff' only exists when a test fails.
115
116    --show-slow <n>
117                Show all tests that took longer than <n> milliseconds to run.
118
119    --no-clean  Do not execute clean section if any.
120
121    --color
122    --no-color  Do/Don't colorize the result type in the test result.
123
124    --progress
125    --no-progress  Do/Don't show the current progress.
126
127    --repeat [n]
128                Run the tests multiple times in the same process and check the
129                output of the last execution (CLI SAPI only).
130
131    --bless     Bless failed tests using scripts/dev/bless_tests.php.
132
133HELP;
134}
135
136/**
137 * One function to rule them all, one function to find them, one function to
138 * bring them all and in the darkness bind them.
139 * This is the entry point and exit point überfunction. It contains all the
140 * code that was previously found at the top level. It could and should be
141 * refactored to be smaller and more manageable.
142 */
143function main(): void
144{
145    /* This list was derived in a naïve mechanical fashion. If a member
146     * looks like it doesn't belong, it probably doesn't; cull at will.
147     */
148    global $DETAILED, $PHP_FAILED_TESTS, $SHOW_ONLY_GROUPS, $argc, $argv, $cfg,
149           $end_time, $environment,
150           $exts_skipped, $exts_tested, $exts_to_test, $failed_tests_file,
151           $ignored_by_ext, $ini_overwrites, $colorize,
152           $log_format, $no_clean, $no_file_cache,
153           $pass_options, $php, $php_cgi, $preload,
154           $result_tests_file, $slow_min_ms, $start_time,
155           $temp_source, $temp_target, $test_cnt,
156           $test_files, $test_idx, $test_results, $testfile,
157           $valgrind, $sum_results, $shuffle, $file_cache, $num_repeats,
158           $show_progress;
159    // Parallel testing
160    global $workers, $workerID;
161    global $context_line_count;
162
163    // Temporary for the duration of refactoring
164    /** @var JUnit $junit */
165    global $junit;
166
167    define('IS_WINDOWS', substr(PHP_OS, 0, 3) == "WIN");
168
169    $workerID = 0;
170    if (getenv("TEST_PHP_WORKER")) {
171        $workerID = intval(getenv("TEST_PHP_WORKER"));
172        run_worker();
173        return;
174    }
175
176    define('INIT_DIR', getcwd());
177
178    // Change into the PHP source directory.
179    if (getenv('TEST_PHP_SRCDIR')) {
180        @chdir(getenv('TEST_PHP_SRCDIR'));
181    }
182
183    define('TEST_PHP_SRCDIR', getcwd());
184
185    check_proc_open_function_exists();
186
187    // If timezone is not set, use UTC.
188    if (ini_get('date.timezone') == '') {
189        date_default_timezone_set('UTC');
190    }
191
192    // Delete some security related environment variables
193    putenv('SSH_CLIENT=deleted');
194    putenv('SSH_AUTH_SOCK=deleted');
195    putenv('SSH_TTY=deleted');
196    putenv('SSH_CONNECTION=deleted');
197
198    set_time_limit(0);
199
200    ini_set('pcre.backtrack_limit', PHP_INT_MAX);
201
202    init_output_buffers();
203
204    error_reporting(E_ALL);
205
206    $environment = $_ENV ?? [];
207
208    // Some configurations like php.ini-development set variables_order="GPCS"
209    // not "EGPCS", in which case $_ENV is NOT populated. Detect if the $_ENV
210    // was empty and handle it by explicitly populating through getenv().
211    if (empty($environment)) {
212        $environment = getenv();
213    }
214
215    if (empty($environment['TEMP'])) {
216        $environment['TEMP'] = sys_get_temp_dir();
217
218        if (empty($environment['TEMP'])) {
219            // For example, OpCache on Windows will fail in this case because
220            // child processes (for tests) will not get a TEMP variable, so
221            // GetTempPath() will fallback to c:\windows, while GetTempPath()
222            // will return %TEMP% for parent (likely a different path). The
223            // parent will initialize the OpCache in that path, and child will
224            // fail to reattach to the OpCache because it will be using the
225            // wrong path.
226            die("TEMP environment is NOT set");
227        }
228
229        if (count($environment) == 1) {
230            // Not having other environment variables, only having TEMP, is
231            // probably ok, but strange and may make a difference in the
232            // test pass rate, so warn the user.
233            echo "WARNING: Only 1 environment variable will be available to tests(TEMP environment variable)" , PHP_EOL;
234        }
235    }
236
237    if (IS_WINDOWS && empty($environment["SystemRoot"])) {
238        $environment["SystemRoot"] = getenv("SystemRoot");
239    }
240
241    if (getenv('TEST_PHP_LOG_FORMAT')) {
242        $log_format = strtoupper(getenv('TEST_PHP_LOG_FORMAT'));
243    } else {
244        $log_format = 'LEODS';
245    }
246
247    // Check whether a detailed log is wanted.
248    if (getenv('TEST_PHP_DETAILED')) {
249        $DETAILED = getenv('TEST_PHP_DETAILED');
250    } else {
251        $DETAILED = 0;
252    }
253
254    $junit = new JUnit($environment, $workerID);
255
256    if (getenv('SHOW_ONLY_GROUPS')) {
257        $SHOW_ONLY_GROUPS = explode(",", getenv('SHOW_ONLY_GROUPS'));
258    } else {
259        $SHOW_ONLY_GROUPS = [];
260    }
261
262    // Check whether user test dirs are requested.
263    $user_tests = [];
264    if (getenv('TEST_PHP_USER')) {
265        $user_tests = explode(',', getenv('TEST_PHP_USER'));
266    }
267
268    $exts_to_test = [];
269    $ini_overwrites = [
270        'output_handler=',
271        'open_basedir=',
272        'disable_functions=',
273        'output_buffering=Off',
274        'error_reporting=' . E_ALL,
275        'display_errors=1',
276        'display_startup_errors=1',
277        'log_errors=0',
278        'html_errors=0',
279        'track_errors=0',
280        'report_memleaks=1',
281        'report_zend_debug=0',
282        'docref_root=',
283        'docref_ext=.html',
284        'error_prepend_string=',
285        'error_append_string=',
286        'auto_prepend_file=',
287        'auto_append_file=',
288        'ignore_repeated_errors=0',
289        'precision=14',
290        'serialize_precision=-1',
291        'memory_limit=128M',
292        'opcache.fast_shutdown=0',
293        'opcache.file_update_protection=0',
294        'opcache.revalidate_freq=0',
295        'opcache.jit_hot_loop=1',
296        'opcache.jit_hot_func=1',
297        'opcache.jit_hot_return=1',
298        'opcache.jit_hot_side_exit=1',
299        'opcache.jit_max_root_traces=100000',
300        'opcache.jit_max_side_traces=100000',
301        'opcache.jit_max_exit_counters=100000',
302        'opcache.protect_memory=1',
303        'zend.assertions=1',
304        'zend.exception_ignore_args=0',
305        'zend.exception_string_param_max_len=15',
306        'short_open_tag=0',
307    ];
308
309    $no_file_cache = '-d opcache.file_cache= -d opcache.file_cache_only=0';
310
311    // Determine the tests to be run.
312
313    $test_files = [];
314    $redir_tests = [];
315    $test_results = [];
316    $PHP_FAILED_TESTS = [
317        'BORKED' => [],
318        'FAILED' => [],
319        'WARNED' => [],
320        'LEAKED' => [],
321        'XFAILED' => [],
322        'XLEAKED' => [],
323        'SLOW' => []
324    ];
325
326    // If parameters given assume they represent selected tests to run.
327    $result_tests_file = false;
328    $failed_tests_file = false;
329    $pass_option_n = false;
330    $pass_options = '';
331
332    $output_file = INIT_DIR . '/php_test_results_' . date('Ymd_Hi') . '.txt';
333
334    $just_save_results = false;
335    $valgrind = null;
336    $temp_source = null;
337    $temp_target = null;
338    $conf_passed = null;
339    $no_clean = false;
340    $colorize = true;
341    if (function_exists('sapi_windows_vt100_support') && !sapi_windows_vt100_support(STDOUT, true)) {
342        $colorize = false;
343    }
344    if (array_key_exists('NO_COLOR', $environment)) {
345        $colorize = false;
346    }
347    $selected_tests = false;
348    $slow_min_ms = INF;
349    $preload = false;
350    $file_cache = null;
351    $shuffle = false;
352    $bless = false;
353    $workers = null;
354    $context_line_count = 3;
355    $num_repeats = 1;
356    $show_progress = true;
357    $ignored_by_ext = [];
358    $online = null;
359
360    $cfgtypes = ['show', 'keep'];
361    $cfgfiles = ['skip', 'php', 'clean', 'out', 'diff', 'exp', 'mem'];
362    $cfg = [];
363
364    foreach ($cfgtypes as $type) {
365        $cfg[$type] = [];
366
367        foreach ($cfgfiles as $file) {
368            $cfg[$type][$file] = false;
369        }
370    }
371
372    if (!isset($argc, $argv) || !$argc) {
373        $argv = [__FILE__];
374        $argc = 1;
375    }
376
377    if (getenv('TEST_PHP_ARGS')) {
378        $argv = array_merge($argv, explode(' ', getenv('TEST_PHP_ARGS')));
379        $argc = count($argv);
380    }
381
382    for ($i = 1; $i < $argc; $i++) {
383        $is_switch = false;
384        $switch = substr($argv[$i], 1, 1);
385        $repeat = substr($argv[$i], 0, 1) == '-';
386
387        while ($repeat) {
388            if (!$is_switch) {
389                $switch = substr($argv[$i], 1, 1);
390            }
391
392            $is_switch = true;
393
394            foreach ($cfgtypes as $type) {
395                if (strpos($switch, '--' . $type) === 0) {
396                    foreach ($cfgfiles as $file) {
397                        if ($switch == '--' . $type . '-' . $file) {
398                            $cfg[$type][$file] = true;
399                            $is_switch = false;
400                            break;
401                        }
402                    }
403                }
404            }
405
406            if (!$is_switch) {
407                $is_switch = true;
408                break;
409            }
410
411            $repeat = false;
412
413            switch ($switch) {
414                case 'j':
415                    $workers = substr($argv[$i], 2);
416                    if ($workers == 0 || !preg_match('/^\d+$/', $workers)) {
417                        error("'$workers' is not a valid number of workers, try e.g. -j16 for 16 workers");
418                    }
419                    $workers = intval($workers, 10);
420                    // Don't use parallel testing infrastructure if there is only one worker.
421                    if ($workers === 1) {
422                        $workers = null;
423                    }
424                    break;
425                case 'r':
426                case 'l':
427                    $test_list = file($argv[++$i]);
428                    if ($test_list) {
429                        foreach ($test_list as $test) {
430                            $matches = [];
431                            if (preg_match('/^#.*\[(.*)\]\:\s+(.*)$/', $test, $matches)) {
432                                $redir_tests[] = [$matches[1], $matches[2]];
433                            } elseif (strlen($test)) {
434                                $test_files[] = trim($test);
435                            }
436                        }
437                    }
438                    if ($switch != 'l') {
439                        break;
440                    }
441                    $i--;
442                // no break
443                case 'w':
444                    $failed_tests_file = fopen($argv[++$i], 'w+t');
445                    break;
446                case 'a':
447                    $failed_tests_file = fopen($argv[++$i], 'a+t');
448                    break;
449                case 'W':
450                    $result_tests_file = fopen($argv[++$i], 'w+t');
451                    break;
452                case 'c':
453                    $conf_passed = $argv[++$i];
454                    break;
455                case 'd':
456                    $ini_overwrites[] = $argv[++$i];
457                    break;
458                case 'g':
459                    $SHOW_ONLY_GROUPS = explode(",", $argv[++$i]);
460                    break;
461                case '--keep-all':
462                    foreach ($cfgfiles as $file) {
463                        $cfg['keep'][$file] = true;
464                    }
465                    break;
466                case 'm':
467                    $valgrind = new RuntestsValgrind($environment);
468                    break;
469                case 'M':
470                    $valgrind = new RuntestsValgrind($environment, $argv[++$i]);
471                    break;
472                case 'n':
473                    if (!$pass_option_n) {
474                        $pass_options .= ' -n';
475                    }
476                    $pass_option_n = true;
477                    break;
478                case 'e':
479                    $pass_options .= ' -e';
480                    break;
481                case '--preload':
482                    $preload = true;
483                    $environment['SKIP_PRELOAD'] = 1;
484                    break;
485                case '--file-cache-prime':
486                    $file_cache = 'prime';
487                    break;
488                case '--file-cache-use':
489                    $file_cache = 'use';
490                    break;
491                case '--no-clean':
492                    $no_clean = true;
493                    break;
494                case '--color':
495                    $colorize = true;
496                    break;
497                case '--no-color':
498                    $colorize = false;
499                    break;
500                case 'p':
501                    $php = $argv[++$i];
502                    putenv("TEST_PHP_EXECUTABLE=$php");
503                    $environment['TEST_PHP_EXECUTABLE'] = $php;
504                    break;
505                case 'P':
506                    $php = PHP_BINARY;
507                    putenv("TEST_PHP_EXECUTABLE=$php");
508                    $environment['TEST_PHP_EXECUTABLE'] = $php;
509                    break;
510                case 'q':
511                    putenv('NO_INTERACTION=1');
512                    $environment['NO_INTERACTION'] = 1;
513                    break;
514                case 's':
515                    $output_file = $argv[++$i];
516                    $just_save_results = true;
517                    break;
518                case '--set-timeout':
519                    $timeout = $argv[++$i] ?? '';
520                    if (!preg_match('/^\d+$/', $timeout)) {
521                        error("'$timeout' is not a valid number of seconds, try e.g. --set-timeout 60 for 1 minute");
522                    }
523                    $environment['TEST_TIMEOUT'] = intval($timeout, 10);
524                    break;
525                case '--context':
526                    $context_line_count = $argv[++$i] ?? '';
527                    if (!preg_match('/^\d+$/', $context_line_count)) {
528                        error("'$context_line_count' is not a valid number of lines of context, try e.g. --context 3 for 3 lines");
529                    }
530                    $context_line_count = intval($context_line_count, 10);
531                    break;
532                case '--show-all':
533                    foreach ($cfgfiles as $file) {
534                        $cfg['show'][$file] = true;
535                    }
536                    break;
537                case '--show-slow':
538                    $slow_min_ms = $argv[++$i] ?? '';
539                    if (!preg_match('/^\d+$/', $slow_min_ms)) {
540                        error("'$slow_min_ms' is not a valid number of milliseconds, try e.g. --show-slow 1000 for 1 second");
541                    }
542                    $slow_min_ms = intval($slow_min_ms, 10);
543                    break;
544                case '--temp-source':
545                    $temp_source = $argv[++$i];
546                    break;
547                case '--temp-target':
548                    $temp_target = $argv[++$i];
549                    break;
550                case 'v':
551                case '--verbose':
552                    $DETAILED = true;
553                    break;
554                case 'x':
555                    $environment['SKIP_SLOW_TESTS'] = 1;
556                    break;
557                case '--online':
558                    $online = true;
559                    break;
560                case '--offline':
561                    $online = false;
562                    break;
563                case '--shuffle':
564                    $shuffle = true;
565                    break;
566                case '--asan':
567                case '--msan':
568                    $environment['USE_ZEND_ALLOC'] = 0;
569                    $environment['USE_TRACKED_ALLOC'] = 1;
570                    $environment['SKIP_ASAN'] = 1;
571                    $environment['SKIP_PERF_SENSITIVE'] = 1;
572                    if ($switch === '--msan') {
573                        $environment['SKIP_MSAN'] = 1;
574                        $environment['MSAN_OPTIONS'] = 'intercept_tls_get_addr=0';
575                    }
576
577                    $lsanSuppressions = __DIR__ . '/.github/lsan-suppressions.txt';
578                    if (file_exists($lsanSuppressions)) {
579                        $environment['LSAN_OPTIONS'] = 'suppressions=' . $lsanSuppressions
580                            . ':print_suppressions=0';
581                    }
582                    break;
583                case '--repeat':
584                    $num_repeats = (int) $argv[++$i];
585                    $environment['SKIP_REPEAT'] = 1;
586                    break;
587                case '--bless':
588                    $bless = true;
589                    break;
590                case '-':
591                    // repeat check with full switch
592                    $switch = $argv[$i];
593                    if ($switch != '-') {
594                        $repeat = true;
595                    }
596                    break;
597                case '--progress':
598                    $show_progress = true;
599                    break;
600                case '--no-progress':
601                    $show_progress = false;
602                    break;
603                case '--version':
604                    echo '$Id: 9dffecca8d2c00ac70762bd5a162504c3e455838 $' . "\n";
605                    exit(1);
606
607                default:
608                    echo "Illegal switch '$switch' specified!\n";
609                    // no break
610                case 'h':
611                case '-help':
612                case '--help':
613                    show_usage();
614                    exit(1);
615            }
616        }
617
618        if (!$is_switch) {
619            $selected_tests = true;
620            $testfile = realpath($argv[$i]);
621
622            if (!$testfile && strpos($argv[$i], '*') !== false && function_exists('glob')) {
623                if (substr($argv[$i], -5) == '.phpt') {
624                    $pattern_match = glob($argv[$i]);
625                } elseif (preg_match("/\*$/", $argv[$i])) {
626                    $pattern_match = glob($argv[$i] . '.phpt');
627                } else {
628                    die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL);
629                }
630
631                if (is_array($pattern_match)) {
632                    $test_files = array_merge($test_files, $pattern_match);
633                }
634            } elseif (is_dir($testfile)) {
635                find_files($testfile);
636            } elseif (substr($testfile, -5) == '.phpt') {
637                $test_files[] = $testfile;
638            } else {
639                die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL);
640            }
641        }
642    }
643
644    if ($online === null && !isset($environment['SKIP_ONLINE_TESTS'])) {
645        $online = false;
646    }
647    if ($online !== null) {
648        $environment['SKIP_ONLINE_TESTS'] = $online ? '0' : '1';
649    }
650
651    if ($selected_tests && count($test_files) === 0) {
652        echo "No tests found.\n";
653        return;
654    }
655
656    if (!$php) {
657        $php = getenv('TEST_PHP_EXECUTABLE') ?: PHP_BINARY;
658    }
659
660    $php_cgi = getenv('TEST_PHP_CGI_EXECUTABLE') ?: get_binary($php, 'php-cgi', 'sapi/cgi/php-cgi');
661    $phpdbg = getenv('TEST_PHPDBG_EXECUTABLE') ?: get_binary($php, 'phpdbg', 'sapi/phpdbg/phpdbg');
662
663    putenv("TEST_PHP_EXECUTABLE=$php");
664    $environment['TEST_PHP_EXECUTABLE'] = $php;
665    putenv("TEST_PHP_EXECUTABLE_ESCAPED=" . escapeshellarg($php));
666    $environment['TEST_PHP_EXECUTABLE_ESCAPED'] = escapeshellarg($php);
667    putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi");
668    $environment['TEST_PHP_CGI_EXECUTABLE'] = $php_cgi;
669    putenv("TEST_PHP_CGI_EXECUTABLE_ESCAPED=" . escapeshellarg($php_cgi ?? ''));
670    $environment['TEST_PHP_CGI_EXECUTABLE_ESCAPED'] = escapeshellarg($php_cgi ?? '');
671    putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg");
672    $environment['TEST_PHPDBG_EXECUTABLE'] = $phpdbg;
673    putenv("TEST_PHPDBG_EXECUTABLE_ESCAPED=" . escapeshellarg($phpdbg ?? ''));
674    $environment['TEST_PHPDBG_EXECUTABLE_ESCAPED'] = escapeshellarg($phpdbg ?? '');
675
676    if ($conf_passed !== null) {
677        if (IS_WINDOWS) {
678            $pass_options .= " -c " . escapeshellarg($conf_passed);
679        } else {
680            $pass_options .= " -c '" . realpath($conf_passed) . "'";
681        }
682    }
683
684    $test_files = array_unique($test_files);
685    $test_files = array_merge($test_files, $redir_tests);
686
687    // Run selected tests.
688    $test_cnt = count($test_files);
689
690    verify_config($php);
691    write_information($user_tests, $phpdbg);
692
693    if ($test_cnt) {
694        putenv('NO_INTERACTION=1');
695        usort($test_files, "test_sort");
696        $start_time = hrtime(true);
697
698        echo "Running selected tests.\n";
699
700        $test_idx = 0;
701        run_all_tests($test_files, $environment);
702        $end_time = hrtime(true);
703
704        if ($failed_tests_file) {
705            fclose($failed_tests_file);
706        }
707
708        if ($result_tests_file) {
709            fclose($result_tests_file);
710        }
711
712        if (0 == count($test_results)) {
713            echo "No tests were run.\n";
714            return;
715        }
716
717        compute_summary();
718        echo "=====================================================================";
719        echo get_summary(false);
720
721        if ($output_file != '' && $just_save_results) {
722            save_results($output_file, /* prompt_to_save_results: */ false);
723        }
724    } else {
725        // Compile a list of all test files (*.phpt).
726        $test_files = [];
727        $exts_tested = $exts_to_test;
728        $exts_skipped = [];
729        sort($exts_to_test);
730
731        foreach (['Zend', 'tests', 'ext', 'sapi'] as $dir) {
732            if (is_dir($dir)) {
733                find_files(TEST_PHP_SRCDIR . "/{$dir}", $dir == 'ext');
734            }
735        }
736
737        foreach ($user_tests as $dir) {
738            find_files($dir, $dir == 'ext');
739        }
740
741        $test_files = array_unique($test_files);
742        usort($test_files, "test_sort");
743
744        $start_timestamp = time();
745        $start_time = hrtime(true);
746        show_start($start_timestamp);
747
748        $test_cnt = count($test_files);
749        $test_idx = 0;
750        run_all_tests($test_files, $environment);
751        $end_time = hrtime(true);
752
753        if ($failed_tests_file) {
754            fclose($failed_tests_file);
755        }
756
757        if ($result_tests_file) {
758            fclose($result_tests_file);
759        }
760
761        // Summarize results
762
763        if (0 == count($test_results)) {
764            echo "No tests were run.\n";
765            return;
766        }
767
768        compute_summary();
769
770        show_end($start_timestamp, $start_time, $end_time);
771        show_summary();
772
773        save_results($output_file, /* prompt_to_save_results: */ true);
774    }
775
776    $junit->saveXML();
777    if ($bless) {
778        bless_failed_tests($PHP_FAILED_TESTS['FAILED']);
779    }
780    if (getenv('REPORT_EXIT_STATUS') !== '0' && getenv('REPORT_EXIT_STATUS') !== 'no' &&
781            ($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['LEAKED'])) {
782        exit(1);
783    }
784}
785
786function verify_config(string $php): void
787{
788    if (empty($php) || !file_exists($php)) {
789        error('environment variable TEST_PHP_EXECUTABLE must be set to specify PHP executable!');
790    }
791
792    if (!is_executable($php)) {
793        error("invalid PHP executable specified by TEST_PHP_EXECUTABLE  = $php");
794    }
795}
796
797/**
798 * @param string[] $user_tests
799 */
800function write_information(array $user_tests, $phpdbg): void
801{
802    global $php, $php_cgi, $php_info, $ini_overwrites, $pass_options, $exts_to_test, $valgrind, $no_file_cache;
803    $php_escaped = escapeshellarg($php);
804
805    // Get info from php
806    $info_file = __DIR__ . '/run-test-info.php';
807    @unlink($info_file);
808    $php_info = '<?php echo "
809PHP_SAPI    : " , PHP_SAPI , "
810PHP_VERSION : " , phpversion() , "
811ZEND_VERSION: " , zend_version() , "
812PHP_OS      : " , PHP_OS , " - " , php_uname() , "
813INI actual  : " , realpath(get_cfg_var("cfg_file_path")) , "
814More .INIs  : " , (function_exists(\'php_ini_scanned_files\') ? str_replace("\n","", php_ini_scanned_files()) : "** not determined **"); ?>';
815    save_text($info_file, $php_info);
816    $info_params = [];
817    settings2array($ini_overwrites, $info_params);
818    $info_params = settings2params($info_params);
819    $php_info = shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\"");
820    define('TESTED_PHP_VERSION', shell_exec("$php_escaped -n -r \"echo PHP_VERSION;\""));
821
822    if ($php_cgi && $php != $php_cgi) {
823        $php_cgi_escaped = escapeshellarg($php_cgi);
824        $php_info_cgi = shell_exec("$php_cgi_escaped $pass_options $info_params $no_file_cache -q \"$info_file\"");
825        $php_info_sep = "\n---------------------------------------------------------------------";
826        $php_cgi_info = "$php_info_sep\nPHP         : $php_cgi $php_info_cgi$php_info_sep";
827    } else {
828        $php_cgi_info = '';
829    }
830
831    if ($phpdbg) {
832        $phpdbg_escaped = escapeshellarg($phpdbg);
833        $phpdbg_info = shell_exec("$phpdbg_escaped $pass_options $info_params $no_file_cache -qrr \"$info_file\"");
834        $php_info_sep = "\n---------------------------------------------------------------------";
835        $phpdbg_info = "$php_info_sep\nPHP         : $phpdbg $phpdbg_info$php_info_sep";
836    } else {
837        $phpdbg_info = '';
838    }
839
840    if (function_exists('opcache_invalidate')) {
841        opcache_invalidate($info_file, true);
842    }
843    @unlink($info_file);
844
845    // load list of enabled and loadable extensions
846    save_text($info_file, <<<'PHP'
847        <?php
848        $exts = get_loaded_extensions();
849        $ext_dir = ini_get('extension_dir');
850        foreach (scandir($ext_dir) as $file) {
851            if (preg_match('/^(?:php_)?([_a-zA-Z0-9]+)\.(?:so|dll)$/', $file, $matches)) {
852                if (!extension_loaded($matches[1])) {
853                    $exts[] = $matches[1];
854                }
855            }
856        }
857        echo implode(',', $exts);
858        PHP);
859    $extensionsNames = explode(',', shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\""));
860    $exts_to_test = array_unique(remap_loaded_extensions_names($extensionsNames));
861    // check for extensions that need special handling and regenerate
862    $info_params_ex = [
863        'session' => ['session.auto_start=0'],
864        'tidy' => ['tidy.clean_output=0'],
865        'zlib' => ['zlib.output_compression=Off'],
866        'xdebug' => ['xdebug.mode=off'],
867    ];
868
869    foreach ($info_params_ex as $ext => $ini_overwrites_ex) {
870        if (in_array($ext, $exts_to_test)) {
871            $ini_overwrites = array_merge($ini_overwrites, $ini_overwrites_ex);
872        }
873    }
874
875    if (function_exists('opcache_invalidate')) {
876        opcache_invalidate($info_file, true);
877    }
878    @unlink($info_file);
879
880    // Write test context information.
881    echo "
882=====================================================================
883PHP         : $php $php_info $php_cgi_info $phpdbg_info
884CWD         : " . TEST_PHP_SRCDIR . "
885Extra dirs  : ";
886    foreach ($user_tests as $test_dir) {
887        echo "{$test_dir}\n			  ";
888    }
889    echo "
890VALGRIND    : " . ($valgrind ? $valgrind->getHeader() : 'Not used') . "
891=====================================================================
892";
893}
894
895function save_results(string $output_file, bool $prompt_to_save_results): void
896{
897    global $sum_results, $failed_test_summary, $PHP_FAILED_TESTS, $php;
898
899    if (getenv('NO_INTERACTION')) {
900        return;
901    }
902
903    if ($prompt_to_save_results) {
904        /* We got failed Tests, offer the user to save a QA report */
905        $fp = fopen("php://stdin", "r+");
906        if ($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['WARNED'] || $sum_results['LEAKED']) {
907            echo "\nYou may have found a problem in PHP.";
908        }
909        echo "\nThis report can be saved and used to open an issue on the bug tracker at\n";
910        echo "https://github.com/php/php-src/issues\n";
911        echo "This gives us a better understanding of PHP's behavior.\n";
912        echo "Do you want to save this report in a file? [Yn]: ";
913        flush();
914
915        $user_input = fgets($fp, 10);
916        fclose($fp);
917        if (!(strlen(trim($user_input)) == 0 || strtolower($user_input[0]) == 'y')) {
918            return;
919        }
920    }
921    /**
922     * Collect information about the host system for our report
923     * Fetch phpinfo() output so that we can see the PHP environment
924     * Make an archive of all the failed tests
925     */
926    $failed_tests_data = '';
927    $sep = "\n" . str_repeat('=', 80) . "\n";
928    $failed_tests_data .= $failed_test_summary . "\n";
929    $failed_tests_data .= get_summary(true) . "\n";
930
931    if ($sum_results['FAILED']) {
932        foreach ($PHP_FAILED_TESTS['FAILED'] as $test_info) {
933            $failed_tests_data .= $sep . $test_info['name'] . $test_info['info'];
934            $failed_tests_data .= $sep . file_get_contents(realpath($test_info['output']));
935            $failed_tests_data .= $sep . file_get_contents(realpath($test_info['diff']));
936            $failed_tests_data .= $sep . "\n\n";
937        }
938    }
939
940    $failed_tests_data .= "\n" . $sep . 'BUILD ENVIRONMENT' . $sep;
941    $failed_tests_data .= "OS:\n" . PHP_OS . " - " . php_uname() . "\n\n";
942    $ldd = $autoconf = $sys_libtool = $libtool = $compiler = 'N/A';
943
944    if (!IS_WINDOWS) {
945        /* If PHP_AUTOCONF is set, use it; otherwise, use 'autoconf'. */
946        if (getenv('PHP_AUTOCONF')) {
947            $autoconf = shell_exec(getenv('PHP_AUTOCONF') . ' --version');
948        } else {
949            $autoconf = shell_exec('autoconf --version');
950        }
951
952        /* Always use the generated libtool - Mac OSX uses 'glibtool' */
953        $libtool = shell_exec(INIT_DIR . '/libtool --version');
954
955        /* Use shtool to find out if there is glibtool present (MacOSX) */
956        $sys_libtool_path = shell_exec(__DIR__ . '/build/shtool path glibtool libtool');
957
958        if ($sys_libtool_path) {
959            $sys_libtool = shell_exec(str_replace("\n", "", $sys_libtool_path) . ' --version');
960        }
961
962        /* Try the most common flags for 'version' */
963        $flags = ['-v', '-V', '--version'];
964        $cc_status = 0;
965
966        foreach ($flags as $flag) {
967            system(getenv('CC') . " $flag >/dev/null 2>&1", $cc_status);
968            if ($cc_status == 0) {
969                $compiler = shell_exec(getenv('CC') . " $flag 2>&1");
970                break;
971            }
972        }
973
974        $ldd = shell_exec("ldd $php 2>/dev/null");
975    }
976
977    $failed_tests_data .= "Autoconf:\n$autoconf\n";
978    $failed_tests_data .= "Bundled Libtool:\n$libtool\n";
979    $failed_tests_data .= "System Libtool:\n$sys_libtool\n";
980    $failed_tests_data .= "Compiler:\n$compiler\n";
981    $failed_tests_data .= "Bison:\n" . shell_exec('bison --version 2>/dev/null') . "\n";
982    $failed_tests_data .= "Libraries:\n$ldd\n";
983    $failed_tests_data .= "\n";
984    $failed_tests_data .= $sep . "PHPINFO" . $sep;
985    $failed_tests_data .= shell_exec($php . ' -ddisplay_errors=stderr -dhtml_errors=0 -i 2> /dev/null');
986
987    file_put_contents($output_file, $failed_tests_data);
988    echo "Report saved to: ", $output_file, "\n";
989}
990
991function get_binary(string $php, string $sapi, string $sapi_path): ?string
992{
993    $dir = dirname($php);
994    if (IS_WINDOWS && file_exists("$dir/$sapi.exe")) {
995        return realpath("$dir/$sapi.exe");
996    }
997    // Sources tree
998    if (file_exists("$dir/../../$sapi_path")) {
999        return realpath("$dir/../../$sapi_path");
1000    }
1001    // Installation tree, preserve command prefix/suffix
1002    $inst = str_replace('php', $sapi, basename($php));
1003    if (file_exists("$dir/$inst")) {
1004        return realpath("$dir/$inst");
1005    }
1006    return null;
1007}
1008
1009function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false): void
1010{
1011    global $test_files, $exts_to_test, $ignored_by_ext, $exts_skipped;
1012
1013    $o = opendir($dir) or error("cannot open directory: $dir");
1014
1015    while (($name = readdir($o)) !== false) {
1016        if (is_dir("{$dir}/{$name}") && !in_array($name, ['.', '..', '.svn'])) {
1017            $skip_ext = ($is_ext_dir && !in_array($name, $exts_to_test));
1018            if ($skip_ext) {
1019                $exts_skipped[] = $name;
1020            }
1021            find_files("{$dir}/{$name}", false, $ignore || $skip_ext);
1022        }
1023
1024        // Cleanup any left-over tmp files from last run.
1025        if (substr($name, -4) == '.tmp') {
1026            @unlink("$dir/$name");
1027            continue;
1028        }
1029
1030        // Otherwise we're only interested in *.phpt files.
1031        // (but not those starting with a dot, which are hidden on
1032        // many platforms)
1033        if (substr($name, -5) == '.phpt' && substr($name, 0, 1) !== '.') {
1034            $testfile = realpath("{$dir}/{$name}");
1035            if ($ignore) {
1036                $ignored_by_ext[] = $testfile;
1037            } else {
1038                $test_files[] = $testfile;
1039            }
1040        }
1041    }
1042
1043    closedir($o);
1044}
1045
1046/**
1047 * @param array|string $name
1048 */
1049function test_name($name): string
1050{
1051    if (is_array($name)) {
1052        return $name[0] . ':' . $name[1];
1053    }
1054
1055    return $name;
1056}
1057/**
1058 * @param array|string $a
1059 * @param array|string $b
1060 */
1061function test_sort($a, $b): int
1062{
1063    $a = test_name($a);
1064    $b = test_name($b);
1065
1066    $ta = strpos($a, TEST_PHP_SRCDIR . "/tests") === 0 ? 1 + (strpos($a,
1067            TEST_PHP_SRCDIR . "/tests/run-test") === 0 ? 1 : 0) : 0;
1068    $tb = strpos($b, TEST_PHP_SRCDIR . "/tests") === 0 ? 1 + (strpos($b,
1069            TEST_PHP_SRCDIR . "/tests/run-test") === 0 ? 1 : 0) : 0;
1070
1071    if ($ta == $tb) {
1072        return strcmp($a, $b);
1073    }
1074
1075    return $tb - $ta;
1076}
1077
1078//
1079//  Write the given text to a temporary file.
1080//
1081
1082function save_text(string $filename, string $text, ?string $filename_copy = null): void
1083{
1084    global $DETAILED;
1085
1086    if ($filename_copy && $filename_copy != $filename && file_put_contents($filename_copy, $text) === false) {
1087        error("Cannot open file '" . $filename_copy . "' (save_text)");
1088    }
1089
1090    if (file_put_contents($filename, $text) === false) {
1091        error("Cannot open file '" . $filename . "' (save_text)");
1092    }
1093
1094    if (1 < $DETAILED) {
1095        echo "
1096FILE $filename {{{
1097$text
1098}}}
1099";
1100    }
1101}
1102
1103//
1104//  Write an error in a format recognizable to Emacs or MSVC.
1105//
1106
1107function error_report(string $testname, string $logname, string $tested): void
1108{
1109    $testname = realpath($testname);
1110    $logname = realpath($logname);
1111
1112    switch (strtoupper(getenv('TEST_PHP_ERROR_STYLE'))) {
1113        case 'MSVC':
1114            echo $testname . "(1) : $tested\n";
1115            echo $logname . "(1) :  $tested\n";
1116            break;
1117        case 'EMACS':
1118            echo $testname . ":1: $tested\n";
1119            echo $logname . ":1:  $tested\n";
1120            break;
1121    }
1122}
1123
1124/**
1125 * @return false|string
1126 */
1127function system_with_timeout(
1128    string $commandline,
1129    ?array $env = null,
1130    ?string $stdin = null,
1131    bool $captureStdIn = true,
1132    bool $captureStdOut = true,
1133    bool $captureStdErr = true
1134) {
1135    global $valgrind;
1136
1137    // when proc_open cmd is passed as a string (without bypass_shell=true option) the cmd goes thru shell
1138    // and on Windows quotes are discarded, this is a fix to honor the quotes and allow values containing
1139    // spaces like '"C:\Program Files\PHP\php.exe"' to be passed as 1 argument correctly
1140    if (IS_WINDOWS) {
1141        $commandline = 'start "" /b /wait ' . $commandline . ' & exit';
1142    }
1143
1144    $data = '';
1145
1146    $bin_env = [];
1147    foreach ((array) $env as $key => $value) {
1148        $bin_env[$key] = $value;
1149    }
1150
1151    $descriptorspec = [];
1152    if ($captureStdIn) {
1153        $descriptorspec[0] = ['pipe', 'r'];
1154    }
1155    if ($captureStdOut) {
1156        $descriptorspec[1] = ['pipe', 'w'];
1157    }
1158    if ($captureStdErr) {
1159        $descriptorspec[2] = ['pipe', 'w'];
1160    }
1161    $proc = proc_open($commandline, $descriptorspec, $pipes, TEST_PHP_SRCDIR, $bin_env, ['suppress_errors' => true]);
1162
1163    if (!$proc) {
1164        return false;
1165    }
1166
1167    if ($captureStdIn) {
1168        if (!is_null($stdin)) {
1169            fwrite($pipes[0], $stdin);
1170        }
1171        fclose($pipes[0]);
1172        unset($pipes[0]);
1173    }
1174
1175    $timeout = $valgrind ? 300 : ($env['TEST_TIMEOUT'] ?? 60);
1176    /* ASAN can cause a ~2-3x slowdown. */
1177    if (isset($env['SKIP_ASAN'])) {
1178        $timeout *= 3;
1179    }
1180
1181    while (true) {
1182        /* hide errors from interrupted syscalls */
1183        $r = $pipes;
1184        $w = null;
1185        $e = null;
1186
1187        $n = @stream_select($r, $w, $e, $timeout);
1188
1189        if ($n === false) {
1190            break;
1191        }
1192
1193        if ($n === 0) {
1194            /* timed out */
1195            $data .= "\n ** ERROR: process timed out **\n";
1196            proc_terminate($proc, 9);
1197            return $data;
1198        }
1199
1200        if ($n > 0) {
1201            if ($captureStdOut) {
1202                $line = fread($pipes[1], 8192);
1203            } elseif ($captureStdErr) {
1204                $line = fread($pipes[2], 8192);
1205            } else {
1206                $line = '';
1207            }
1208            if (strlen($line) == 0) {
1209                /* EOF */
1210                break;
1211            }
1212            $data .= $line;
1213        }
1214    }
1215
1216    $stat = proc_get_status($proc);
1217
1218    if ($stat['signaled']) {
1219        $data .= "\nTermsig=" . $stat['stopsig'] . "\n";
1220    }
1221    if ($stat["exitcode"] > 128 && $stat["exitcode"] < 160) {
1222        $data .= "\nTermsig=" . ($stat["exitcode"] - 128) . "\n";
1223    } else if (defined('PHP_WINDOWS_VERSION_MAJOR') && (($stat["exitcode"] >> 28) & 0b1111) === 0b1100) {
1224        // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/87fba13e-bf06-450e-83b1-9241dc81e781
1225        $data .= "\nTermsig=" . $stat["exitcode"] . "\n";
1226    }
1227
1228    proc_close($proc);
1229    return $data;
1230}
1231
1232function run_all_tests(array $test_files, array $env, ?string $redir_tested = null): void
1233{
1234    global $test_results, $failed_tests_file, $result_tests_file, $php, $test_idx, $file_cache;
1235    global $preload;
1236    // Parallel testing
1237    global $PHP_FAILED_TESTS, $workers, $workerID, $workerSock;
1238
1239    if ($file_cache !== null || $preload) {
1240        /* Automatically skip opcache tests in --file-cache and --preload mode,
1241         * because opcache generally expects these to run under a default configuration. */
1242        $test_files = array_filter($test_files, function($test) use($preload) {
1243            if (!is_string($test)) {
1244                return true;
1245            }
1246            if (false !== strpos($test, 'ext/opcache')) {
1247                return false;
1248            }
1249            if ($preload && false !== strpos($test, 'ext/zend_test/tests/observer')) {
1250                return false;
1251            }
1252            return true;
1253        });
1254    }
1255
1256    /* Ignore -jN if there is only one file to analyze. */
1257    if ($workers !== null && count($test_files) > 1 && !$workerID) {
1258        run_all_tests_parallel($test_files, $env, $redir_tested);
1259        return;
1260    }
1261
1262    foreach ($test_files as $name) {
1263        if (is_array($name)) {
1264            $index = "# $name[1]: $name[0]";
1265
1266            if ($redir_tested) {
1267                $name = $name[0];
1268            }
1269        } elseif ($redir_tested) {
1270            $index = "# $redir_tested: $name";
1271        } else {
1272            $index = $name;
1273        }
1274        $test_idx++;
1275
1276        if ($workerID) {
1277            $PHP_FAILED_TESTS = ['BORKED' => [], 'FAILED' => [], 'WARNED' => [], 'LEAKED' => [], 'XFAILED' => [], 'XLEAKED' => [], 'SLOW' => []];
1278            ob_start();
1279        }
1280
1281        $result = run_test($php, $name, $env);
1282        if ($workerID) {
1283            $resultText = ob_get_clean();
1284        }
1285
1286        if (!is_array($name) && $result != 'REDIR') {
1287            if ($workerID) {
1288                send_message($workerSock, [
1289                    "type" => "test_result",
1290                    "name" => $name,
1291                    "index" => $index,
1292                    "result" => $result,
1293                    "text" => $resultText,
1294                    "PHP_FAILED_TESTS" => $PHP_FAILED_TESTS
1295                ]);
1296                continue;
1297            }
1298
1299            $test_results[$index] = $result;
1300            if ($failed_tests_file && ($result == 'XFAILED' || $result == 'XLEAKED' || $result == 'FAILED' || $result == 'WARNED' || $result == 'LEAKED')) {
1301                fwrite($failed_tests_file, "$index\n");
1302            }
1303            if ($result_tests_file) {
1304                fwrite($result_tests_file, "$result\t$index\n");
1305            }
1306        }
1307    }
1308}
1309
1310function run_all_tests_parallel(array $test_files, array $env, ?string $redir_tested): void
1311{
1312    global $workers, $test_idx, $test_results, $failed_tests_file, $result_tests_file, $PHP_FAILED_TESTS, $shuffle, $valgrind, $show_progress;
1313
1314    global $junit;
1315
1316    // The PHP binary running run-tests.php, and run-tests.php itself
1317    // This PHP executable is *not* necessarily the same as the tested version
1318    $thisPHP = PHP_BINARY;
1319    $thisScript = __FILE__;
1320
1321    $workerProcs = [];
1322    $workerSocks = [];
1323
1324    // Each test may specify a list of conflict keys. While a test that conflicts with
1325    // key K is running, no other test that conflicts with K may run. Conflict keys are
1326    // specified either in the --CONFLICTS-- section, or CONFLICTS file inside a directory.
1327    $dirConflictsWith = [];
1328    $fileConflictsWith = [];
1329    $sequentialTests = [];
1330    foreach ($test_files as $i => $file) {
1331        $contents = file_get_contents($file);
1332        if (preg_match('/^--CONFLICTS--(.+?)^--/ms', $contents, $matches)) {
1333            $conflicts = parse_conflicts($matches[1]);
1334        } else {
1335            // Cache per-directory conflicts in a separate map, so we compute these only once.
1336            $dir = dirname($file);
1337            if (!isset($dirConflictsWith[$dir])) {
1338                $dirConflicts = [];
1339                if (file_exists($dir . '/CONFLICTS')) {
1340                    $contents = file_get_contents($dir . '/CONFLICTS');
1341                    $dirConflicts = parse_conflicts($contents);
1342                }
1343                $dirConflictsWith[$dir] = $dirConflicts;
1344            }
1345            $conflicts = $dirConflictsWith[$dir];
1346        }
1347
1348        // For tests conflicting with "all", no other tests may run in parallel. We'll run these
1349        // tests separately at the end, when only one worker is left.
1350        if (in_array('all', $conflicts, true)) {
1351            $sequentialTests[] = $file;
1352            unset($test_files[$i]);
1353        }
1354
1355        $fileConflictsWith[$file] = $conflicts;
1356    }
1357
1358    // Some tests assume that they are executed in a certain order. We will be popping from
1359    // $test_files, so reverse its order here. This makes sure that order is preserved at least
1360    // for tests with a common conflict key.
1361    $test_files = array_reverse($test_files);
1362
1363    // To discover parallelization issues it is useful to randomize the test order.
1364    if ($shuffle) {
1365        shuffle($test_files);
1366    }
1367
1368    // Don't start more workers than test files.
1369    $workers = max(1, min($workers, count($test_files)));
1370
1371    echo "Spawning $workers workers... ";
1372
1373    // We use sockets rather than STDIN/STDOUT for comms because on Windows,
1374    // those can't be non-blocking for some reason.
1375    $listenSock = stream_socket_server("tcp://127.0.0.1:0") or error("Couldn't create socket on localhost.");
1376    $sockName = stream_socket_get_name($listenSock, false);
1377    // PHP is terrible and returns IPv6 addresses not enclosed by []
1378    $portPos = strrpos($sockName, ":");
1379    $sockHost = substr($sockName, 0, $portPos);
1380    if (false !== strpos($sockHost, ":")) {
1381        $sockHost = "[$sockHost]";
1382    }
1383    $sockPort = substr($sockName, $portPos + 1);
1384    $sockUri = "tcp://$sockHost:$sockPort";
1385    $totalFileCount = count($test_files);
1386
1387    $startTime = microtime(true);
1388    for ($i = 1; $i <= $workers; $i++) {
1389        $proc = proc_open(
1390            [$thisPHP, $thisScript],
1391            [], // Inherit our stdin, stdout and stderr
1392            $pipes,
1393            null,
1394            $GLOBALS['environment'] + [
1395                "TEST_PHP_WORKER" => $i,
1396                "TEST_PHP_URI" => $sockUri,
1397            ],
1398            [
1399                "suppress_errors" => true,
1400                'create_new_console' => true,
1401            ]
1402        );
1403        if ($proc === false) {
1404            kill_children($workerProcs);
1405            error("Failed to spawn worker $i");
1406        }
1407        $workerProcs[$i] = $proc;
1408    }
1409
1410    for ($i = 1; $i <= $workers; $i++) {
1411        $workerSock = stream_socket_accept($listenSock, 5);
1412        if ($workerSock === false) {
1413            kill_children($workerProcs);
1414            error("Failed to accept connection from worker.");
1415        }
1416
1417        $greeting = base64_encode(serialize([
1418            "type" => "hello",
1419            "GLOBALS" => $GLOBALS,
1420            "constants" => [
1421                "INIT_DIR" => INIT_DIR,
1422                "TEST_PHP_SRCDIR" => TEST_PHP_SRCDIR,
1423            ]
1424        ])) . "\n";
1425
1426        stream_set_timeout($workerSock, 5);
1427        if (fwrite($workerSock, $greeting) === false) {
1428            kill_children($workerProcs);
1429            error("Failed to send greeting to worker.");
1430        }
1431
1432        $rawReply = fgets($workerSock);
1433        if ($rawReply === false) {
1434            kill_children($workerProcs);
1435            error("Failed to read greeting reply from worker.");
1436        }
1437
1438        $reply = unserialize(base64_decode($rawReply));
1439        if (!$reply || $reply["type"] !== "hello_reply") {
1440            kill_children($workerProcs);
1441            error("Greeting reply from worker unexpected or could not be decoded: '$rawReply'");
1442        }
1443
1444        stream_set_timeout($workerSock, 0);
1445        stream_set_blocking($workerSock, false);
1446
1447        $workerID = $reply["workerID"];
1448        $workerSocks[$workerID] = $workerSock;
1449    }
1450    printf("Done in %.2fs\n", microtime(true) - $startTime);
1451    echo "=====================================================================\n";
1452    echo "\n";
1453
1454    $rawMessageBuffers = [];
1455    $testsInProgress = 0;
1456
1457    // Map from conflict key to worker ID.
1458    $activeConflicts = [];
1459    // Tests waiting due to conflicts. Map from conflict key to array.
1460    $waitingTests = [];
1461
1462escape:
1463    while ($test_files || $sequentialTests || $testsInProgress > 0) {
1464        $toRead = array_values($workerSocks);
1465        $toWrite = null;
1466        $toExcept = null;
1467        if (stream_select($toRead, $toWrite, $toExcept, 10)) {
1468            foreach ($toRead as $workerSock) {
1469                $i = array_search($workerSock, $workerSocks);
1470                if ($i === false) {
1471                    kill_children($workerProcs);
1472                    error("Could not find worker stdout in array of worker stdouts, THIS SHOULD NOT HAPPEN.");
1473                }
1474                if (feof($workerSock)) {
1475                    kill_children($workerProcs);
1476                    error("Worker $i died unexpectedly");
1477                }
1478                while (false !== ($rawMessage = fgets($workerSock))) {
1479                    // work around fgets truncating things
1480                    if (($rawMessageBuffers[$i] ?? '') !== '') {
1481                        $rawMessage = $rawMessageBuffers[$i] . $rawMessage;
1482                        $rawMessageBuffers[$i] = '';
1483                    }
1484                    if (substr($rawMessage, -1) !== "\n") {
1485                        $rawMessageBuffers[$i] = $rawMessage;
1486                        continue;
1487                    }
1488
1489                    $message = unserialize(base64_decode($rawMessage));
1490                    if (!$message) {
1491                        kill_children($workerProcs);
1492                        $stuff = fread($workerSock, 65536);
1493                        error("Could not decode message from worker $i: '$rawMessage$stuff'");
1494                    }
1495
1496                    switch ($message["type"]) {
1497                        case "tests_finished":
1498                            $testsInProgress--;
1499                            foreach ($activeConflicts as $key => $workerId) {
1500                                if ($workerId === $i) {
1501                                    unset($activeConflicts[$key]);
1502                                    if (isset($waitingTests[$key])) {
1503                                        while ($test = array_pop($waitingTests[$key])) {
1504                                            $test_files[] = $test;
1505                                        }
1506                                        unset($waitingTests[$key]);
1507                                    }
1508                                }
1509                            }
1510                            $junit->mergeResults($message["junit"]);
1511                            // no break
1512                        case "ready":
1513                            // Schedule sequential tests only once we are down to one worker.
1514                            if (count($workerProcs) === 1 && $sequentialTests) {
1515                                $test_files = array_merge($test_files, $sequentialTests);
1516                                $sequentialTests = [];
1517                            }
1518                            // Batch multiple tests to reduce communication overhead.
1519                            // - When valgrind is used, communication overhead is relatively small,
1520                            //   so just use a batch size of 1.
1521                            // - If this is running a small enough number of tests,
1522                            //   reduce the batch size to give batches to more workers.
1523                            $files = [];
1524                            $maxBatchSize = $valgrind ? 1 : ($shuffle ? 4 : 32);
1525                            $averageFilesPerWorker = max(1, (int) ceil($totalFileCount / count($workerProcs)));
1526                            $batchSize = min($maxBatchSize, $averageFilesPerWorker);
1527                            while (count($files) <= $batchSize && $file = array_pop($test_files)) {
1528                                foreach ($fileConflictsWith[$file] as $conflictKey) {
1529                                    if (isset($activeConflicts[$conflictKey])) {
1530                                        $waitingTests[$conflictKey][] = $file;
1531                                        continue 2;
1532                                    }
1533                                }
1534                                $files[] = $file;
1535                            }
1536                            if ($files) {
1537                                foreach ($files as $file) {
1538                                    foreach ($fileConflictsWith[$file] as $conflictKey) {
1539                                        $activeConflicts[$conflictKey] = $i;
1540                                    }
1541                                }
1542                                $testsInProgress++;
1543                                send_message($workerSocks[$i], [
1544                                    "type" => "run_tests",
1545                                    "test_files" => $files,
1546                                    "env" => $env,
1547                                    "redir_tested" => $redir_tested
1548                                ]);
1549                            } else {
1550                                proc_terminate($workerProcs[$i]);
1551                                unset($workerProcs[$i], $workerSocks[$i]);
1552                                goto escape;
1553                            }
1554                            break;
1555                        case "test_result":
1556                            list($name, $index, $result, $resultText) = [$message["name"], $message["index"], $message["result"], $message["text"]];
1557                            foreach ($message["PHP_FAILED_TESTS"] as $category => $tests) {
1558                                $PHP_FAILED_TESTS[$category] = array_merge($PHP_FAILED_TESTS[$category], $tests);
1559                            }
1560                            $test_idx++;
1561
1562                            if ($show_progress) {
1563                                clear_show_test();
1564                            }
1565
1566                            echo $resultText;
1567
1568                            if ($show_progress) {
1569                                show_test($test_idx, count($workerProcs) . "/$workers concurrent test workers running");
1570                            }
1571
1572                            if (!is_array($name) && $result != 'REDIR') {
1573                                $test_results[$index] = $result;
1574
1575                                if ($failed_tests_file && ($result == 'XFAILED' || $result == 'XLEAKED' || $result == 'FAILED' || $result == 'WARNED' || $result == 'LEAKED')) {
1576                                    fwrite($failed_tests_file, "$index\n");
1577                                }
1578                                if ($result_tests_file) {
1579                                    fwrite($result_tests_file, "$result\t$index\n");
1580                                }
1581                            }
1582                            break;
1583                        case "error":
1584                            kill_children($workerProcs);
1585                            error("Worker $i reported error: $message[msg]");
1586                            break;
1587                        case "php_error":
1588                            kill_children($workerProcs);
1589                            $error_consts = [
1590                                'E_ERROR',
1591                                'E_WARNING',
1592                                'E_PARSE',
1593                                'E_NOTICE',
1594                                'E_CORE_ERROR',
1595                                'E_CORE_WARNING',
1596                                'E_COMPILE_ERROR',
1597                                'E_COMPILE_WARNING',
1598                                'E_USER_ERROR',
1599                                'E_USER_WARNING',
1600                                'E_USER_NOTICE',
1601                                'E_RECOVERABLE_ERROR',
1602                                'E_DEPRECATED',
1603                                'E_USER_DEPRECATED'
1604                            ];
1605                            $error_consts = array_combine(array_map('constant', $error_consts), $error_consts);
1606                            error("Worker $i reported unexpected {$error_consts[$message['errno']]}: $message[errstr] in $message[errfile] on line $message[errline]");
1607                            // no break
1608                        default:
1609                            kill_children($workerProcs);
1610                            error("Unrecognised message type '$message[type]' from worker $i");
1611                    }
1612                }
1613            }
1614        }
1615    }
1616
1617    if ($show_progress) {
1618        clear_show_test();
1619    }
1620
1621    kill_children($workerProcs);
1622
1623    if ($testsInProgress < 0) {
1624        error("$testsInProgress test batches “in progress”, which is less than zero. THIS SHOULD NOT HAPPEN.");
1625    }
1626}
1627
1628/**
1629 * Calls fwrite and retries when network writes fail with errors such as "Resource temporarily unavailable"
1630 *
1631 * @param resource $stream the stream to fwrite to
1632 * @param string $data
1633 * @return int|false
1634 */
1635function safe_fwrite($stream, string $data)
1636{
1637    // safe_fwrite was tested by adding $message['unused'] = str_repeat('a', 20_000_000); in send_message()
1638    // fwrites on tcp sockets can return false or less than strlen if the recipient is busy.
1639    // (e.g. fwrite(): Send of 577 bytes failed with errno=35 Resource temporarily unavailable)
1640    $bytes_written = 0;
1641    while ($bytes_written < strlen($data)) {
1642        $n = @fwrite($stream, substr($data, $bytes_written));
1643        if ($n === false) {
1644            $write_streams = [$stream];
1645            $read_streams = [];
1646            $except_streams = [];
1647            /* Wait for up to 10 seconds for the stream to be ready to write again. */
1648            $result = stream_select($read_streams, $write_streams, $except_streams, 10);
1649            if (!$result) {
1650                echo "ERROR: send_message() stream_select() failed\n";
1651                return false;
1652            }
1653            $n = @fwrite($stream, substr($data, $bytes_written));
1654            if ($n === false) {
1655                echo "ERROR: send_message() Failed to write chunk after stream_select: " . error_get_last()['message'] . "\n";
1656                return false;
1657            }
1658        }
1659        $bytes_written += $n;
1660    }
1661    return $bytes_written;
1662}
1663
1664function send_message($stream, array $message): void
1665{
1666    $blocking = stream_get_meta_data($stream)["blocked"];
1667    stream_set_blocking($stream, true);
1668    safe_fwrite($stream, base64_encode(serialize($message)) . "\n");
1669    stream_set_blocking($stream, $blocking);
1670}
1671
1672function kill_children(array $children): void
1673{
1674    foreach ($children as $child) {
1675        if ($child) {
1676            proc_terminate($child);
1677        }
1678    }
1679}
1680
1681function run_worker(): void
1682{
1683    global $workerID, $workerSock;
1684
1685    global $junit;
1686
1687    $sockUri = getenv("TEST_PHP_URI");
1688
1689    $workerSock = stream_socket_client($sockUri, $_, $_, 5) or error("Couldn't connect to $sockUri");
1690
1691    $greeting = fgets($workerSock);
1692    $greeting = unserialize(base64_decode($greeting)) or die("Could not decode greeting\n");
1693    if ($greeting["type"] !== "hello") {
1694        error("Unexpected greeting of type $greeting[type]");
1695    }
1696
1697    set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($workerSock): bool {
1698        if (error_reporting() & $errno) {
1699            send_message($workerSock, compact('errno', 'errstr', 'errfile', 'errline') + [
1700                'type' => 'php_error'
1701            ]);
1702        }
1703
1704        return true;
1705    });
1706
1707    foreach ($greeting["GLOBALS"] as $var => $value) {
1708        if ($var !== "workerID" && $var !== "workerSock" && $var !== "GLOBALS") {
1709            $GLOBALS[$var] = $value;
1710        }
1711    }
1712    foreach ($greeting["constants"] as $const => $value) {
1713        define($const, $value);
1714    }
1715
1716    send_message($workerSock, [
1717        "type" => "hello_reply",
1718        "workerID" => $workerID
1719    ]);
1720
1721    send_message($workerSock, [
1722        "type" => "ready"
1723    ]);
1724
1725    while (($command = fgets($workerSock))) {
1726        $command = unserialize(base64_decode($command));
1727
1728        switch ($command["type"]) {
1729            case "run_tests":
1730                run_all_tests($command["test_files"], $command["env"], $command["redir_tested"]);
1731                send_message($workerSock, [
1732                    "type" => "tests_finished",
1733                    "junit" => $junit->isEnabled() ? $junit : null,
1734                ]);
1735                $junit->clear();
1736                break;
1737            default:
1738                send_message($workerSock, [
1739                    "type" => "error",
1740                    "msg" => "Unrecognised message type: $command[type]"
1741                ]);
1742                break 2;
1743        }
1744    }
1745}
1746
1747//
1748//  Show file or result block
1749//
1750function show_file_block(string $file, string $block, ?string $section = null): void
1751{
1752    global $cfg;
1753    global $colorize;
1754
1755    if ($cfg['show'][$file]) {
1756        if (is_null($section)) {
1757            $section = strtoupper($file);
1758        }
1759        if ($section === 'DIFF' && $colorize) {
1760            // '-' is Light Red for removal, '+' is Light Green for addition
1761            $block = preg_replace('/^[0-9]+\-\s.*$/m', "\e[1;31m\\0\e[0m", $block);
1762            $block = preg_replace('/^[0-9]+\+\s.*$/m', "\e[1;32m\\0\e[0m", $block);
1763        }
1764
1765        echo "\n========" . $section . "========\n";
1766        echo rtrim($block);
1767        echo "\n========DONE========\n";
1768    }
1769}
1770
1771function skip_test(string $tested, string $tested_file, string $shortname, string $reason): string
1772{
1773    global $junit;
1774
1775    show_result('SKIP', $tested, $tested_file, "reason: $reason");
1776    $junit->initSuite($junit->getSuiteName($shortname));
1777    $junit->markTestAs('SKIP', $shortname, $tested, 0, $reason);
1778    return 'SKIPPED';
1779}
1780
1781//
1782//  Run an individual test case.
1783//
1784/**
1785 * @param string|array $file
1786 */
1787function run_test(string $php, $file, array $env): string
1788{
1789    global $log_format, $ini_overwrites, $PHP_FAILED_TESTS;
1790    global $pass_options, $DETAILED, $IN_REDIRECT, $test_cnt, $test_idx;
1791    global $valgrind, $temp_source, $temp_target, $cfg, $environment;
1792    global $no_clean;
1793    global $SHOW_ONLY_GROUPS;
1794    global $no_file_cache;
1795    global $slow_min_ms;
1796    global $preload, $file_cache;
1797    global $num_repeats;
1798    // Parallel testing
1799    global $workerID;
1800    global $show_progress;
1801
1802    // Temporary
1803    /** @var JUnit $junit */
1804    global $junit;
1805
1806    static $skipCache;
1807    if (!$skipCache) {
1808        $enableSkipCache = !($env['DISABLE_SKIP_CACHE'] ?? '0');
1809        $skipCache = new SkipCache($enableSkipCache, $cfg['keep']['skip']);
1810    }
1811
1812    $orig_php = $php;
1813    $php = escapeshellarg($php);
1814
1815    $retried = false;
1816retry:
1817
1818    $org_file = $file;
1819
1820    $php_cgi = $env['TEST_PHP_CGI_EXECUTABLE'] ?? null;
1821    $phpdbg = $env['TEST_PHPDBG_EXECUTABLE'] ?? null;
1822
1823    if (is_array($file)) {
1824        $file = $file[0];
1825    }
1826
1827    if ($DETAILED) {
1828        echo "
1829=================
1830TEST $file
1831";
1832    }
1833
1834    $shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file);
1835    $tested_file = $shortname;
1836
1837    try {
1838        $test = new TestFile($file, (bool)$IN_REDIRECT);
1839    } catch (BorkageException $ex) {
1840        show_result("BORK", $ex->getMessage(), $tested_file);
1841        $PHP_FAILED_TESTS['BORKED'][] = [
1842            'name' => $file,
1843            'test_name' => '',
1844            'output' => '',
1845            'diff' => '',
1846            'info' => "{$ex->getMessage()} [$file]",
1847        ];
1848
1849        $junit->markTestAs('BORK', $shortname, $tested_file, 0, $ex->getMessage());
1850        return 'BORKED';
1851    }
1852
1853    $tested = $test->getName();
1854
1855    if ($test->hasSection('FILE_EXTERNAL')) {
1856        if ($num_repeats > 1) {
1857            return skip_test($tested, $tested_file, $shortname, 'Test with FILE_EXTERNAL might not be repeatable');
1858        }
1859    }
1860
1861    if ($test->hasSection('CAPTURE_STDIO')) {
1862        $capture = $test->getSection('CAPTURE_STDIO');
1863        $captureStdIn = stripos($capture, 'STDIN') !== false;
1864        $captureStdOut = stripos($capture, 'STDOUT') !== false;
1865        $captureStdErr = stripos($capture, 'STDERR') !== false;
1866    } else {
1867        $captureStdIn = true;
1868        $captureStdOut = true;
1869        $captureStdErr = true;
1870    }
1871    if ($captureStdOut && $captureStdErr) {
1872        $cmdRedirect = ' 2>&1';
1873    } else {
1874        $cmdRedirect = '';
1875    }
1876
1877    /* For GET/POST/PUT tests, check if cgi sapi is available and if it is, use it. */
1878    $uses_cgi = false;
1879    if ($test->isCGI()) {
1880        if (!$php_cgi) {
1881            return skip_test($tested, $tested_file, $shortname, 'CGI not available');
1882        }
1883        $php = escapeshellarg($php_cgi) . ' -C ';
1884        $uses_cgi = true;
1885        if ($num_repeats > 1) {
1886            return skip_test($tested, $tested_file, $shortname, 'CGI does not support --repeat');
1887        }
1888    }
1889
1890    /* For phpdbg tests, check if phpdbg sapi is available and if it is, use it. */
1891    $extra_options = '';
1892    if ($test->hasSection('PHPDBG')) {
1893        if (isset($phpdbg)) {
1894            $php = escapeshellarg($phpdbg) . ' -qIb';
1895
1896            // Additional phpdbg command line options for sections that need to
1897            // be run straight away. For example, EXTENSIONS, SKIPIF, CLEAN.
1898            $extra_options = '-rr';
1899        } else {
1900            return skip_test($tested, $tested_file, $shortname, 'phpdbg not available');
1901        }
1902        if ($num_repeats > 1) {
1903            return skip_test($tested, $tested_file, $shortname, 'phpdbg does not support --repeat');
1904        }
1905    }
1906
1907    foreach (['CLEAN', 'STDIN', 'CAPTURE_STDIO'] as $section) {
1908        if ($test->hasSection($section)) {
1909            if ($num_repeats > 1) {
1910                return skip_test($tested, $tested_file, $shortname, "Test with $section might not be repeatable");
1911            }
1912        }
1913    }
1914
1915    if ($show_progress && !$workerID) {
1916        show_test($test_idx, $shortname);
1917    }
1918
1919    if (is_array($IN_REDIRECT)) {
1920        $temp_dir = $test_dir = $IN_REDIRECT['dir'];
1921    } else {
1922        $temp_dir = $test_dir = realpath(dirname($file));
1923    }
1924
1925    if ($temp_source && $temp_target) {
1926        $temp_dir = str_replace($temp_source, $temp_target, $temp_dir);
1927    }
1928
1929    $main_file_name = basename($file, 'phpt');
1930
1931    $diff_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'diff';
1932    $log_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'log';
1933    $exp_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'exp';
1934    $output_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'out';
1935    $memcheck_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'mem';
1936    $sh_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'sh';
1937    $temp_file = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'php';
1938    $test_file = $test_dir . DIRECTORY_SEPARATOR . $main_file_name . 'php';
1939    $temp_skipif = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'skip.php';
1940    $test_skipif = $test_dir . DIRECTORY_SEPARATOR . $main_file_name . 'skip.php';
1941    $temp_clean = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'clean.php';
1942    $test_clean = $test_dir . DIRECTORY_SEPARATOR . $main_file_name . 'clean.php';
1943    $preload_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'preload.php';
1944    $tmp_post = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'post';
1945    $tmp_relative_file = str_replace(__DIR__ . DIRECTORY_SEPARATOR, '', $test_file) . 't';
1946
1947    if ($temp_source && $temp_target) {
1948        $temp_skipif .= 's';
1949        $temp_file .= 's';
1950        $temp_clean .= 's';
1951        $copy_file = $temp_dir . DIRECTORY_SEPARATOR . basename($file) . '.phps';
1952
1953        if (!is_dir(dirname($copy_file))) {
1954            mkdir(dirname($copy_file), 0777, true) or error("Cannot create output directory - " . dirname($copy_file));
1955        }
1956
1957        if ($test->hasSection('FILE')) {
1958            save_text($copy_file, $test->getSection('FILE'));
1959        }
1960    }
1961
1962    if (is_array($IN_REDIRECT)) {
1963        $tested = $IN_REDIRECT['prefix'] . ' ' . $tested;
1964        $tested_file = $tmp_relative_file;
1965        $shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $tested_file);
1966    }
1967
1968    // unlink old test results
1969    @unlink($diff_filename);
1970    @unlink($log_filename);
1971    @unlink($exp_filename);
1972    @unlink($output_filename);
1973    @unlink($memcheck_filename);
1974    @unlink($sh_filename);
1975    @unlink($temp_file);
1976    @unlink($test_file);
1977    @unlink($temp_skipif);
1978    @unlink($test_skipif);
1979    @unlink($tmp_post);
1980    @unlink($temp_clean);
1981    @unlink($test_clean);
1982    @unlink($preload_filename);
1983
1984    // Reset environment from any previous test.
1985    $env['REDIRECT_STATUS'] = '';
1986    $env['QUERY_STRING'] = '';
1987    $env['PATH_TRANSLATED'] = '';
1988    $env['SCRIPT_FILENAME'] = '';
1989    $env['REQUEST_METHOD'] = '';
1990    $env['CONTENT_TYPE'] = '';
1991    $env['CONTENT_LENGTH'] = '';
1992    $env['TZ'] = '';
1993
1994    if ($test->sectionNotEmpty('ENV')) {
1995        $env_str = str_replace('{PWD}', dirname($file), $test->getSection('ENV'));
1996        foreach (explode("\n", $env_str) as $e) {
1997            $e = explode('=', trim($e), 2);
1998
1999            if (!empty($e[0]) && isset($e[1])) {
2000                $env[$e[0]] = $e[1];
2001            }
2002        }
2003    }
2004
2005    // Default ini settings
2006    $ini_settings = $workerID ? ['opcache.cache_id' => "worker$workerID"] : [];
2007
2008    // Additional required extensions
2009    $extensions = [];
2010    if ($test->hasSection('EXTENSIONS')) {
2011        $extensions = preg_split("/[\n\r]+/", trim($test->getSection('EXTENSIONS')));
2012    }
2013    if (is_array($IN_REDIRECT) && $IN_REDIRECT['EXTENSIONS'] != []) {
2014        $extensions = array_merge($extensions, $IN_REDIRECT['EXTENSIONS']);
2015    }
2016
2017    /* Load required extensions */
2018    if ($extensions != []) {
2019        $ext_params = [];
2020        settings2array($ini_overwrites, $ext_params);
2021        $ext_params = settings2params($ext_params);
2022        [$ext_dir, $loaded] = $skipCache->getExtensions("$orig_php $pass_options $extra_options $ext_params $no_file_cache");
2023        $ext_prefix = IS_WINDOWS ? "php_" : "";
2024        $missing = [];
2025        foreach ($extensions as $req_ext) {
2026            if (!in_array($req_ext, $loaded, true)) {
2027                if ($req_ext == 'opcache' || $req_ext == 'xdebug') {
2028                    $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX;
2029                    $ini_settings['zend_extension'][] = $ext_file;
2030                } else {
2031                    $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX;
2032                    $ini_settings['extension'][] = $ext_file;
2033                }
2034                if (!is_readable($ext_file)) {
2035                    $missing[] = $req_ext;
2036                }
2037            }
2038        }
2039        if ($missing) {
2040            $message = 'Required extension' . (count($missing) > 1 ? 's' : '')
2041                . ' missing: ' . implode(', ', $missing);
2042            return skip_test($tested, $tested_file, $shortname, $message);
2043        }
2044    }
2045
2046    // additional ini overwrites
2047    //$ini_overwrites[] = 'setting=value';
2048    settings2array($ini_overwrites, $ini_settings);
2049
2050    $orig_ini_settings = settings2params($ini_settings);
2051
2052    if ($file_cache !== null) {
2053        $ini_settings['opcache.file_cache'] = '/tmp';
2054        // Make sure warnings still show up on the second run.
2055        $ini_settings['opcache.record_warnings'] = '1';
2056        // File cache is currently incompatible with JIT.
2057        $ini_settings['opcache.jit'] = '0';
2058        if ($file_cache === 'use') {
2059            // Disable timestamp validation in order to fetch from file cache,
2060            // even though all the files are re-created.
2061            $ini_settings['opcache.validate_timestamps'] = '0';
2062        }
2063    } else if ($num_repeats > 1) {
2064        // Make sure warnings still show up on the second run.
2065        $ini_settings['opcache.record_warnings'] = '1';
2066    }
2067
2068    // Any special ini settings
2069    // these may overwrite the test defaults...
2070    if ($test->hasSection('INI')) {
2071        $ini = str_replace('{PWD}', dirname($file), $test->getSection('INI'));
2072        $ini = str_replace('{TMP}', sys_get_temp_dir(), $ini);
2073        $replacement = IS_WINDOWS ? '"' . PHP_BINARY . ' -r \"while ($in = fgets(STDIN)) echo $in;\" > $1"' : 'tee $1 >/dev/null';
2074        $ini = preg_replace('/{MAIL:(\S+)}/', $replacement, $ini);
2075        $skip = false;
2076        $ini = preg_replace_callback('/{ENV:(\S+)}/', function ($m) use (&$skip) {
2077            $name = $m[1];
2078            $value = getenv($name);
2079            if ($value === false) {
2080                $skip = sprintf('Environment variable %s is not set', $name);
2081                return '';
2082            }
2083            return $value;
2084        }, $ini);
2085        if ($skip !== false) {
2086            return skip_test($tested, $tested_file, $shortname, $skip);
2087        }
2088        settings2array(preg_split("/[\n\r]+/", $ini), $ini_settings);
2089
2090        if (isset($ini_settings['opcache.opt_debug_level'])) {
2091            if ($num_repeats > 1) {
2092                return skip_test($tested, $tested_file, $shortname, 'opt_debug_level tests are not repeatable');
2093            }
2094        }
2095    }
2096
2097    $ini_settings = settings2params($ini_settings);
2098
2099    $env['TEST_PHP_EXTRA_ARGS'] = $pass_options . ' ' . $ini_settings;
2100
2101    // Check if test should be skipped.
2102    $info = '';
2103    $warn = false;
2104
2105    if ($test->sectionNotEmpty('SKIPIF')) {
2106        show_file_block('skip', $test->getSection('SKIPIF'));
2107        $extra = !IS_WINDOWS ?
2108            "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
2109
2110        if ($valgrind) {
2111            $env['USE_ZEND_ALLOC'] = '0';
2112            $env['ZEND_DONT_UNLOAD_MODULES'] = 1;
2113        }
2114
2115        $junit->startTimer($shortname);
2116
2117        $startTime = microtime(true);
2118        $commandLine = "$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0";
2119        $output = $skipCache->checkSkip($commandLine, $test->getSection('SKIPIF'), $test_skipif, $temp_skipif, $env);
2120
2121        $time = microtime(true) - $startTime;
2122        $junit->stopTimer($shortname);
2123
2124        if ($time > $slow_min_ms / 1000) {
2125            $PHP_FAILED_TESTS['SLOW'][] = [
2126                'name' => $file,
2127                'test_name' => 'SKIPIF of ' . $tested . " [$tested_file]",
2128                'output' => '',
2129                'diff' => '',
2130                'info' => $time,
2131            ];
2132        }
2133
2134        if (!$cfg['keep']['skip']) {
2135            @unlink($test_skipif);
2136        }
2137
2138        if (!strncasecmp('skip', $output, 4)) {
2139            if (preg_match('/^skip\s*(.+)/i', $output, $m)) {
2140                show_result('SKIP', $tested, $tested_file, "reason: $m[1]");
2141            } else {
2142                show_result('SKIP', $tested, $tested_file, '');
2143            }
2144
2145            $message = !empty($m[1]) ? $m[1] : '';
2146            $junit->markTestAs('SKIP', $shortname, $tested, null, $message);
2147            return 'SKIPPED';
2148        }
2149
2150        if (!strncasecmp('info', $output, 4) && preg_match('/^info\s*(.+)/i', $output, $m)) {
2151            $info = " (info: $m[1])";
2152        } elseif (!strncasecmp('warn', $output, 4) && preg_match('/^warn\s+(.+)/i', $output, $m)) {
2153            $warn = true; /* only if there is a reason */
2154            $info = " (warn: $m[1])";
2155        } elseif (!strncasecmp('xfail', $output, 5)) {
2156            // Pretend we have an XFAIL section
2157            $test->setSection('XFAIL', ltrim(substr($output, 5)));
2158        } elseif (!strncasecmp('xleak', $output, 5)) {
2159            // Pretend we have an XLEAK section
2160            $test->setSection('XLEAK', ltrim(substr($output, 5)));
2161        } elseif (!strncasecmp('flaky', $output, 5)) {
2162            // Pretend we have a FLAKY section
2163            $test->setSection('FLAKY', ltrim(substr($output, 5)));
2164        } elseif ($output !== '') {
2165            show_result("BORK", $output, $tested_file, 'reason: invalid output from SKIPIF');
2166            $PHP_FAILED_TESTS['BORKED'][] = [
2167                'name' => $file,
2168                'test_name' => '',
2169                'output' => '',
2170                'diff' => '',
2171                'info' => "$output [$file]",
2172            ];
2173
2174            $junit->markTestAs('BORK', $shortname, $tested, null, $output);
2175            return 'BORKED';
2176        }
2177    }
2178
2179    if (!extension_loaded("zlib") && $test->hasAnySections("GZIP_POST", "DEFLATE_POST")) {
2180        $message = "ext/zlib required";
2181        show_result('SKIP', $tested, $tested_file, "reason: $message");
2182        $junit->markTestAs('SKIP', $shortname, $tested, null, $message);
2183        return 'SKIPPED';
2184    }
2185
2186    if ($test->hasSection('REDIRECTTEST')) {
2187        $test_files = [];
2188
2189        $IN_REDIRECT = eval($test->getSection('REDIRECTTEST'));
2190        $IN_REDIRECT['via'] = "via [$shortname]\n\t";
2191        $IN_REDIRECT['dir'] = realpath(dirname($file));
2192        $IN_REDIRECT['prefix'] = $tested;
2193        $IN_REDIRECT['EXTENSIONS'] = $extensions;
2194
2195        if (!empty($IN_REDIRECT['TESTS'])) {
2196            if (is_array($org_file)) {
2197                $test_files[] = $org_file[1];
2198            } else {
2199                $GLOBALS['test_files'] = $test_files;
2200                find_files($IN_REDIRECT['TESTS']);
2201
2202                foreach ($GLOBALS['test_files'] as $f) {
2203                    $test_files[] = [$f, $file];
2204                }
2205            }
2206            $test_cnt += count($test_files) - 1;
2207            $test_idx--;
2208
2209            show_redirect_start($IN_REDIRECT['TESTS'], $tested, $tested_file);
2210
2211            // set up environment
2212            $redirenv = array_merge($environment, $IN_REDIRECT['ENV']);
2213            $redirenv['REDIR_TEST_DIR'] = realpath($IN_REDIRECT['TESTS']) . DIRECTORY_SEPARATOR;
2214
2215            usort($test_files, "test_sort");
2216            run_all_tests($test_files, $redirenv, $tested);
2217
2218            show_redirect_ends($IN_REDIRECT['TESTS'], $tested, $tested_file);
2219
2220            // a redirected test never fails
2221            $IN_REDIRECT = false;
2222
2223            $junit->markTestAs('PASS', $shortname, $tested);
2224            return 'REDIR';
2225        }
2226
2227        $bork_info = "Redirect info must contain exactly one TEST string to be used as redirect directory.";
2228        show_result("BORK", $bork_info, '', '');
2229        $PHP_FAILED_TESTS['BORKED'][] = [
2230            'name' => $file,
2231            'test_name' => '',
2232            'output' => '',
2233            'diff' => '',
2234            'info' => "$bork_info [$file]",
2235        ];
2236    }
2237
2238    if (is_array($org_file) || $test->hasSection('REDIRECTTEST')) {
2239        if (is_array($org_file)) {
2240            $file = $org_file[0];
2241        }
2242
2243        $bork_info = "Redirected test did not contain redirection info";
2244        show_result("BORK", $bork_info, '', '');
2245        $PHP_FAILED_TESTS['BORKED'][] = [
2246            'name' => $file,
2247            'test_name' => '',
2248            'output' => '',
2249            'diff' => '',
2250            'info' => "$bork_info [$file]",
2251        ];
2252
2253        $junit->markTestAs('BORK', $shortname, $tested, null, $bork_info);
2254
2255        return 'BORKED';
2256    }
2257
2258    // We've satisfied the preconditions - run the test!
2259    if ($test->hasSection('FILE')) {
2260        show_file_block('php', $test->getSection('FILE'), 'TEST');
2261        save_text($test_file, $test->getSection('FILE'), $temp_file);
2262    } else {
2263        $test_file = "";
2264    }
2265
2266    if ($test->hasSection('GET')) {
2267        $query_string = trim($test->getSection('GET'));
2268    } else {
2269        $query_string = '';
2270    }
2271
2272    $env['REDIRECT_STATUS'] = '1';
2273    if (empty($env['QUERY_STRING'])) {
2274        $env['QUERY_STRING'] = $query_string;
2275    }
2276    if (empty($env['PATH_TRANSLATED'])) {
2277        $env['PATH_TRANSLATED'] = $test_file;
2278    }
2279    if (empty($env['SCRIPT_FILENAME'])) {
2280        $env['SCRIPT_FILENAME'] = $test_file;
2281    }
2282
2283    if ($test->hasSection('COOKIE')) {
2284        $env['HTTP_COOKIE'] = trim($test->getSection('COOKIE'));
2285    } else {
2286        $env['HTTP_COOKIE'] = '';
2287    }
2288
2289    $args = $test->hasSection('ARGS') ? ' -- ' . $test->getSection('ARGS') : '';
2290
2291    if ($preload && !empty($test_file)) {
2292        save_text($preload_filename, "<?php opcache_compile_file('$test_file');");
2293        $local_pass_options = $pass_options;
2294        unset($pass_options);
2295        $pass_options = $local_pass_options;
2296        $pass_options .= " -d opcache.preload=" . $preload_filename;
2297    }
2298
2299    if ($test->sectionNotEmpty('POST_RAW')) {
2300        $post = trim($test->getSection('POST_RAW'));
2301        $raw_lines = explode("\n", $post);
2302
2303        $request = '';
2304        $started = false;
2305
2306        foreach ($raw_lines as $line) {
2307            if (empty($env['CONTENT_TYPE']) && preg_match('/^Content-Type:(.*)/i', $line, $res)) {
2308                $env['CONTENT_TYPE'] = trim(str_replace("\r", '', $res[1]));
2309                continue;
2310            }
2311
2312            if ($started) {
2313                $request .= "\n";
2314            }
2315
2316            $started = true;
2317            $request .= $line;
2318        }
2319
2320        $env['CONTENT_LENGTH'] = strlen($request);
2321        if (empty($env['REQUEST_METHOD'])) {
2322            $env['REQUEST_METHOD'] = 'POST';
2323        }
2324
2325        if (empty($request)) {
2326            $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request');
2327            return 'BORKED';
2328        }
2329
2330        save_text($tmp_post, $request);
2331        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
2332    } elseif ($test->sectionNotEmpty('PUT')) {
2333        $post = trim($test->getSection('PUT'));
2334        $raw_lines = explode("\n", $post);
2335
2336        $request = '';
2337        $started = false;
2338
2339        foreach ($raw_lines as $line) {
2340            if (empty($env['CONTENT_TYPE']) && preg_match('/^Content-Type:(.*)/i', $line, $res)) {
2341                $env['CONTENT_TYPE'] = trim(str_replace("\r", '', $res[1]));
2342                continue;
2343            }
2344
2345            if ($started) {
2346                $request .= "\n";
2347            }
2348
2349            $started = true;
2350            $request .= $line;
2351        }
2352
2353        $env['CONTENT_LENGTH'] = strlen($request);
2354        $env['REQUEST_METHOD'] = 'PUT';
2355
2356        if (empty($request)) {
2357            $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request');
2358            return 'BORKED';
2359        }
2360
2361        save_text($tmp_post, $request);
2362        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
2363    } elseif ($test->sectionNotEmpty('POST')) {
2364        $post = trim($test->getSection('POST'));
2365        $content_length = strlen($post);
2366        save_text($tmp_post, $post);
2367
2368        $env['REQUEST_METHOD'] = 'POST';
2369        if (empty($env['CONTENT_TYPE'])) {
2370            $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
2371        }
2372
2373        if (empty($env['CONTENT_LENGTH'])) {
2374            $env['CONTENT_LENGTH'] = $content_length;
2375        }
2376
2377        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
2378    } elseif ($test->sectionNotEmpty('GZIP_POST')) {
2379        $post = trim($test->getSection('GZIP_POST'));
2380        $post = gzencode($post, 9, FORCE_GZIP);
2381        $env['HTTP_CONTENT_ENCODING'] = 'gzip';
2382
2383        save_text($tmp_post, $post);
2384        $content_length = strlen($post);
2385
2386        $env['REQUEST_METHOD'] = 'POST';
2387        $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
2388        $env['CONTENT_LENGTH'] = $content_length;
2389
2390        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
2391    } elseif ($test->sectionNotEmpty('DEFLATE_POST')) {
2392        $post = trim($test->getSection('DEFLATE_POST'));
2393        $post = gzcompress($post, 9);
2394        $env['HTTP_CONTENT_ENCODING'] = 'deflate';
2395        save_text($tmp_post, $post);
2396        $content_length = strlen($post);
2397
2398        $env['REQUEST_METHOD'] = 'POST';
2399        $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
2400        $env['CONTENT_LENGTH'] = $content_length;
2401
2402        $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
2403    } else {
2404        $env['REQUEST_METHOD'] = 'GET';
2405        $env['CONTENT_TYPE'] = '';
2406        $env['CONTENT_LENGTH'] = '';
2407
2408        $repeat_option = $num_repeats > 1 ? "--repeat $num_repeats" : "";
2409        $cmd = "$php $pass_options $repeat_option $ini_settings -f \"$test_file\" $args$cmdRedirect";
2410    }
2411
2412    $orig_cmd = $cmd;
2413    if ($valgrind) {
2414        $env['USE_ZEND_ALLOC'] = '0';
2415        $env['ZEND_DONT_UNLOAD_MODULES'] = 1;
2416
2417        $cmd = $valgrind->wrapCommand($cmd, $memcheck_filename, strpos($test_file, "pcre") !== false);
2418    }
2419
2420    if ($test->hasSection('XLEAK')) {
2421        $env['ZEND_ALLOC_PRINT_LEAKS'] = '0';
2422        if (isset($env['SKIP_ASAN'])) {
2423            // $env['LSAN_OPTIONS'] = 'detect_leaks=0';
2424            /* For unknown reasons, LSAN_OPTIONS=detect_leaks=0 would occasionally not be picked up
2425             * in CI. Skip the test with ASAN, as it's not worth investegating. */
2426            return skip_test($tested, $tested_file, $shortname, 'xleak does not work with asan');
2427        }
2428    }
2429
2430    if ($DETAILED) {
2431        echo "
2432CONTENT_LENGTH  = " . $env['CONTENT_LENGTH'] . "
2433CONTENT_TYPE    = " . $env['CONTENT_TYPE'] . "
2434PATH_TRANSLATED = " . $env['PATH_TRANSLATED'] . "
2435QUERY_STRING    = " . $env['QUERY_STRING'] . "
2436REDIRECT_STATUS = " . $env['REDIRECT_STATUS'] . "
2437REQUEST_METHOD  = " . $env['REQUEST_METHOD'] . "
2438SCRIPT_FILENAME = " . $env['SCRIPT_FILENAME'] . "
2439HTTP_COOKIE     = " . $env['HTTP_COOKIE'] . "
2440COMMAND $cmd
2441";
2442    }
2443
2444    $junit->startTimer($shortname);
2445    $hrtime = hrtime();
2446    $startTime = $hrtime[0] * 1000000000 + $hrtime[1];
2447
2448    $stdin = $test->hasSection('STDIN') ? $test->getSection('STDIN') : null;
2449    $out = system_with_timeout($cmd, $env, $stdin, $captureStdIn, $captureStdOut, $captureStdErr);
2450
2451    $junit->stopTimer($shortname);
2452    $hrtime = hrtime();
2453    $time = $hrtime[0] * 1000000000 + $hrtime[1] - $startTime;
2454    if ($time >= $slow_min_ms * 1000000) {
2455        $PHP_FAILED_TESTS['SLOW'][] = [
2456            'name' => $file,
2457            'test_name' => $tested . " [$tested_file]",
2458            'output' => '',
2459            'diff' => '',
2460            'info' => $time / 1000000000,
2461        ];
2462    }
2463
2464    // Remember CLEAN output to report borked test if it otherwise passes.
2465    $clean_output = null;
2466    if ((!$no_clean || $cfg['keep']['clean']) && $test->sectionNotEmpty('CLEAN')) {
2467        show_file_block('clean', $test->getSection('CLEAN'));
2468        save_text($test_clean, trim($test->getSection('CLEAN')), $temp_clean);
2469
2470        if (!$no_clean) {
2471            $extra = !IS_WINDOWS ?
2472                "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
2473            $clean_output = system_with_timeout("$extra $orig_php $pass_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env);
2474        }
2475
2476        if (!$cfg['keep']['clean']) {
2477            @unlink($test_clean);
2478        }
2479    }
2480
2481    $leaked = false;
2482    $passed = false;
2483
2484    if ($valgrind) { // leak check
2485        $leaked = filesize($memcheck_filename) > 0;
2486
2487        if (!$leaked) {
2488            @unlink($memcheck_filename);
2489        }
2490    }
2491
2492    if ($num_repeats > 1) {
2493        // In repeat mode, retain the output before the first execution,
2494        // and of the last execution. Do this early, because the trimming below
2495        // makes the newline handling complicated.
2496        $separator1 = "Executing for the first time...\n";
2497        $separator1_pos = strpos($out, $separator1);
2498        if ($separator1_pos !== false) {
2499            $separator2 = "Finished execution, repeating...\n";
2500            $separator2_pos = strrpos($out, $separator2);
2501            if ($separator2_pos !== false) {
2502                $out = substr($out, 0, $separator1_pos)
2503                     . substr($out, $separator2_pos + strlen($separator2));
2504            } else {
2505                $out = substr($out, 0, $separator1_pos)
2506                     . substr($out, $separator1_pos + strlen($separator1));
2507            }
2508        }
2509    }
2510
2511    // Does the output match what is expected?
2512    $output = preg_replace("/\r\n/", "\n", trim($out));
2513
2514    /* when using CGI, strip the headers from the output */
2515    $headers = [];
2516
2517    if ($uses_cgi && preg_match("/^(.*?)\r?\n\r?\n(.*)/s", $out, $match)) {
2518        $output = trim($match[2]);
2519        $rh = preg_split("/[\n\r]+/", $match[1]);
2520
2521        foreach ($rh as $line) {
2522            if (strpos($line, ':') !== false) {
2523                $line = explode(':', $line, 2);
2524                $headers[trim($line[0])] = trim($line[1]);
2525            }
2526        }
2527    }
2528
2529    $wanted_headers = null;
2530    $output_headers = null;
2531    $failed_headers = false;
2532
2533    if ($test->hasSection('EXPECTHEADERS')) {
2534        $want = [];
2535        $wanted_headers = [];
2536        $lines = preg_split("/[\n\r]+/", $test->getSection('EXPECTHEADERS'));
2537
2538        foreach ($lines as $line) {
2539            if (strpos($line, ':') !== false) {
2540                $line = explode(':', $line, 2);
2541                $want[trim($line[0])] = trim($line[1]);
2542                $wanted_headers[] = trim($line[0]) . ': ' . trim($line[1]);
2543            }
2544        }
2545
2546        $output_headers = [];
2547
2548        foreach ($want as $k => $v) {
2549            if (isset($headers[$k])) {
2550                $output_headers[] = $k . ': ' . $headers[$k];
2551            }
2552
2553            if (!isset($headers[$k]) || $headers[$k] != $v) {
2554                $failed_headers = true;
2555            }
2556        }
2557
2558        $wanted_headers = implode("\n", $wanted_headers);
2559        $output_headers = implode("\n", $output_headers);
2560    }
2561
2562    show_file_block('out', $output);
2563
2564    if ($preload) {
2565        $output = trim(preg_replace("/\n?Warning: Can't preload [^\n]*\n?/", "", $output));
2566    }
2567
2568    if ($test->hasAnySections('EXPECTF', 'EXPECTREGEX')) {
2569        if ($test->hasSection('EXPECTF')) {
2570            $wanted = trim($test->getSection('EXPECTF'));
2571        } else {
2572            $wanted = trim($test->getSection('EXPECTREGEX'));
2573        }
2574
2575        show_file_block('exp', $wanted);
2576        $wanted_re = preg_replace('/\r\n/', "\n", $wanted);
2577
2578        if ($test->hasSection('EXPECTF')) {
2579            $wanted_re = expectf_to_regex($wanted_re);
2580        }
2581
2582        if (preg_match('/^' . $wanted_re . '$/s', $output)) {
2583            $passed = true;
2584        }
2585    } else {
2586        $wanted = trim($test->getSection('EXPECT'));
2587        $wanted = preg_replace('/\r\n/', "\n", $wanted);
2588        show_file_block('exp', $wanted);
2589
2590        // compare and leave on success
2591        if (!strcmp($output, $wanted)) {
2592            $passed = true;
2593        }
2594
2595        $wanted_re = null;
2596    }
2597    if (!$passed && !$retried && error_may_be_retried($test, $output)) {
2598        $retried = true;
2599        goto retry;
2600    }
2601
2602    if ($passed) {
2603        if (!$cfg['keep']['php'] && !$leaked) {
2604            @unlink($test_file);
2605            @unlink($preload_filename);
2606        }
2607        @unlink($tmp_post);
2608
2609        if (!$leaked && !$failed_headers) {
2610            // If the test passed and CLEAN produced output, report test as borked.
2611            if ($clean_output) {
2612                show_result("BORK", $output, $tested_file, 'reason: invalid output from CLEAN');
2613                $PHP_FAILED_TESTS['BORKED'][] = [
2614                    'name' => $file,
2615                    'test_name' => '',
2616                    'output' => '',
2617                    'diff' => '',
2618                    'info' => "$clean_output [$file]",
2619                ];
2620
2621                $junit->markTestAs('BORK', $shortname, $tested, null, $clean_output);
2622                return 'BORKED';
2623            }
2624
2625            if ($test->hasSection('XFAIL')) {
2626                $warn = true;
2627                $info = " (warn: XFAIL section but test passes)";
2628            } elseif ($test->hasSection('XLEAK') && $valgrind) {
2629                // XLEAK with ASAN completely disables LSAN so the test is expected to pass
2630                $warn = true;
2631                $info = " (warn: XLEAK section but test passes)";
2632            } elseif ($retried) {
2633                $warn = true;
2634                $info = " (warn: Test passed on retry attempt)";
2635            } else {
2636                show_result("PASS", $tested, $tested_file, '');
2637                $junit->markTestAs('PASS', $shortname, $tested);
2638                return 'PASSED';
2639            }
2640        }
2641    }
2642
2643    // Test failed so we need to report details.
2644    if ($failed_headers) {
2645        $passed = false;
2646        $wanted = $wanted_headers . "\n--HEADERS--\n" . $wanted;
2647        $output = $output_headers . "\n--HEADERS--\n" . $output;
2648
2649        if (isset($wanted_re)) {
2650            $wanted_re = preg_quote($wanted_headers . "\n--HEADERS--\n", '/') . $wanted_re;
2651        }
2652    }
2653
2654    $restype = [];
2655
2656    if ($leaked) {
2657        $restype[] = $test->hasSection('XLEAK') ?
2658                        'XLEAK' : 'LEAK';
2659    }
2660
2661    if ($warn) {
2662        $restype[] = 'WARN';
2663    }
2664
2665    if (!$passed) {
2666        if ($test->hasSection('XFAIL')) {
2667            $restype[] = 'XFAIL';
2668            $info = '  XFAIL REASON: ' . rtrim($test->getSection('XFAIL'));
2669        } elseif ($test->hasSection('XLEAK') && $valgrind) {
2670            // XLEAK with ASAN completely disables LSAN so the test is expected to pass
2671            $restype[] = 'XLEAK';
2672            $info = '  XLEAK REASON: ' . rtrim($test->getSection('XLEAK'));
2673        } else {
2674            $restype[] = 'FAIL';
2675        }
2676    }
2677
2678    if (!$passed) {
2679        // write .exp
2680        if (strpos($log_format, 'E') !== false && file_put_contents($exp_filename, $wanted) === false) {
2681            error("Cannot create expected test output - $exp_filename");
2682        }
2683
2684        // write .out
2685        if (strpos($log_format, 'O') !== false && file_put_contents($output_filename, $output) === false) {
2686            error("Cannot create test output - $output_filename");
2687        }
2688
2689        // write .diff
2690        if (!empty($environment['TEST_PHP_DIFF_CMD'])) {
2691            $diff = generate_diff_external($environment['TEST_PHP_DIFF_CMD'], $exp_filename, $output_filename);
2692        } else {
2693            $diff = generate_diff($wanted, $wanted_re, $output);
2694        }
2695
2696        if (is_array($IN_REDIRECT)) {
2697            $orig_shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file);
2698            $diff = "# original source file: $orig_shortname\n" . $diff;
2699        }
2700        if (!$SHOW_ONLY_GROUPS || array_intersect($restype, $SHOW_ONLY_GROUPS)) {
2701            show_file_block('diff', $diff);
2702        }
2703        if (strpos($log_format, 'D') !== false && file_put_contents($diff_filename, $diff) === false) {
2704            error("Cannot create test diff - $diff_filename");
2705        }
2706
2707        // write .log
2708        if (strpos($log_format, 'L') !== false && file_put_contents($log_filename, "
2709---- EXPECTED OUTPUT
2710$wanted
2711---- ACTUAL OUTPUT
2712$output
2713---- FAILED
2714") === false) {
2715            error("Cannot create test log - $log_filename");
2716            error_report($file, $log_filename, $tested);
2717        }
2718    }
2719
2720    if (!$passed || $leaked) {
2721        // write .sh
2722        if (strpos($log_format, 'S') !== false) {
2723            $env_lines = [];
2724            foreach ($env as $env_var => $env_val) {
2725                $env_lines[] = "export $env_var=" . escapeshellarg($env_val ?? "");
2726            }
2727            $exported_environment = "\n" . implode("\n", $env_lines) . "\n";
2728            $sh_script = <<<SH
2729#!/bin/sh
2730{$exported_environment}
2731case "$1" in
2732"gdb")
2733    gdb --args {$orig_cmd}
2734    ;;
2735"lldb")
2736    lldb -- {$orig_cmd}
2737    ;;
2738"valgrind")
2739    USE_ZEND_ALLOC=0 valgrind $2 {$orig_cmd}
2740    ;;
2741"rr")
2742    rr record $2 {$orig_cmd}
2743    ;;
2744*)
2745    {$orig_cmd}
2746    ;;
2747esac
2748SH;
2749            if (file_put_contents($sh_filename, $sh_script) === false) {
2750                error("Cannot create test shell script - $sh_filename");
2751            }
2752            chmod($sh_filename, 0755);
2753        }
2754    }
2755
2756    if ($valgrind && $leaked && $cfg["show"]["mem"]) {
2757        show_file_block('mem', file_get_contents($memcheck_filename));
2758    }
2759
2760    show_result(implode('&', $restype), $tested, $tested_file, $info);
2761
2762    foreach ($restype as $type) {
2763        $PHP_FAILED_TESTS[$type . 'ED'][] = [
2764            'name' => $file,
2765            'test_name' => (is_array($IN_REDIRECT) ? $IN_REDIRECT['via'] : '') . $tested . " [$tested_file]",
2766            'output' => $output_filename,
2767            'diff' => $diff_filename,
2768            'info' => $info,
2769        ];
2770    }
2771
2772    $diff = empty($diff) ? '' : preg_replace('/\e/', '<esc>', $diff);
2773
2774    $junit->markTestAs($restype, $shortname, $tested, null, $info, $diff);
2775
2776    return $restype[0] . 'ED';
2777}
2778
2779function is_flaky(TestFile $test): bool
2780{
2781    if ($test->hasSection('FLAKY')) {
2782        return true;
2783    }
2784    if (!$test->hasSection('FILE')) {
2785        return false;
2786    }
2787    $file = $test->getSection('FILE');
2788    $flaky_functions = [
2789        'disk_free_space',
2790        'hrtime',
2791        'microtime',
2792        'sleep',
2793        'usleep',
2794    ];
2795    $regex = '(\b(' . implode('|', $flaky_functions) . ')\()i';
2796    return preg_match($regex, $file) === 1;
2797}
2798
2799function is_flaky_output(string $output): bool
2800{
2801    $messages = [
2802        '404: page not found',
2803        'address already in use',
2804        'connection refused',
2805        'deadlock',
2806        'mailbox already exists',
2807        'timed out',
2808    ];
2809    $regex = '(\b(' . implode('|', $messages) . ')\b)i';
2810    return preg_match($regex, $output) === 1;
2811}
2812
2813function error_may_be_retried(TestFile $test, string $output): bool
2814{
2815    return is_flaky_output($output)
2816        || is_flaky($test);
2817}
2818
2819function expectf_to_regex(?string $wanted): string
2820{
2821    $wanted_re = $wanted ?? '';
2822
2823    $wanted_re = preg_replace('/\r\n/', "\n", $wanted_re);
2824
2825    // do preg_quote, but miss out any %r delimited sections
2826    $temp = "";
2827    $r = "%r";
2828    $startOffset = 0;
2829    $length = strlen($wanted_re);
2830    while ($startOffset < $length) {
2831        $start = strpos($wanted_re, $r, $startOffset);
2832        if ($start !== false) {
2833            // we have found a start tag
2834            $end = strpos($wanted_re, $r, $start + 2);
2835            if ($end === false) {
2836                // unbalanced tag, ignore it.
2837                $end = $start = $length;
2838            }
2839        } else {
2840            // no more %r sections
2841            $start = $end = $length;
2842        }
2843        // quote a non re portion of the string
2844        $temp .= preg_quote(substr($wanted_re, $startOffset, $start - $startOffset), '/');
2845        // add the re unquoted.
2846        if ($end > $start) {
2847            $temp .= '(' . substr($wanted_re, $start + 2, $end - $start - 2) . ')';
2848        }
2849        $startOffset = $end + 2;
2850    }
2851    $wanted_re = $temp;
2852
2853    return strtr($wanted_re, [
2854        '%e' => preg_quote(DIRECTORY_SEPARATOR, '/'),
2855        '%s' => '[^\r\n]+',
2856        '%S' => '[^\r\n]*',
2857        '%a' => '.+?',
2858        '%A' => '.*?',
2859        '%w' => '\s*',
2860        '%i' => '[+-]?\d+',
2861        '%d' => '\d+',
2862        '%x' => '[0-9a-fA-F]+',
2863        '%f' => '[+-]?(?:\d+|(?=\.\d))(?:\.\d+)?(?:[Ee][+-]?\d+)?',
2864        '%c' => '.',
2865        '%0' => '\x00',
2866    ]);
2867}
2868/**
2869 * Map "Zend OPcache" to "opcache" and convert all ext names to lowercase.
2870 */
2871function remap_loaded_extensions_names(array $names): array
2872{
2873    $exts = [];
2874    foreach ($names as $name) {
2875        if ($name === 'Core') {
2876            continue;
2877        }
2878        $exts[] = ['Zend OPcache' => 'opcache'][$name] ?? strtolower($name);
2879    }
2880
2881    return $exts;
2882}
2883
2884function generate_diff_external(string $diff_cmd, string $exp_file, string $output_file): string
2885{
2886    $retval = shell_exec("{$diff_cmd} {$exp_file} {$output_file}");
2887
2888    return is_string($retval) ? $retval : 'Could not run external diff tool set through TEST_PHP_DIFF_CMD environment variable';
2889}
2890
2891function generate_diff(string $wanted, ?string $wanted_re, string $output): string
2892{
2893    $w = explode("\n", $wanted);
2894    $o = explode("\n", $output);
2895    $is_regex = $wanted_re !== null;
2896
2897    $differ = new Differ(function ($expected, $new) use ($is_regex) {
2898        if (!$is_regex) {
2899            return $expected === $new;
2900        }
2901        $regex = '/^' . expectf_to_regex($expected). '$/s';
2902        return preg_match($regex, $new);
2903    });
2904    return $differ->diff($w, $o);
2905}
2906
2907function error(string $message): void
2908{
2909    echo "ERROR: {$message}\n";
2910    exit(1);
2911}
2912
2913function settings2array(array $settings, array &$ini_settings): void
2914{
2915    foreach ($settings as $setting) {
2916        if (strpos($setting, '=') !== false) {
2917            $setting = explode("=", $setting, 2);
2918            $name = trim($setting[0]);
2919            $value = trim($setting[1]);
2920
2921            if ($name == 'extension' || $name == 'zend_extension') {
2922                if (!isset($ini_settings[$name])) {
2923                    $ini_settings[$name] = [];
2924                }
2925
2926                $ini_settings[$name][] = $value;
2927            } else {
2928                $ini_settings[$name] = $value;
2929            }
2930        }
2931    }
2932}
2933
2934function settings2params(array $ini_settings): string
2935{
2936    $settings = '';
2937
2938    foreach ($ini_settings as $name => $value) {
2939        if (is_array($value)) {
2940            foreach ($value as $val) {
2941                $val = addslashes($val);
2942                $settings .= " -d \"$name=$val\"";
2943            }
2944        } else {
2945            if (IS_WINDOWS && !empty($value) && $value[0] == '"') {
2946                $len = strlen($value);
2947
2948                if ($value[$len - 1] == '"') {
2949                    $value[0] = "'";
2950                    $value[$len - 1] = "'";
2951                }
2952            } else {
2953                $value = addslashes($value);
2954            }
2955
2956            $settings .= " -d \"$name=$value\"";
2957        }
2958    }
2959
2960    return $settings;
2961}
2962
2963function compute_summary(): void
2964{
2965    global $n_total, $test_results, $ignored_by_ext, $sum_results, $percent_results;
2966
2967    $n_total = count($test_results);
2968    $n_total += count($ignored_by_ext);
2969    $sum_results = [
2970        'PASSED' => 0,
2971        'WARNED' => 0,
2972        'SKIPPED' => 0,
2973        'FAILED' => 0,
2974        'BORKED' => 0,
2975        'LEAKED' => 0,
2976        'XFAILED' => 0,
2977        'XLEAKED' => 0
2978    ];
2979
2980    foreach ($test_results as $v) {
2981        $sum_results[$v]++;
2982    }
2983
2984    $sum_results['SKIPPED'] += count($ignored_by_ext);
2985    $percent_results = [];
2986
2987    foreach ($sum_results as $v => $n) {
2988        $percent_results[$v] = (100.0 * $n) / $n_total;
2989    }
2990}
2991
2992function get_summary(bool $show_ext_summary): string
2993{
2994    global $exts_skipped, $exts_tested, $n_total, $sum_results, $percent_results, $end_time, $start_time, $failed_test_summary, $PHP_FAILED_TESTS, $valgrind;
2995
2996    $x_total = $n_total - $sum_results['SKIPPED'] - $sum_results['BORKED'];
2997
2998    if ($x_total) {
2999        $x_warned = (100.0 * $sum_results['WARNED']) / $x_total;
3000        $x_failed = (100.0 * $sum_results['FAILED']) / $x_total;
3001        $x_xfailed = (100.0 * $sum_results['XFAILED']) / $x_total;
3002        $x_xleaked = (100.0 * $sum_results['XLEAKED']) / $x_total;
3003        $x_leaked = (100.0 * $sum_results['LEAKED']) / $x_total;
3004        $x_passed = (100.0 * $sum_results['PASSED']) / $x_total;
3005    } else {
3006        $x_warned = $x_failed = $x_passed = $x_leaked = $x_xfailed = $x_xleaked = 0;
3007    }
3008
3009    $summary = '';
3010
3011    if ($show_ext_summary) {
3012        $summary .= '
3013=====================================================================
3014TEST RESULT SUMMARY
3015---------------------------------------------------------------------
3016Exts skipped    : ' . sprintf('%5d', count($exts_skipped)) . ($exts_skipped ? ' (' . implode(', ', $exts_skipped) . ')' : '') . '
3017Exts tested     : ' . sprintf('%5d', count($exts_tested)) . '
3018---------------------------------------------------------------------
3019';
3020    }
3021
3022    $summary .= '
3023Number of tests : ' . sprintf('%5d', $n_total) . '          ' . sprintf('%8d', $x_total);
3024
3025    if ($sum_results['BORKED']) {
3026        $summary .= '
3027Tests borked    : ' . sprintf('%5d (%5.1f%%)', $sum_results['BORKED'], $percent_results['BORKED']) . ' --------';
3028    }
3029
3030    $summary .= '
3031Tests skipped   : ' . sprintf('%5d (%5.1f%%)', $sum_results['SKIPPED'], $percent_results['SKIPPED']) . ' --------
3032Tests warned    : ' . sprintf('%5d (%5.1f%%)', $sum_results['WARNED'], $percent_results['WARNED']) . ' ' . sprintf('(%5.1f%%)', $x_warned) . '
3033Tests failed    : ' . sprintf('%5d (%5.1f%%)', $sum_results['FAILED'], $percent_results['FAILED']) . ' ' . sprintf('(%5.1f%%)', $x_failed);
3034
3035    if ($sum_results['XFAILED']) {
3036        $summary .= '
3037Expected fail   : ' . sprintf('%5d (%5.1f%%)', $sum_results['XFAILED'], $percent_results['XFAILED']) . ' ' . sprintf('(%5.1f%%)', $x_xfailed);
3038    }
3039
3040    if ($valgrind) {
3041        $summary .= '
3042Tests leaked    : ' . sprintf('%5d (%5.1f%%)', $sum_results['LEAKED'], $percent_results['LEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_leaked);
3043        if ($sum_results['XLEAKED']) {
3044            $summary .= '
3045Expected leak   : ' . sprintf('%5d (%5.1f%%)', $sum_results['XLEAKED'], $percent_results['XLEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_xleaked);
3046        }
3047    }
3048
3049    $summary .= '
3050Tests passed    : ' . sprintf('%5d (%5.1f%%)', $sum_results['PASSED'], $percent_results['PASSED']) . ' ' . sprintf('(%5.1f%%)', $x_passed) . '
3051---------------------------------------------------------------------
3052Time taken      : ' . sprintf('%5.3f seconds', ($end_time - $start_time) / 1e9) . '
3053=====================================================================
3054';
3055    $failed_test_summary = '';
3056
3057    if (count($PHP_FAILED_TESTS['SLOW'])) {
3058        usort($PHP_FAILED_TESTS['SLOW'], function (array $a, array $b): int {
3059            return $a['info'] < $b['info'] ? 1 : -1;
3060        });
3061
3062        $failed_test_summary .= '
3063=====================================================================
3064SLOW TEST SUMMARY
3065---------------------------------------------------------------------
3066';
3067        foreach ($PHP_FAILED_TESTS['SLOW'] as $failed_test_data) {
3068            $failed_test_summary .= sprintf('(%.3f s) ', $failed_test_data['info']) . $failed_test_data['test_name'] . "\n";
3069        }
3070        $failed_test_summary .= "=====================================================================\n";
3071    }
3072
3073    if (count($PHP_FAILED_TESTS['BORKED'])) {
3074        $failed_test_summary .= '
3075=====================================================================
3076BORKED TEST SUMMARY
3077---------------------------------------------------------------------
3078';
3079        foreach ($PHP_FAILED_TESTS['BORKED'] as $failed_test_data) {
3080            $failed_test_summary .= $failed_test_data['info'] . "\n";
3081        }
3082
3083        $failed_test_summary .= "=====================================================================\n";
3084    }
3085
3086    if (count($PHP_FAILED_TESTS['FAILED'])) {
3087        $failed_test_summary .= '
3088=====================================================================
3089FAILED TEST SUMMARY
3090---------------------------------------------------------------------
3091';
3092        foreach ($PHP_FAILED_TESTS['FAILED'] as $failed_test_data) {
3093            $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n";
3094        }
3095        $failed_test_summary .= "=====================================================================\n";
3096    }
3097    if (count($PHP_FAILED_TESTS['WARNED'])) {
3098        $failed_test_summary .= '
3099=====================================================================
3100WARNED TEST SUMMARY
3101---------------------------------------------------------------------
3102';
3103        foreach ($PHP_FAILED_TESTS['WARNED'] as $failed_test_data) {
3104            $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n";
3105        }
3106
3107        $failed_test_summary .= "=====================================================================\n";
3108    }
3109
3110    if (count($PHP_FAILED_TESTS['LEAKED'])) {
3111        $failed_test_summary .= '
3112=====================================================================
3113LEAKED TEST SUMMARY
3114---------------------------------------------------------------------
3115';
3116        foreach ($PHP_FAILED_TESTS['LEAKED'] as $failed_test_data) {
3117            $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n";
3118        }
3119
3120        $failed_test_summary .= "=====================================================================\n";
3121    }
3122
3123    if ($failed_test_summary && !getenv('NO_PHPTEST_SUMMARY')) {
3124        $summary .= $failed_test_summary;
3125    }
3126
3127    return $summary;
3128}
3129
3130function show_start(int $start_timestamp): void
3131{
3132    echo "TIME START " . date('Y-m-d H:i:s', $start_timestamp) . "\n=====================================================================\n";
3133}
3134
3135function show_end(int $start_timestamp, int|float $start_time, int|float $end_time): void
3136{
3137    echo "=====================================================================\nTIME END " . date('Y-m-d H:i:s', $start_timestamp + (int)(($end_time - $start_time)/1e9)) . "\n";
3138}
3139
3140function show_summary(): void
3141{
3142    echo get_summary(true);
3143}
3144
3145function show_redirect_start(string $tests, string $tested, string $tested_file): void
3146{
3147    global $SHOW_ONLY_GROUPS, $show_progress;
3148
3149    if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) {
3150        echo "REDIRECT $tests ($tested [$tested_file]) begin\n";
3151    } elseif ($show_progress) {
3152        clear_show_test();
3153    }
3154}
3155
3156function show_redirect_ends(string $tests, string $tested, string $tested_file): void
3157{
3158    global $SHOW_ONLY_GROUPS, $show_progress;
3159
3160    if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) {
3161        echo "REDIRECT $tests ($tested [$tested_file]) done\n";
3162    } elseif ($show_progress) {
3163        clear_show_test();
3164    }
3165}
3166
3167function show_test(int $test_idx, string $shortname): void
3168{
3169    global $test_cnt;
3170    global $line_length;
3171
3172    $str = "TEST $test_idx/$test_cnt [$shortname]\r";
3173    $line_length = strlen($str);
3174    echo $str;
3175    flush();
3176}
3177
3178function clear_show_test(): void
3179{
3180    global $line_length;
3181    // Parallel testing
3182    global $workerID;
3183
3184    if (!$workerID && isset($line_length)) {
3185        // Write over the last line to avoid random trailing chars on next echo
3186        echo str_repeat(" ", $line_length), "\r";
3187    }
3188}
3189
3190function parse_conflicts(string $text): array
3191{
3192    // Strip comments
3193    $text = preg_replace('/#.*/', '', $text);
3194    return array_map('trim', explode("\n", trim($text)));
3195}
3196
3197function show_result(
3198    string $result,
3199    string $tested,
3200    string $tested_file,
3201    string $extra = ''
3202): void {
3203    global $SHOW_ONLY_GROUPS, $colorize, $show_progress;
3204
3205    if (!$SHOW_ONLY_GROUPS || in_array($result, $SHOW_ONLY_GROUPS)) {
3206        if ($colorize) {
3207            /* Use ANSI escape codes for coloring test result */
3208            switch ( $result ) {
3209                case 'PASS': // Light Green
3210                    $color = "\e[1;32m{$result}\e[0m"; break;
3211                case 'FAIL':
3212                case 'BORK':
3213                case 'LEAK':
3214                case 'LEAK&FAIL':
3215                    // Light Red
3216                    $color = "\e[1;31m{$result}\e[0m"; break;
3217                default: // Yellow
3218                    $color = "\e[1;33m{$result}\e[0m"; break;
3219            }
3220
3221            echo "$color $tested [$tested_file] $extra\n";
3222        } else {
3223            echo "$result $tested [$tested_file] $extra\n";
3224        }
3225    } elseif ($show_progress) {
3226        clear_show_test();
3227    }
3228}
3229
3230class BorkageException extends Exception
3231{
3232}
3233
3234class JUnit
3235{
3236    private bool $enabled = true;
3237    private $fp = null;
3238    private array $suites = [];
3239    private array $rootSuite = self::EMPTY_SUITE + ['name' => 'php'];
3240
3241    private const EMPTY_SUITE = [
3242        'test_total' => 0,
3243        'test_pass' => 0,
3244        'test_fail' => 0,
3245        'test_error' => 0,
3246        'test_skip' => 0,
3247        'test_warn' => 0,
3248        'files' => [],
3249        'execution_time' => 0,
3250    ];
3251
3252    /**
3253     * @throws Exception
3254     */
3255    public function __construct(array $env, int $workerID)
3256    {
3257        // Check whether a junit log is wanted.
3258        $fileName = $env['TEST_PHP_JUNIT'] ?? null;
3259        if (empty($fileName)) {
3260            $this->enabled = false;
3261            return;
3262        }
3263        if (!$workerID && !$this->fp = fopen($fileName, 'w')) {
3264            throw new Exception("Failed to open $fileName for writing.");
3265        }
3266    }
3267
3268    public function isEnabled(): bool
3269    {
3270        return $this->enabled;
3271    }
3272
3273    public function clear(): void
3274    {
3275        $this->rootSuite = self::EMPTY_SUITE + ['name' => 'php'];
3276        $this->suites = [];
3277    }
3278
3279    public function saveXML(): void
3280    {
3281        if (!$this->enabled) {
3282            return;
3283        }
3284
3285        $xml = '<' . '?' . 'xml version="1.0" encoding="UTF-8"' . '?' . '>' . PHP_EOL;
3286        $xml .= sprintf(
3287            '<testsuites name="%s" tests="%s" failures="%d" errors="%d" skip="%d" time="%s">' . PHP_EOL,
3288            $this->rootSuite['name'],
3289            $this->rootSuite['test_total'],
3290            $this->rootSuite['test_fail'],
3291            $this->rootSuite['test_error'],
3292            $this->rootSuite['test_skip'],
3293            $this->rootSuite['execution_time']
3294        );
3295        $xml .= $this->getSuitesXML();
3296        $xml .= '</testsuites>';
3297        fwrite($this->fp, $xml);
3298    }
3299
3300    private function getSuitesXML(): string
3301    {
3302        $result = '';
3303
3304        foreach ($this->suites as $suite_name => $suite) {
3305            $result .= sprintf(
3306                '<testsuite name="%s" tests="%s" failures="%d" errors="%d" skip="%d" time="%s">' . PHP_EOL,
3307                $suite['name'],
3308                $suite['test_total'],
3309                $suite['test_fail'],
3310                $suite['test_error'],
3311                $suite['test_skip'],
3312                $suite['execution_time']
3313            );
3314
3315            if (!empty($suite_name)) {
3316                foreach ($suite['files'] as $file) {
3317                    $result .= $this->rootSuite['files'][$file]['xml'];
3318                }
3319            }
3320
3321            $result .= '</testsuite>' . PHP_EOL;
3322        }
3323
3324        return $result;
3325    }
3326
3327    public function markTestAs(
3328        $type,
3329        string $file_name,
3330        string $test_name,
3331        ?int $time = null,
3332        string $message = '',
3333        string $details = ''
3334    ): void {
3335        if (!$this->enabled) {
3336            return;
3337        }
3338
3339        $suite = $this->getSuiteName($file_name);
3340
3341        $this->record($suite, 'test_total');
3342
3343        $time = $time ?? $this->getTimer($file_name);
3344        $this->record($suite, 'execution_time', $time);
3345
3346        $escaped_details = htmlspecialchars($details, ENT_QUOTES, 'UTF-8');
3347        $escaped_details = preg_replace_callback('/[\0-\x08\x0B\x0C\x0E-\x1F]/', function ($c) {
3348            return sprintf('[[0x%02x]]', ord($c[0]));
3349        }, $escaped_details);
3350        $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3351
3352        $escaped_test_name = htmlspecialchars($file_name . ' (' . $test_name . ')', ENT_QUOTES);
3353        $this->rootSuite['files'][$file_name]['xml'] = "<testcase name='$escaped_test_name' time='$time'>\n";
3354
3355        if (is_array($type)) {
3356            $output_type = $type[0] . 'ED';
3357            $temp = array_intersect(['XFAIL', 'XLEAK', 'FAIL', 'WARN'], $type);
3358            $type = reset($temp);
3359        } else {
3360            $output_type = $type . 'ED';
3361        }
3362
3363        if ('PASS' == $type || 'XFAIL' == $type || 'XLEAK' == $type) {
3364            $this->record($suite, 'test_pass');
3365        } elseif ('BORK' == $type) {
3366            $this->record($suite, 'test_error');
3367            $this->rootSuite['files'][$file_name]['xml'] .= "<error type='$output_type' message='$escaped_message'/>\n";
3368        } elseif ('SKIP' == $type) {
3369            $this->record($suite, 'test_skip');
3370            $this->rootSuite['files'][$file_name]['xml'] .= "<skipped>$escaped_message</skipped>\n";
3371        } elseif ('WARN' == $type) {
3372            $this->record($suite, 'test_warn');
3373            $this->rootSuite['files'][$file_name]['xml'] .= "<warning>$escaped_message</warning>\n";
3374        } elseif ('FAIL' == $type) {
3375            $this->record($suite, 'test_fail');
3376            $this->rootSuite['files'][$file_name]['xml'] .= "<failure type='$output_type' message='$escaped_message'>$escaped_details</failure>\n";
3377        } else {
3378            $this->record($suite, 'test_error');
3379            $this->rootSuite['files'][$file_name]['xml'] .= "<error type='$output_type' message='$escaped_message'>$escaped_details</error>\n";
3380        }
3381
3382        $this->rootSuite['files'][$file_name]['xml'] .= "</testcase>\n";
3383    }
3384
3385    private function record(string $suite, string $param, $value = 1): void
3386    {
3387        $this->rootSuite[$param] += $value;
3388        $this->suites[$suite][$param] += $value;
3389    }
3390
3391    private function getTimer(string $file_name)
3392    {
3393        if (!$this->enabled) {
3394            return 0;
3395        }
3396
3397        if (isset($this->rootSuite['files'][$file_name]['total'])) {
3398            return number_format($this->rootSuite['files'][$file_name]['total'], 4);
3399        }
3400
3401        return 0;
3402    }
3403
3404    public function startTimer(string $file_name): void
3405    {
3406        if (!$this->enabled) {
3407            return;
3408        }
3409
3410        if (!isset($this->rootSuite['files'][$file_name]['start'])) {
3411            $this->rootSuite['files'][$file_name]['start'] = microtime(true);
3412
3413            $suite = $this->getSuiteName($file_name);
3414            $this->initSuite($suite);
3415            $this->suites[$suite]['files'][$file_name] = $file_name;
3416        }
3417    }
3418
3419    public function getSuiteName(string $file_name): string
3420    {
3421        return $this->pathToClassName(dirname($file_name));
3422    }
3423
3424    private function pathToClassName(string $file_name): string
3425    {
3426        if (!$this->enabled) {
3427            return '';
3428        }
3429
3430        $ret = $this->rootSuite['name'];
3431        $_tmp = [];
3432
3433        // lookup whether we're in the PHP source checkout
3434        $max = 5;
3435        if (is_file($file_name)) {
3436            $dir = dirname(realpath($file_name));
3437        } else {
3438            $dir = realpath($file_name);
3439        }
3440        do {
3441            array_unshift($_tmp, basename($dir));
3442            $chk = $dir . DIRECTORY_SEPARATOR . "main" . DIRECTORY_SEPARATOR . "php_version.h";
3443            $dir = dirname($dir);
3444        } while (!file_exists($chk) && --$max > 0);
3445        if (file_exists($chk)) {
3446            if ($max) {
3447                array_shift($_tmp);
3448            }
3449            foreach ($_tmp as $p) {
3450                $ret .= "." . preg_replace(",[^a-z0-9]+,i", ".", $p);
3451            }
3452            return $ret;
3453        }
3454
3455        return $this->rootSuite['name'] . '.' . str_replace([DIRECTORY_SEPARATOR, '-'], '.', $file_name);
3456    }
3457
3458    public function initSuite(string $suite_name): void
3459    {
3460        if (!$this->enabled) {
3461            return;
3462        }
3463
3464        if (!empty($this->suites[$suite_name])) {
3465            return;
3466        }
3467
3468        $this->suites[$suite_name] = self::EMPTY_SUITE + ['name' => $suite_name];
3469    }
3470
3471    /**
3472     * @throws Exception
3473     */
3474    public function stopTimer(string $file_name): void
3475    {
3476        if (!$this->enabled) {
3477            return;
3478        }
3479
3480        if (!isset($this->rootSuite['files'][$file_name]['start'])) {
3481            throw new Exception("Timer for $file_name was not started!");
3482        }
3483
3484        if (!isset($this->rootSuite['files'][$file_name]['total'])) {
3485            $this->rootSuite['files'][$file_name]['total'] = 0;
3486        }
3487
3488        $start = $this->rootSuite['files'][$file_name]['start'];
3489        $this->rootSuite['files'][$file_name]['total'] += microtime(true) - $start;
3490        unset($this->rootSuite['files'][$file_name]['start']);
3491    }
3492
3493    public function mergeResults(?JUnit $other): void
3494    {
3495        if (!$this->enabled || !$other) {
3496            return;
3497        }
3498
3499        $this->mergeSuites($this->rootSuite, $other->rootSuite);
3500        foreach ($other->suites as $name => $suite) {
3501            if (!isset($this->suites[$name])) {
3502                $this->suites[$name] = $suite;
3503                continue;
3504            }
3505
3506            $this->mergeSuites($this->suites[$name], $suite);
3507        }
3508    }
3509
3510    private function mergeSuites(array &$dest, array $source): void
3511    {
3512        $dest['test_total'] += $source['test_total'];
3513        $dest['test_pass']  += $source['test_pass'];
3514        $dest['test_fail']  += $source['test_fail'];
3515        $dest['test_error'] += $source['test_error'];
3516        $dest['test_skip']  += $source['test_skip'];
3517        $dest['test_warn']  += $source['test_warn'];
3518        $dest['execution_time'] += $source['execution_time'];
3519        $dest['files'] += $source['files'];
3520    }
3521}
3522
3523class SkipCache
3524{
3525    private bool $enable;
3526    private bool $keepFile;
3527
3528    private array $skips = [];
3529    private array $extensions = [];
3530
3531    private int $hits = 0;
3532    private int $misses = 0;
3533    private int $extHits = 0;
3534    private int $extMisses = 0;
3535
3536    public function __construct(bool $enable, bool $keepFile)
3537    {
3538        $this->enable = $enable;
3539        $this->keepFile = $keepFile;
3540    }
3541
3542    public function checkSkip(string $php, string $code, string $checkFile, string $tempFile, array $env): string
3543    {
3544        // Extension tests frequently use something like <?php require 'skipif.inc';
3545        // for skip checks. This forces us to cache per directory to avoid pollution.
3546        $dir = dirname($checkFile);
3547        $key = "$php => $dir";
3548
3549        if (isset($this->skips[$key][$code])) {
3550            $this->hits++;
3551            if ($this->keepFile) {
3552                save_text($checkFile, $code, $tempFile);
3553            }
3554            return $this->skips[$key][$code];
3555        }
3556
3557        save_text($checkFile, $code, $tempFile);
3558        $result = trim(system_with_timeout("$php \"$checkFile\"", $env));
3559        if (strpos($result, 'nocache') === 0) {
3560            $result = '';
3561        } else if ($this->enable) {
3562            $this->skips[$key][$code] = $result;
3563        }
3564        $this->misses++;
3565
3566        if (!$this->keepFile) {
3567            @unlink($checkFile);
3568        }
3569
3570        return $result;
3571    }
3572
3573    public function getExtensions(string $php): array
3574    {
3575        if (isset($this->extensions[$php])) {
3576            $this->extHits++;
3577            return $this->extensions[$php];
3578        }
3579
3580        $extDir = shell_exec("$php -d display_errors=0 -r \"echo ini_get('extension_dir');\"");
3581        $extensionsNames = explode(",", shell_exec("$php -d display_errors=0 -r \"echo implode(',', get_loaded_extensions());\""));
3582        $extensions = remap_loaded_extensions_names($extensionsNames);
3583
3584        $result = [$extDir, $extensions];
3585        $this->extensions[$php] = $result;
3586        $this->extMisses++;
3587
3588        return $result;
3589    }
3590}
3591
3592class RuntestsValgrind
3593{
3594    protected string $header;
3595    protected bool $version_3_8_0;
3596    protected string $tool;
3597
3598    public function getHeader(): string
3599    {
3600        return $this->header;
3601    }
3602
3603    public function __construct(array $environment, string $tool = 'memcheck')
3604    {
3605        $this->tool = $tool;
3606        $header = system_with_timeout("valgrind --tool={$this->tool} --version", $environment);
3607        if (!$header) {
3608            error("Valgrind returned no version info for {$this->tool}, cannot proceed.\n".
3609                "Please check if Valgrind is installed and the tool is named correctly.");
3610        }
3611        $count = 0;
3612        $version = preg_replace("/valgrind-(\d+)\.(\d+)\.(\d+)([.\w_-]+)?(\s+)/", '$1.$2.$3', $header, 1, $count);
3613        if ($count != 1) {
3614            error("Valgrind returned invalid version info (\"{$header}\") for {$this->tool}, cannot proceed.");
3615        }
3616        $this->header = sprintf("%s (%s)", trim($header), $this->tool);
3617        $this->version_3_8_0 = version_compare($version, '3.8.0', '>=');
3618    }
3619
3620    public function wrapCommand(string $cmd, string $memcheck_filename, bool $check_all): string
3621    {
3622        $vcmd = "valgrind -q --tool={$this->tool} --trace-children=yes";
3623        if ($check_all) {
3624            $vcmd .= ' --smc-check=all';
3625        }
3626
3627        /* --vex-iropt-register-updates=allregs-at-mem-access is necessary for phpdbg watchpoint tests */
3628        if ($this->version_3_8_0) {
3629            return "$vcmd --vex-iropt-register-updates=allregs-at-mem-access --log-file=$memcheck_filename $cmd";
3630        }
3631        return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file=$memcheck_filename $cmd";
3632    }
3633}
3634
3635class TestFile
3636{
3637    private string $fileName;
3638
3639    private array $sections = ['TEST' => ''];
3640
3641    private const ALLOWED_SECTIONS = [
3642        'EXPECT', 'EXPECTF', 'EXPECTREGEX', 'EXPECTREGEX_EXTERNAL', 'EXPECT_EXTERNAL', 'EXPECTF_EXTERNAL', 'EXPECTHEADERS',
3643        'POST', 'POST_RAW', 'GZIP_POST', 'DEFLATE_POST', 'PUT', 'GET', 'COOKIE', 'ARGS',
3644        'FILE', 'FILEEOF', 'FILE_EXTERNAL', 'REDIRECTTEST',
3645        'CAPTURE_STDIO', 'STDIN', 'CGI', 'PHPDBG',
3646        'INI', 'ENV', 'EXTENSIONS',
3647        'SKIPIF', 'XFAIL', 'XLEAK', 'CLEAN',
3648        'CREDITS', 'DESCRIPTION', 'CONFLICTS', 'WHITESPACE_SENSITIVE',
3649        'FLAKY',
3650    ];
3651
3652    /**
3653     * @throws BorkageException
3654     */
3655    public function __construct(string $fileName, bool $inRedirect)
3656    {
3657        $this->fileName = $fileName;
3658
3659        $this->readFile();
3660        $this->validateAndProcess($inRedirect);
3661    }
3662
3663    public function hasSection(string $name): bool
3664    {
3665        return isset($this->sections[$name]);
3666    }
3667
3668    public function hasAnySections(string ...$names): bool
3669    {
3670        foreach ($names as $section) {
3671            if (isset($this->sections[$section])) {
3672                return true;
3673            }
3674        }
3675
3676        return false;
3677    }
3678
3679    public function sectionNotEmpty(string $name): bool
3680    {
3681        return !empty($this->sections[$name]);
3682    }
3683
3684    /**
3685     * @throws Exception
3686     */
3687    public function getSection(string $name): string
3688    {
3689        if (!isset($this->sections[$name])) {
3690            throw new Exception("Section $name not found");
3691        }
3692        return $this->sections[$name];
3693    }
3694
3695    public function getName(): string
3696    {
3697        return trim($this->getSection('TEST'));
3698    }
3699
3700    public function isCGI(): bool
3701    {
3702        return $this->hasSection('CGI')
3703            || $this->sectionNotEmpty('GET')
3704            || $this->sectionNotEmpty('POST')
3705            || $this->sectionNotEmpty('GZIP_POST')
3706            || $this->sectionNotEmpty('DEFLATE_POST')
3707            || $this->sectionNotEmpty('POST_RAW')
3708            || $this->sectionNotEmpty('PUT')
3709            || $this->sectionNotEmpty('COOKIE')
3710            || $this->sectionNotEmpty('EXPECTHEADERS');
3711    }
3712
3713    /**
3714     * TODO Refactor to make it not needed
3715     */
3716    public function setSection(string $name, string $value): void
3717    {
3718        $this->sections[$name] = $value;
3719    }
3720
3721    /**
3722     * Load the sections of the test file
3723     * @throws BorkageException
3724     */
3725    private function readFile(): void
3726    {
3727        $fp = fopen($this->fileName, "rb") or error("Cannot open test file: {$this->fileName}");
3728
3729        if (!feof($fp)) {
3730            $line = fgets($fp);
3731
3732            if ($line === false) {
3733                throw new BorkageException("cannot read test");
3734            }
3735        } else {
3736            throw new BorkageException("empty test [{$this->fileName}]");
3737        }
3738        if (strncmp('--TEST--', $line, 8)) {
3739            throw new BorkageException("tests must start with --TEST-- [{$this->fileName}]");
3740        }
3741
3742        $section = 'TEST';
3743        $secfile = false;
3744        $secdone = false;
3745
3746        while (!feof($fp)) {
3747            $line = fgets($fp);
3748
3749            if ($line === false) {
3750                break;
3751            }
3752
3753            // Match the beginning of a section.
3754            if (preg_match('/^--([_A-Z]+)--/', $line, $r)) {
3755                $section = $r[1];
3756
3757                if (isset($this->sections[$section]) && $this->sections[$section]) {
3758                    throw new BorkageException("duplicated $section section");
3759                }
3760
3761                // check for unknown sections
3762                if (!in_array($section, self::ALLOWED_SECTIONS)) {
3763                    throw new BorkageException('Unknown section "' . $section . '"');
3764                }
3765
3766                $this->sections[$section] = '';
3767                $secfile = $section == 'FILE' || $section == 'FILEEOF' || $section == 'FILE_EXTERNAL';
3768                $secdone = false;
3769                continue;
3770            }
3771
3772            // Add to the section text.
3773            if (!$secdone) {
3774                $this->sections[$section] .= $line;
3775            }
3776
3777            // End of actual test?
3778            if ($secfile && preg_match('/^===DONE===\s*$/', $line)) {
3779                $secdone = true;
3780            }
3781        }
3782
3783        fclose($fp);
3784    }
3785
3786    /**
3787     * @throws BorkageException
3788     */
3789    private function validateAndProcess(bool $inRedirect): void
3790    {
3791        // the redirect section allows a set of tests to be reused outside of
3792        // a given test dir
3793        if ($this->hasSection('REDIRECTTEST')) {
3794            if ($inRedirect) {
3795                throw new BorkageException("Can't redirect a test from within a redirected test");
3796            }
3797            return;
3798        }
3799        if (!$this->hasSection('PHPDBG') && $this->hasSection('FILE') + $this->hasSection('FILEEOF') + $this->hasSection('FILE_EXTERNAL') != 1) {
3800            throw new BorkageException("missing section --FILE--");
3801        }
3802
3803        if ($this->hasSection('FILEEOF')) {
3804            $this->sections['FILE'] = preg_replace("/[\r\n]+$/", '', $this->sections['FILEEOF']);
3805            unset($this->sections['FILEEOF']);
3806        }
3807
3808        foreach (['FILE', 'EXPECT', 'EXPECTF', 'EXPECTREGEX'] as $prefix) {
3809            // For grepping: FILE_EXTERNAL, EXPECT_EXTERNAL, EXPECTF_EXTERNAL, EXPECTREGEX_EXTERNAL
3810            $key = $prefix . '_EXTERNAL';
3811
3812            if ($this->hasSection($key)) {
3813                // don't allow tests to retrieve files from anywhere but this subdirectory
3814                $dir = dirname($this->fileName);
3815                $fileName = $dir . '/' . trim(str_replace('..', '', $this->getSection($key)));
3816
3817                if (file_exists($fileName)) {
3818                    $this->sections[$prefix] = file_get_contents($fileName);
3819                } else {
3820                    throw new BorkageException("could not load --" . $key . "-- " . $dir . '/' . trim($fileName));
3821                }
3822            }
3823        }
3824
3825        if (($this->hasSection('EXPECT') + $this->hasSection('EXPECTF') + $this->hasSection('EXPECTREGEX')) != 1) {
3826            throw new BorkageException("missing section --EXPECT--, --EXPECTF-- or --EXPECTREGEX--");
3827        }
3828
3829        if ($this->hasSection('PHPDBG') && !$this->hasSection('STDIN')) {
3830            $this->sections['STDIN'] = $this->sections['PHPDBG'] . "\n";
3831        }
3832    }
3833}
3834
3835function init_output_buffers(): void
3836{
3837    // Delete as much output buffers as possible.
3838    while (@ob_end_clean()) {
3839    }
3840
3841    if (ob_get_level()) {
3842        echo "Not all buffers were deleted.\n";
3843    }
3844}
3845
3846function check_proc_open_function_exists(): void
3847{
3848    if (!function_exists('proc_open')) {
3849        echo <<<NO_PROC_OPEN_ERROR
3850
3851+-----------------------------------------------------------+
3852|                       ! ERROR !                           |
3853| The test-suite requires that proc_open() is available.    |
3854| Please check if you disabled it in php.ini.               |
3855+-----------------------------------------------------------+
3856
3857NO_PROC_OPEN_ERROR;
3858        exit(1);
3859    }
3860}
3861
3862function bless_failed_tests(array $failedTests): void
3863{
3864    if (empty($failedTests)) {
3865        return;
3866    }
3867    $args = [
3868        PHP_BINARY,
3869        __DIR__ . '/scripts/dev/bless_tests.php',
3870    ];
3871    foreach ($failedTests as $test) {
3872        $args[] = $test['name'];
3873    }
3874    proc_open($args, [], $pipes);
3875}
3876
3877/*
3878 * BSD 3-Clause License
3879 *
3880 * Copyright (c) 2002-2023, Sebastian Bergmann
3881 * All rights reserved.
3882 *
3883 * This file is part of sebastian/diff.
3884 * https://github.com/sebastianbergmann/diff
3885 */
3886
3887final class Differ
3888{
3889    public const OLD = 0;
3890    public const ADDED = 1;
3891    public const REMOVED = 2;
3892    private DiffOutputBuilder $outputBuilder;
3893    private $isEqual;
3894
3895    public function __construct(callable $isEqual)
3896    {
3897        $this->outputBuilder = new DiffOutputBuilder;
3898        $this->isEqual = $isEqual;
3899    }
3900
3901    public function diff(array $from, array $to): string
3902    {
3903        $diff = $this->diffToArray($from, $to);
3904
3905        return $this->outputBuilder->getDiff($diff);
3906    }
3907
3908    public function diffToArray(array $from, array $to): array
3909    {
3910        $fromLine = 1;
3911        $toLine = 1;
3912
3913        [$from, $to, $start, $end] = $this->getArrayDiffParted($from, $to);
3914
3915        $common = $this->calculateCommonSubsequence(array_values($from), array_values($to));
3916        $diff   = [];
3917
3918        foreach ($start as $token) {
3919            $diff[] = [$token, self::OLD];
3920            $fromLine++;
3921            $toLine++;
3922        }
3923
3924        reset($from);
3925        reset($to);
3926
3927        foreach ($common as $token) {
3928            while (!empty($from) && !($this->isEqual)(reset($from), $token)) {
3929                $diff[] = [array_shift($from), self::REMOVED, $fromLine++];
3930            }
3931
3932            while (!empty($to) && !($this->isEqual)($token, reset($to))) {
3933                $diff[] = [array_shift($to), self::ADDED, $toLine++];
3934            }
3935
3936            $diff[] = [$token, self::OLD];
3937            $fromLine++;
3938            $toLine++;
3939
3940            array_shift($from);
3941            array_shift($to);
3942        }
3943
3944        while (($token = array_shift($from)) !== null) {
3945            $diff[] = [$token, self::REMOVED, $fromLine++];
3946        }
3947
3948        while (($token = array_shift($to)) !== null) {
3949            $diff[] = [$token, self::ADDED, $toLine++];
3950        }
3951
3952        foreach ($end as $token) {
3953            $diff[] = [$token, self::OLD];
3954        }
3955
3956        return $diff;
3957    }
3958
3959    private function getArrayDiffParted(array &$from, array &$to): array
3960    {
3961        $start = [];
3962        $end   = [];
3963
3964        reset($to);
3965
3966        foreach ($from as $k => $v) {
3967            $toK = key($to);
3968
3969            if (($this->isEqual)($toK, $k) && ($this->isEqual)($v, $to[$k])) {
3970                $start[$k] = $v;
3971
3972                unset($from[$k], $to[$k]);
3973            } else {
3974                break;
3975            }
3976        }
3977
3978        end($from);
3979        end($to);
3980
3981        do {
3982            $fromK = key($from);
3983            $toK   = key($to);
3984
3985            if (null === $fromK || null === $toK || !($this->isEqual)(current($from), current($to))) {
3986                break;
3987            }
3988
3989            prev($from);
3990            prev($to);
3991
3992            $end = [$fromK => $from[$fromK]] + $end;
3993            unset($from[$fromK], $to[$toK]);
3994        } while (true);
3995
3996        return [$from, $to, $start, $end];
3997    }
3998
3999    public function calculateCommonSubsequence(array $from, array $to): array
4000    {
4001        $cFrom = count($from);
4002        $cTo   = count($to);
4003
4004        if ($cFrom === 0) {
4005            return [];
4006        }
4007
4008        if ($cFrom === 1) {
4009            foreach ($to as $toV) {
4010                if (($this->isEqual)($from[0], $toV)) {
4011                    return [$toV];
4012                }
4013            }
4014
4015            return [];
4016        }
4017
4018        $i         = (int) ($cFrom / 2);
4019        $fromStart = array_slice($from, 0, $i);
4020        $fromEnd   = array_slice($from, $i);
4021        $llB       = $this->commonSubsequenceLength($fromStart, $to);
4022        $llE       = $this->commonSubsequenceLength(array_reverse($fromEnd), array_reverse($to));
4023        $jMax      = 0;
4024        $max       = 0;
4025
4026        for ($j = 0; $j <= $cTo; $j++) {
4027            $m = $llB[$j] + $llE[$cTo - $j];
4028
4029            if ($m >= $max) {
4030                $max  = $m;
4031                $jMax = $j;
4032            }
4033        }
4034
4035        $toStart = array_slice($to, 0, $jMax);
4036        $toEnd   = array_slice($to, $jMax);
4037
4038        return array_merge(
4039            $this->calculateCommonSubsequence($fromStart, $toStart),
4040            $this->calculateCommonSubsequence($fromEnd, $toEnd)
4041        );
4042    }
4043
4044    private function commonSubsequenceLength(array $from, array $to): array
4045    {
4046        $current = array_fill(0, count($to) + 1, 0);
4047        $cFrom   = count($from);
4048        $cTo     = count($to);
4049
4050        for ($i = 0; $i < $cFrom; $i++) {
4051            $prev = $current;
4052
4053            for ($j = 0; $j < $cTo; $j++) {
4054                if (($this->isEqual)($from[$i], $to[$j])) {
4055                    $current[$j + 1] = $prev[$j] + 1;
4056                } else {
4057                    $current[$j + 1] = max($current[$j], $prev[$j + 1]);
4058                }
4059            }
4060        }
4061
4062        return $current;
4063    }
4064}
4065
4066class DiffOutputBuilder
4067{
4068    public function getDiff(array $diffs): string
4069    {
4070        global $context_line_count;
4071        $i = 0;
4072        $number_len = max(3, strlen((string)count($diffs)));
4073        $line_number_spec = '%0' . $number_len . 'd';
4074        $buffer = fopen('php://memory', 'r+b');
4075        while ($i < count($diffs)) {
4076            // Find next difference
4077            $next = $i;
4078            while ($next < count($diffs)) {
4079                if ($diffs[$next][1] !== Differ::OLD) {
4080                    break;
4081                }
4082                $next++;
4083            }
4084            // Found no more differentiating rows, we're done
4085            if ($next === count($diffs)) {
4086                if (($i - 1) < count($diffs)) {
4087                    fwrite($buffer, "--\n");
4088                }
4089                break;
4090            }
4091            // Print separator if necessary
4092            if ($i < ($next - $context_line_count)) {
4093                fwrite($buffer, "--\n");
4094                $i = $next - $context_line_count;
4095            }
4096            // Print leading context
4097            while ($i < $next) {
4098                fwrite($buffer, str_repeat(' ', $number_len + 2));
4099                fwrite($buffer, $diffs[$i][0]);
4100                fwrite($buffer, "\n");
4101                $i++;
4102            }
4103            // Print differences
4104            while ($i < count($diffs) && $diffs[$i][1] !== Differ::OLD) {
4105                fwrite($buffer, sprintf($line_number_spec, $diffs[$i][2]));
4106                switch ($diffs[$i][1]) {
4107                    case Differ::ADDED:
4108                        fwrite($buffer, '+ ');
4109                        break;
4110                    case Differ::REMOVED:
4111                        fwrite($buffer, '- ');
4112                        break;
4113                }
4114                fwrite($buffer, $diffs[$i][0]);
4115                fwrite($buffer, "\n");
4116                $i++;
4117            }
4118            // Print trailing context
4119            $afterContext = min($i + $context_line_count, count($diffs));
4120            while ($i < $afterContext && $diffs[$i][1] === Differ::OLD) {
4121                fwrite($buffer, str_repeat(' ', $number_len + 2));
4122                fwrite($buffer, $diffs[$i][0]);
4123                fwrite($buffer, "\n");
4124                $i++;
4125            }
4126        }
4127
4128        $diff = stream_get_contents($buffer, -1, 0);
4129        fclose($buffer);
4130
4131        return $diff;
4132    }
4133}
4134
4135main();
4136