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