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