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