diff --git a/.gitignore b/.gitignore index b76b5a787caa..f1ed9c288c95 100644 --- a/.gitignore +++ b/.gitignore @@ -253,7 +253,7 @@ php php_test_results_*.txt # Temporary test information generated by `./run-tests.php` -/run-test-info.php +/run-test-info-*.php # Temporary POST data placeholder files generated by `./run-tests.php` phpt.* diff --git a/run-tests.php b/run-tests.php index 998e7e24c337..d9d6d715dcfb 100755 --- a/run-tests.php +++ b/run-tests.php @@ -147,6 +147,7 @@ function main(): void $end_time, $environment, $exts_skipped, $exts_tested, $exts_to_test, $failed_tests_file, $ignored_by_ext, $ini_overwrites, $colorize, + $cli_opcache_enabled, $log_format, $no_clean, $no_file_cache, $pass_options, $php, $php_cgi, $preload, $result_tests_file, $slow_min_ms, $start_time, @@ -348,6 +349,7 @@ function main(): void $slow_min_ms = INF; $preload = false; $file_cache = null; + $cli_opcache_enabled = true; $shuffle = false; $bless = false; $workers = null; @@ -817,16 +819,19 @@ function verify_config(string $php): void function write_information(array $user_tests, $phpdbg): void { global $php, $php_cgi, $php_info, $ini_overwrites, $pass_options, $exts_to_test, $valgrind, $no_file_cache; + global $cli_opcache_enabled; $php_escaped = escapeshellarg($php); // Get info from php - $info_file = __DIR__ . '/run-test-info.php'; + $info_file = __DIR__ . '/run-test-info-' . getmypid() . '.php'; @unlink($info_file); $php_info = ''; save_text($info_file, $php_info); @@ -834,6 +839,7 @@ function write_information(array $user_tests, $phpdbg): void settings2array($ini_overwrites, $info_params); $info_params = settings2params($info_params); $php_info = shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\""); + $cli_opcache_enabled = !str_contains($php_info, "\nOPCACHE CLI : disabled\n"); define('TESTED_PHP_VERSION', shell_exec("$php_escaped -n -r \"echo PHP_VERSION;\"")); if ($php_cgi && $php != $php_cgi) { @@ -1277,6 +1283,371 @@ function system_with_timeout( return $data; } +final class TestForkServer +{ + public const MAX_EXTRA_ARGS_LENGTH = 1_048_576; + + /** @var resource */ + private $process; + /** @var array */ + private array $pipes = []; + private string $token; + private string $buffer = ''; + private int $index = 0; + private bool $stopped = false; + private ?string $startupOutput = null; + + public function __construct(string|array $command, array $env, bool $captureStdErr = true) + { + $this->token = bin2hex(random_bytes(16)); + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => $captureStdErr + ? ['redirect', 1] + : ['file', '/dev/null', 'a'], + ]; + if (is_array($command)) { + $command[] = '--test-fork-server'; + $command[] = $this->token; + } else { + $command = "exec $command --test-fork-server {$this->token}"; + } + $this->process = proc_open( + $command, + $descriptors, + $this->pipes, + TEST_PHP_SRCDIR, + $env, + ['suppress_errors' => true] + ); + if (!is_resource($this->process)) { + throw new RuntimeException('Unable to start test fork server'); + } + stream_set_blocking($this->pipes[1], false); + } + + public function run( + string $file, + int $timeout, + bool $setScriptEnvironment = true, + bool $captureStdErr = true, + ?string $testPhpExtraArgs = null + ): ?string + { + $environmentMode = $setScriptEnvironment ? '+' : '-'; + $errorMode = $captureStdErr ? '+' : '-'; + $request = "$environmentMode$errorMode\t$file\n"; + if ($testPhpExtraArgs !== null) { + // The length prefix supports values containing newlines or exceeding MAXPATHLEN. + $request = "@$environmentMode$errorMode\t" . strlen($testPhpExtraArgs) + . "\n$testPhpExtraArgs\n$file\n"; + } + if ($this->stopped || !$this->writeRequest($request)) { + $this->abort(); + return null; + } + + $beginMarker = "\0{$this->token}:B:{$this->index}\0"; + $endMarker = "\0{$this->token}:E:{$this->index}:"; + $deadline = microtime(true) + $timeout; + + while (true) { + try { + $result = $this->extractResult($beginMarker, $endMarker); + } catch (UnexpectedValueException) { + $this->abort(); + return null; + } + if ($result !== null) { + [$output, $status] = $result; + $this->index++; + if ($status > 128 && $status < 160) { + $output .= "\nTermsig=" . ($status - 128) . "\n"; + } + return $output; + } + + $remaining = $deadline - microtime(true); + if ($remaining <= 0) { + $this->abort(); + return "\n ** ERROR: process timed out **\n"; + } + + $read = [$this->pipes[1]]; + $write = null; + $except = null; + $seconds = (int) $remaining; + $microseconds = (int) (($remaining - $seconds) * 1_000_000); + $ready = @stream_select($read, $write, $except, $seconds, $microseconds); + if ($ready === false) { + $this->abort(); + return null; + } + if ($ready === 0) { + continue; + } + + $chunk = fread($this->pipes[1], 8192); + if ($chunk === false || ($chunk === '' && feof($this->pipes[1]))) { + $this->abort(); + return null; + } + $this->buffer .= $chunk; + } + } + + private function writeRequest(string $request): bool + { + while ($request !== '') { + $written = @fwrite($this->pipes[0], $request); + if ($written === false || $written === 0) { + return false; + } + $request = substr($request, $written); + } + + return @fflush($this->pipes[0]); + } + + /** + * @return null|array{string, int} + */ + private function extractResult(string $beginMarker, string $endMarker): ?array + { + $begin = strpos($this->buffer, $beginMarker); + if ($begin === false) { + return null; + } + + $outputStart = $begin + strlen($beginMarker); + $end = strpos($this->buffer, $endMarker, $outputStart); + if ($end === false) { + return null; + } + + $statusStart = $end + strlen($endMarker); + $statusEnd = strpos($this->buffer, "\0", $statusStart); + if ($statusEnd === false) { + return null; + } + + $status = substr($this->buffer, $statusStart, $statusEnd - $statusStart); + if ($status === '' || strspn($status, '0123456789') !== strlen($status)) { + throw new UnexpectedValueException('Invalid test fork server status'); + } + + $prefix = substr($this->buffer, 0, $begin); + if ($this->startupOutput === null) { + $this->startupOutput = $prefix; + $prefix = ''; + } + $output = $this->startupOutput + . $prefix + . substr($this->buffer, $outputStart, $end - $outputStart); + $this->buffer = substr($this->buffer, $statusEnd + 1); + return [$output, (int) $status]; + } + + private function abort(): void + { + if ($this->stopped) { + return; + } + $this->stopped = true; + proc_terminate($this->process); + foreach ($this->pipes as $pipe) { + fclose($pipe); + } + proc_close($this->process); + } + + public function __destruct() + { + if ($this->stopped) { + return; + } + fclose($this->pipes[0]); + fclose($this->pipes[1]); + proc_close($this->process); + } +} + +function can_use_test_fork_server(): bool +{ + global $cli_opcache_enabled, $environment, $file_cache, $IN_REDIRECT, $num_repeats, $preload, $valgrind; + + return !IS_WINDOWS + && !$cli_opcache_enabled + && getenv('TEST_PHP_FORK_SERVER') !== '0' + && !isset($environment['SKIP_ASAN']) + && !$valgrind + && !$preload + && $file_cache === null + && $num_repeats === 1 + && !is_array($IN_REDIRECT); +} + +function can_run_in_test_fork_server(TestFile $test, bool $uses_cgi): bool +{ + return can_use_test_fork_server() + && !$uses_cgi + && !($test->hasSection('SKIPIF') && str_contains($test->getSection('SKIPIF'), 'SKIP_REPEAT')) + && has_only_fork_safe_ini_settings($test) + && !$test->hasAnySections( + 'ARGS', + 'CAPTURE_STDIO', + 'CGI', + 'COOKIE', + 'DEFLATE_POST', + 'ENV', + 'GET', + 'GZIP_POST', + 'PHPDBG', + 'POST', + 'POST_RAW', + 'PUT', + 'REDIRECTTEST', + 'STDIN', + 'XLEAK' + ); +} + +function can_run_auxiliary_script_in_test_fork_server(TestFile $test, bool $usesNonCliSapi): bool +{ + return can_use_test_fork_server() + && !$usesNonCliSapi + && !$test->hasSection('ENV'); +} + +function has_only_fork_safe_ini_settings(TestFile $test): bool +{ + if (!$test->sectionNotEmpty('INI')) { + return true; + } + // Only settings that are safe to initialize in the parent process belong here. + static $safeSettings = [ + 'allow_url_fopen' => true, + 'assert.active' => true, + 'assert.bail' => true, + 'assert.callback' => true, + 'assert.exception' => true, + 'assert.warning' => true, + 'bcmath.scale' => true, + 'date.timezone' => true, + 'default_charset' => true, + 'ffi.enable' => true, + 'internal_encoding' => true, + 'intl.default_locale' => true, + 'open_basedir' => true, + 'output_handler' => true, + 'phar.readonly' => true, + 'phar.require_hash' => true, + 'precision' => true, + 'serialize_precision' => true, + 'soap.wsdl_cache_enabled' => true, + 'zend.assertions' => true, + 'zend.enable_gc' => true, + 'zlib.output_compression' => true, + ]; + + foreach (preg_split('/[\r\n]+/', $test->getSection('INI')) as $setting) { + $setting = trim($setting); + if ($setting === '' || $setting[0] === ';') { + continue; + } + $name = strtolower(trim(explode('=', $setting, 2)[0])); + // Session request state is initialized in the child after the fork. + if (str_starts_with($name, 'session.')) { + continue; + } + if (!isset($safeSettings[$name])) { + return false; + } + } + + return true; +} + +function test_fork_server_cache_key( + string|array $command, + array $env, + bool $captureStdErr, + ?string $testPhpExtraArgs +): string { + // A server inherits its environment only when it is created. These values are sent + // explicitly with each request and therefore do not belong to the inherited state. + unset($env['PATH_TRANSLATED'], $env['SCRIPT_FILENAME']); + if ($testPhpExtraArgs !== null) { + unset($env['TEST_PHP_EXTRA_ARGS']); + } + ksort($env); + return serialize([$command, $captureStdErr, $env, $testPhpExtraArgs !== null]); +} + +function system_with_test_fork_server( + string|array $command, + string $file, + array $env, + string $channel = 'test', + bool $setScriptEnvironment = true, + bool $captureStdErr = true, + bool $deferUntilRepeated = false, + ?string $testPhpExtraArgs = null +): ?string { + static $servers = []; + static $serverKeys = []; + static $pendingKeys = []; + static $unsupported = []; + + $serverKey = test_fork_server_cache_key($command, $env, $captureStdErr, $testPhpExtraArgs); + if ( + strpbrk($file, "\r\n") !== false + || ($testPhpExtraArgs !== null + && strlen($testPhpExtraArgs) > TestForkServer::MAX_EXTRA_ARGS_LENGTH) + || isset($unsupported[$serverKey]) + ) { + return null; + } + if (($serverKeys[$channel] ?? null) !== $serverKey) { + if ($deferUntilRepeated && ($pendingKeys[$channel] ?? null) !== $serverKey) { + $pendingKeys[$channel] = $serverKey; + return null; + } + unset($pendingKeys[$channel]); + unset($servers[$channel]); + $serverKeys[$channel] = $serverKey; + } else { + unset($pendingKeys[$channel]); + } + if (!isset($servers[$channel])) { + try { + $servers[$channel] = new TestForkServer($command, $env, $captureStdErr); + } catch (Throwable) { + $unsupported[$serverKey] = true; + return null; + } + } + + $timeout = (int) ($env['TEST_TIMEOUT'] ?? 60); + if (isset($env['SKIP_ASAN'])) { + $timeout *= 3; + } + + $output = $servers[$channel]->run( + $file, + $timeout, + $setScriptEnvironment, + $captureStdErr, + $testPhpExtraArgs, + ); + if ($output === null) { + unset($servers[$channel]); + $unsupported[$serverKey] = true; + } + return $output; +} + function run_all_tests(array $test_files, array $env, ?string $redir_tested = null): void { global $test_results, $failed_tests_file, $result_tests_file, $php, $test_idx, $file_cache, $shuffle; @@ -2168,7 +2539,17 @@ function run_test(string $php, $file, array $env): string $startTime = microtime(true); $commandLine = "$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0"; - $output = $skipCache->checkSkip($commandLine, $test->getSection('SKIPIF'), $test_skipif, $temp_skipif, $env); + $output = $skipCache->checkSkip( + $commandLine, + $test->getSection('SKIPIF'), + $test_skipif, + $temp_skipif, + $env, + can_run_auxiliary_script_in_test_fork_server( + $test, + $uses_cgi || $test->hasSection('PHPDBG') + ), + ); $time = microtime(true) - $startTime; $junit->stopTimer($shortname); @@ -2498,7 +2879,20 @@ function run_test(string $php, $file, array $env): string $startTime = $hrtime[0] * 1000000000 + $hrtime[1]; $stdin = $test->hasSection('STDIN') ? $test->getSection('STDIN') : null; - $out = system_with_timeout($cmd, $env, $stdin, $captureStdIn, $captureStdOut, $captureStdErr); + $out = null; + if (can_run_in_test_fork_server($test, $uses_cgi)) { + $hasTestIni = $test->sectionNotEmpty('INI'); + $out = system_with_test_fork_server( + "$php $pass_options $ini_settings", + $test_file, + $env, + channel: $hasTestIni ? 'test-ini' : 'test', + deferUntilRepeated: $hasTestIni, + ); + } + if ($out === null) { + $out = system_with_timeout($cmd, $env, $stdin, $captureStdIn, $captureStdOut, $captureStdErr); + } $junit->stopTimer($shortname); $hrtime = hrtime(); @@ -2522,7 +2916,31 @@ function run_test(string $php, $file, array $env): string if (!$no_clean) { $extra = !IS_WINDOWS ? "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : ""; - $clean_output = system_with_timeout("$extra $orig_php $pass_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env); + $cleanCommand = "$extra $orig_php $pass_options -q $orig_ini_settings $no_file_cache"; + $cleanEnv = $env; + if (!IS_WINDOWS) { + unset( + $cleanEnv['REQUEST_METHOD'], + $cleanEnv['QUERY_STRING'], + $cleanEnv['PATH_TRANSLATED'], + $cleanEnv['SCRIPT_FILENAME'], + ); + } + $clean_output = null; + if (can_run_auxiliary_script_in_test_fork_server($test, $uses_cgi)) { + $clean_output = system_with_test_fork_server( + $cleanCommand, + $test_clean, + $cleanEnv, + channel: 'clean', + setScriptEnvironment: false, + captureStdErr: false, + testPhpExtraArgs: $cleanEnv['TEST_PHP_EXTRA_ARGS'] ?? '', + ); + } + if ($clean_output === null) { + $clean_output = system_with_timeout("$cleanCommand \"$test_clean\"", $cleanEnv); + } } if (!$cfg['keep']['clean']) { @@ -3612,7 +4030,14 @@ public function __construct(bool $enable, bool $keepFile) $this->keepFile = $keepFile; } - public function checkSkip(string $php, string $code, string $checkFile, string $tempFile, array $env): string + public function checkSkip( + string $php, + string $code, + string $checkFile, + string $tempFile, + array $env, + bool $useForkServer + ): string { // Extension tests frequently use something like enable) { diff --git a/sapi/cli/php_cli.c b/sapi/cli/php_cli.c index 296b7cd521ff..a6bb23b32d21 100644 --- a/sapi/cli/php_cli.c +++ b/sapi/cli/php_cli.c @@ -42,6 +42,12 @@ #ifdef HAVE_UNISTD_H #include #endif +#ifdef HAVE_SYS_WAIT_H +#include +#endif +#ifndef PHP_WIN32 +#include +#endif #include #include @@ -151,9 +157,364 @@ const opt_struct OPTIONS[] = { /* Internal testing option -- may be changed or removed without notice, * including in patch releases. */ {16, 1, "repeat"}, + {17, 1, "test-fork-server"}, {'-', 0, NULL} /* end of args */ }; +typedef struct { + char file[MAXPATHLEN]; + char *test_php_extra_args; + size_t test_php_extra_args_length; + size_t index; + const char *token; + bool set_script_environment; + bool capture_stderr; + bool has_test_php_extra_args; +} php_cli_test_batch; + +#define PHP_CLI_TEST_BATCH_MAX_EXTRA_ARGS (1024 * 1024) + +typedef enum { + PHP_CLI_TEST_BATCH_ERROR = -1, + PHP_CLI_TEST_BATCH_CHILD, + PHP_CLI_TEST_BATCH_PARENT, + PHP_CLI_TEST_BATCH_DONE, +} php_cli_test_batch_result; + +#ifdef HAVE_FORK +static volatile sig_atomic_t php_cli_test_child_pid; + +static void php_cli_test_batch_signal(int signal) /* {{{ */ +{ + if (php_cli_test_child_pid > 0) { + kill(php_cli_test_child_pid, SIGKILL); + } + _exit(128 + signal); +} +/* }}} */ +#endif + +static bool php_cli_test_batch_init(php_cli_test_batch *batch, const char *token) /* {{{ */ +{ + size_t token_length = strlen(token); + + if (token_length == 0 || token_length > 64) { + fprintf(stderr, "Invalid test token length\n"); + return false; + } + for (size_t i = 0; i < token_length; i++) { + if (!isalnum((unsigned char) token[i])) { + fprintf(stderr, "Invalid test token\n"); + return false; + } + } + + batch->token = token; + return true; +} +/* }}} */ + +#ifdef HAVE_FORK +static int php_cli_test_batch_read_line(char *buffer, size_t size) /* {{{ */ +{ + while (fgets(buffer, size, stdin)) { + size_t length = strlen(buffer); + + while (length > 0 && (buffer[length - 1] == '\n' || buffer[length - 1] == '\r')) { + buffer[--length] = '\0'; + } + if (length == 0) { + continue; + } + if (length == size - 1 && !feof(stdin)) { + fprintf(stderr, "Test request exceeds MAXPATHLEN\n"); + return -1; + } + return 1; + } + + return ferror(stdin) ? -1 : 0; +} +/* }}} */ + +static bool php_cli_test_batch_read_bytes(char *buffer, size_t length) /* {{{ */ +{ + while (length > 0) { + size_t bytes_read = fread(buffer, 1, length, stdin); + if (bytes_read == 0) { + return false; + } + buffer += bytes_read; + length -= bytes_read; + } + return true; +} +/* }}} */ + +static int php_cli_test_batch_next(php_cli_test_batch *batch) /* {{{ */ +{ + free(batch->test_php_extra_args); + batch->test_php_extra_args = NULL; + batch->test_php_extra_args_length = 0; + batch->has_test_php_extra_args = false; + + int has_line = php_cli_test_batch_read_line(batch->file, sizeof(batch->file)); + + if (has_line <= 0) { + return has_line; + } + + size_t length = strlen(batch->file); + batch->set_script_environment = true; + batch->capture_stderr = true; + /* A length-prefixed request is followed by extra args, a newline, then the test path. */ + if (batch->file[0] == '@' + && (batch->file[1] == '+' || batch->file[1] == '-') + && (batch->file[2] == '+' || batch->file[2] == '-') + && batch->file[3] == '\t') { + char *end; + errno = 0; + unsigned long long extra_args_length = strtoull(batch->file + 4, &end, 10); + if (errno || end == batch->file + 4 || *end != '\0' + || extra_args_length > PHP_CLI_TEST_BATCH_MAX_EXTRA_ARGS) { + fprintf(stderr, "Invalid TEST_PHP_EXTRA_ARGS length\n"); + return -1; + } + + batch->set_script_environment = batch->file[1] == '+'; + batch->capture_stderr = batch->file[2] == '+'; + batch->test_php_extra_args_length = (size_t) extra_args_length; + batch->test_php_extra_args = malloc(batch->test_php_extra_args_length + 1); + if (!batch->test_php_extra_args + || !php_cli_test_batch_read_bytes( + batch->test_php_extra_args, batch->test_php_extra_args_length) + || fgetc(stdin) != '\n') { + fprintf(stderr, "Unable to read TEST_PHP_EXTRA_ARGS\n"); + free(batch->test_php_extra_args); + batch->test_php_extra_args = NULL; + batch->test_php_extra_args_length = 0; + return -1; + } + batch->test_php_extra_args[batch->test_php_extra_args_length] = '\0'; + batch->has_test_php_extra_args = true; + + has_line = php_cli_test_batch_read_line(batch->file, sizeof(batch->file)); + if (has_line <= 0) { + fprintf(stderr, "Test path is missing\n"); + return -1; + } + return 1; + } + if ((batch->file[0] == '+' || batch->file[0] == '-') + && (batch->file[1] == '+' || batch->file[1] == '-') + && batch->file[2] == '\t') { + batch->set_script_environment = batch->file[0] == '+'; + batch->capture_stderr = batch->file[1] == '+'; + memmove(batch->file, batch->file + 3, length - 2); + if (length == 3) { + fprintf(stderr, "Test path is empty\n"); + return -1; + } + } + return 1; +} +/* }}} */ + +static void php_cli_test_batch_marker( + const php_cli_test_batch *batch, + char type, + int exit_status +) /* {{{ */ +{ + fputc('\0', stdout); + if (type == 'B') { + fprintf(stdout, "%s:B:%zu", batch->token, batch->index); + } else { + fprintf(stdout, "%s:E:%zu:%d", batch->token, batch->index, exit_status); + } + fputc('\0', stdout); + fflush(stdout); +} +/* }}} */ + +static bool php_cli_test_batch_relay_output(int fd) /* {{{ */ +{ + char buffer[8192]; + ssize_t bytes_read; + + while (true) { + bytes_read = read(fd, buffer, sizeof(buffer)); + if (bytes_read > 0) { + if (fwrite(buffer, 1, bytes_read, stdout) != (size_t) bytes_read) { + return false; + } + fflush(stdout); + continue; + } + if (bytes_read < 0 && errno == EINTR) { + continue; + } + return bytes_read == 0; + } +} +/* }}} */ +#endif + +static php_cli_test_batch_result php_cli_test_batch_start( + php_cli_test_batch *batch, + char **script_file +) /* {{{ */ +{ +#ifndef HAVE_FORK + fprintf(stderr, "--test-fork-server requires fork()\n"); + return PHP_CLI_TEST_BATCH_ERROR; +#else + int test_output[2]; + sigset_t termination_signals; + sigset_t previous_signal_mask; + int has_test = php_cli_test_batch_next(batch); + + if (has_test < 0) { + fprintf(stderr, "Unable to read test path\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + if (has_test == 0) { + return PHP_CLI_TEST_BATCH_DONE; + } + + *script_file = batch->file; + if (pipe(test_output) < 0) { + fprintf(stderr, "Unable to create test output pipe\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + php_cli_test_batch_marker(batch, 'B', 0); + + sigemptyset(&termination_signals); + sigaddset(&termination_signals, SIGINT); + sigaddset(&termination_signals, SIGTERM); + if (sigprocmask(SIG_BLOCK, &termination_signals, &previous_signal_mask) < 0) { + close(test_output[0]); + close(test_output[1]); + fprintf(stderr, "Unable to block termination signals\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + + pid_t test_pid = fork(); + if (test_pid < 0) { + close(test_output[0]); + close(test_output[1]); + sigprocmask(SIG_SETMASK, &previous_signal_mask, NULL); + fprintf(stderr, "Unable to fork test process\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + if (test_pid > 0) { + int status; + pid_t waited; + + close(test_output[1]); + php_cli_test_child_pid = test_pid; + if (sigprocmask(SIG_SETMASK, &previous_signal_mask, NULL) < 0) { + kill(test_pid, SIGKILL); + php_cli_test_child_pid = 0; + close(test_output[0]); + do { + waited = waitpid(test_pid, &status, 0); + } while (waited < 0 && errno == EINTR); + fprintf(stderr, "Unable to restore termination signals\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + bool output_relayed = php_cli_test_batch_relay_output(test_output[0]); + close(test_output[0]); + do { + waited = waitpid(test_pid, &status, 0); + } while (waited < 0 && errno == EINTR); + php_cli_test_child_pid = 0; + + if (!output_relayed || waited < 0) { + fprintf(stderr, "Unable to collect test process output\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + + if (!WIFEXITED(status) && !WIFSIGNALED(status)) { + fprintf(stderr, "Unexpected test process status\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + int exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status); + php_cli_test_batch_marker(batch, 'E', exit_status); + batch->index++; + return PHP_CLI_TEST_BATCH_PARENT; + } + + php_cli_test_child_pid = 0; + signal(SIGINT, SIG_DFL); + signal(SIGTERM, SIG_DFL); + if (sigprocmask(SIG_SETMASK, &previous_signal_mask, NULL) < 0) { + fprintf(stderr, "Unable to restore termination signals\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + close(test_output[0]); + if (dup2(test_output[1], STDOUT_FILENO) < 0) { + fprintf(stderr, "Unable to redirect test output\n"); + close(test_output[1]); + return PHP_CLI_TEST_BATCH_ERROR; + } + if (batch->capture_stderr) { + if (dup2(test_output[1], STDERR_FILENO) < 0) { + fprintf(stderr, "Unable to redirect test error output\n"); + close(test_output[1]); + return PHP_CLI_TEST_BATCH_ERROR; + } + } else { + int null_fd = open("/dev/null", O_WRONLY); + if (null_fd < 0 || dup2(null_fd, STDERR_FILENO) < 0) { + fprintf(stderr, "Unable to discard test error output\n"); + if (null_fd >= 0) { + close(null_fd); + } + close(test_output[1]); + return PHP_CLI_TEST_BATCH_ERROR; + } + close(null_fd); + } + close(test_output[1]); + if (batch->set_script_environment) { + if (setenv("PATH_TRANSLATED", *script_file, 1) < 0 + || setenv("SCRIPT_FILENAME", *script_file, 1) < 0) { + fprintf(stderr, "Unable to set test environment\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + } else if (unsetenv("PATH_TRANSLATED") < 0 || unsetenv("SCRIPT_FILENAME") < 0) { + fprintf(stderr, "Unable to unset test environment\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + if (batch->has_test_php_extra_args) { + if (memchr(batch->test_php_extra_args, '\0', batch->test_php_extra_args_length) + || setenv("TEST_PHP_EXTRA_ARGS", batch->test_php_extra_args, 1) < 0) { + fprintf(stderr, "Unable to set TEST_PHP_EXTRA_ARGS\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + } + php_child_init(); + + /* Match proc_open() with a closed write end: an empty pipe, not /dev/null. */ + int test_input[2]; + if (pipe(test_input) < 0) { + fprintf(stderr, "Unable to create test input pipe\n"); + return PHP_CLI_TEST_BATCH_ERROR; + } + close(test_input[1]); + if (dup2(test_input[0], STDIN_FILENO) < 0) { + fprintf(stderr, "Unable to redirect test input\n"); + close(test_input[0]); + return PHP_CLI_TEST_BATCH_ERROR; + } + close(test_input[0]); + + return PHP_CLI_TEST_BATCH_CHILD; +#endif +} +/* }}} */ + static int module_name_cmp(Bucket *f, Bucket *s) /* {{{ */ { return strcasecmp(((zend_module_entry *)Z_PTR(f->val))->name, @@ -592,6 +953,11 @@ static int do_cli(int argc, char **argv) /* {{{ */ char *exec_direct = NULL, *exec_run = NULL, *exec_begin = NULL, *exec_end = NULL; char *arg_free = NULL, **arg_excp = &arg_free; char *script_file = NULL, *translated_path = NULL; + char *test_argv[2] = {NULL, NULL}; + char *test_batch_token = NULL; + php_cli_test_batch test_batch = {0}; + bool test_batch_complete = false; + bool test_batch_failed = false; bool interactive = false; const char *param_error = NULL; bool hide_argv = false; @@ -817,11 +1183,29 @@ static int do_cli(int argc, char **argv) /* {{{ */ case 16: num_repeats = atoi(php_optarg); break; + case 17: + test_batch_token = php_optarg; + break; default: break; } } + if (test_batch_token) { + if (zend_ini_bool_literal("opcache.enable") + && zend_ini_bool_literal("opcache.enable_cli")) { + param_error = "--test-fork-server cannot be used when opcache.enable_cli is enabled.\n"; + } else if (!php_cli_test_batch_init(&test_batch, test_batch_token)) { + param_error = "Unable to initialize test batch.\n"; + } + } +#ifdef HAVE_FORK + if (test_batch.token) { + signal(SIGINT, php_cli_test_batch_signal); + signal(SIGTERM, php_cli_test_batch_signal); + } +#endif + if (param_error) { PUTS(param_error); EG(exit_status) = 1; @@ -853,6 +1237,22 @@ static int do_cli(int argc, char **argv) /* {{{ */ } do_repeat: + if (test_batch.token) { + php_cli_test_batch_result result = php_cli_test_batch_start(&test_batch, &script_file); + if (result == PHP_CLI_TEST_BATCH_PARENT) { + goto do_repeat; + } + if (result == PHP_CLI_TEST_BATCH_DONE) { + test_batch_complete = true; + goto out; + } + if (result == PHP_CLI_TEST_BATCH_ERROR) { + EG(exit_status) = 1; + test_batch_failed = true; + goto out; + } + } + /* only set script_file if not set already and not in direct mode and not at end of parameter list */ if (argc > php_optind && !script_file @@ -890,16 +1290,24 @@ static int do_cli(int argc, char **argv) /* {{{ */ /* before registering argv to module exchange the *new* argv[0] */ /* we can achieve this without allocating more memory */ - SG(request_info).argc = argc - php_optind + 1; - arg_excp = argv + php_optind - 1; - arg_free = argv[php_optind - 1]; SG(request_info).path_translated = translated_path ? translated_path : php_self; - argv[php_optind - 1] = php_self; - SG(request_info).argv = argv + php_optind - 1; + if (test_batch.token) { + test_argv[0] = php_self; + SG(request_info).argc = 1; + SG(request_info).argv = test_argv; + } else { + SG(request_info).argc = argc - php_optind + 1; + arg_excp = argv + php_optind - 1; + arg_free = argv[php_optind - 1]; + argv[php_optind - 1] = php_self; + SG(request_info).argv = argv + php_optind - 1; + } SG(server_context) = &context; if (php_request_startup() == FAILURE) { - *arg_excp = arg_free; + if (!test_batch.token) { + *arg_excp = arg_free; + } PUTS("Could not startup.\n"); goto err; } @@ -911,7 +1319,9 @@ static int do_cli(int argc, char **argv) /* {{{ */ is_ps_title_available() == PS_TITLE_SUCCESS, 0, 0); - *arg_excp = arg_free; /* reconstruct argv */ + if (!test_batch.token) { + *arg_excp = arg_free; /* reconstruct argv */ + } if (hide_argv) { int i; @@ -1154,6 +1564,11 @@ static int do_cli(int argc, char **argv) /* {{{ */ free(translated_path); translated_path = NULL; } + if (test_batch.token && (test_batch_complete || test_batch_failed || pid != getpid())) { + int exit_status = EG(exit_status); + free(test_batch.test_php_extra_args); + return test_batch_complete ? 0 : exit_status; + } if (context.mode == PHP_CLI_MODE_LINT && argc > php_optind && strcmp(argv[php_optind], "--")) { script_file = NULL; goto do_repeat; diff --git a/sapi/cli/tests/test_fork_server.phpt b/sapi/cli/tests/test_fork_server.phpt new file mode 100644 index 000000000000..6ecabea5c6e7 --- /dev/null +++ b/sapi/cli/tests/test_fork_server.phpt @@ -0,0 +1,124 @@ +--TEST-- +CLI test fork server isolates requests and continues after a nonzero exit +--SKIPIF-- + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, +); +fclose($pipes[0]); +stream_get_contents($pipes[1]); +fclose($pipes[1]); +if (proc_close($process) !== 0) { + die('skip fork server is not available'); +} +?> +--FILE-- + <<<'PHP' + <<<'PHP' + <<<'PHP' + <<<'PHP' + <<<'PHP' + <<<'PHP' + $code) { + file_put_contents($file, $code); +} + +$token = 'TESTTOKEN'; +$processEnv = getenv(); +$processEnv['TEST_PHP_EXTRA_ARGS'] = 'server defaults'; +$process = proc_open( + [$php, '-n', '--test-fork-server', $token], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, + null, + $processEnv, +); +$files = array_keys($scripts); +foreach ($files as $index => $file) { + if ($index === array_key_last($files) - 1) { + $extraArgs = '-d test=' . str_repeat('x', 1500); + fwrite($pipes[0], "@--\t" . strlen($extraArgs) . "\n$extraArgs\n$file\n"); + } else { + fwrite($pipes[0], "$file\n"); + } +} +fclose($pipes[0]); +$output = stream_get_contents($pipes[1]); +fclose($pipes[1]); +$exitCode = proc_close($process); + +echo str_replace("\0", '|', $output); +var_dump($exitCode); +?> +--CLEAN-- + +--EXPECT-- +|TESTTOKEN:B:0|first +pipe +empty +script env +|TESTTOKEN:E:0:0||TESTTOKEN:B:1|isolated +|TESTTOKEN:E:1:0||TESTTOKEN:B:2||TESTTOKEN:E:2:23||TESTTOKEN:B:3|after +|TESTTOKEN:E:3:0||TESTTOKEN:B:4|unset env +extra args +|TESTTOKEN:E:4:0||TESTTOKEN:B:5|restored extra args +|TESTTOKEN:E:5:0|int(0) diff --git a/tests/run-test/auxiliary_environment.phpt b/tests/run-test/auxiliary_environment.phpt new file mode 100644 index 000000000000..1a324a5c3d20 --- /dev/null +++ b/tests/run-test/auxiliary_environment.phpt @@ -0,0 +1,26 @@ +--TEST-- +SKIPIF and CLEAN receive test-specific extra arguments +--INI-- +error_reporting=123 +--SKIPIF-- + +--FILE-- + +--CLEAN-- + +--EXPECT-- +set diff --git a/tests/run-test/fork_server_lifecycle.phpt b/tests/run-test/fork_server_lifecycle.phpt new file mode 100644 index 000000000000..0745de9cec68 --- /dev/null +++ b/tests/run-test/fork_server_lifecycle.phpt @@ -0,0 +1,211 @@ +--TEST-- +Test fork server timeout, termination, and failure fallback +--EXTENSIONS-- +posix +--SKIPIF-- + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, +); +fclose($pipes[0]); +stream_get_contents($pipes[1]); +fclose($pipes[1]); +if (proc_close($process) !== 0) { + die('skip fork server is not available'); +} +?> +--ENV-- +TEST_PHP_FORK_SERVER=0 +--FILE-- + + --EXPECT-- + $expected + PHPT); +} + +/** + * @return array{int, string, array} + */ +function runTests(array $files, string $results, array $arguments = []): array +{ + $command = [ + getenv('TEST_PHP_EXECUTABLE'), + '-n', + dirname(__DIR__, 2) . '/run-tests.php', + '-n', + '-q', + '-j1', + ...$arguments, + '-W', + $results, + ...$files, + ]; + $environment = [ + 'PATH' => getenv('PATH'), + 'TEST_PHP_EXECUTABLE' => getenv('TEST_PHP_EXECUTABLE'), + 'TEST_PHP_FORK_SERVER' => '1', + ]; + foreach (['SystemRoot', 'TEMP', 'TMPDIR'] as $name) { + if (($value = getenv($name)) !== false) { + $environment[$name] = $value; + } + } + $process = proc_open( + $command, + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, + null, + $environment, + ); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $exitCode = proc_close($process); + + $statuses = []; + foreach (file($results, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $resultLine) { + [$status, $file] = explode("\t", $resultLine, 2); + $statuses[basename($file)] = $status; + } + ksort($statuses); + return [$exitCode, $output, $statuses]; +} + +function processStopped(int $pid, string $heartbeat): bool +{ + if (!@posix_kill($pid, 0)) { + return true; + } + + clearstatcache(true, $heartbeat); + $size = @filesize($heartbeat); + for ($attempt = 0; $attempt < 4; $attempt++) { + usleep(500_000); + clearstatcache(true, $heartbeat); + $nextSize = @filesize($heartbeat); + if ($size !== false && $size === $nextSize) { + return true; + } + $size = $nextSize; + } + + return false; +} + +$root = __DIR__ . '/fork_server_lifecycle_' . getmypid(); +mkdir($root); + +$timeoutPid = $root . '/timeout.pid'; +$timeoutPidExpression = var_export($timeoutPid, true); +$timeoutHeartbeat = $root . '/timeout.heartbeat'; +$timeoutHeartbeatExpression = var_export($timeoutHeartbeat, true); +$timeoutTest = $root . '/01-timeout.phpt'; +$afterTest = $root . '/02-after.phpt'; +writeTest( + $timeoutTest, + 'fork server timeout', + << $status) { + echo "timeout $status $file\n"; +} +var_dump($timeoutExit); + +$timeoutChildStopped = false; +if (file_exists($timeoutPid) && file_exists($timeoutHeartbeat)) { + $timeoutChild = (int) file_get_contents($timeoutPid); + $timeoutChildStopped = processStopped($timeoutChild, $timeoutHeartbeat); + if (!$timeoutChildStopped) { + posix_kill($timeoutChild, 9); + } +} +echo $timeoutChildStopped ? "timeout child terminated\n" : "timeout child leaked\n"; +if ($timeoutExit !== 0 || count($timeoutStatuses) !== 2) { + echo $timeoutOutput; +} + +$failureMarker = $root . '/failure.marker'; +$failureMarkerExpression = var_export($failureMarker, true); +$failureTest = $root . '/failure.phpt'; +writeTest( + $failureTest, + 'fork server failure fallback', + << $status) { + echo "failure $status $file\n"; +} +var_dump($failureExit); +if ($failureExit !== 0 || count($failureStatuses) !== 1) { + echo $failureOutput; +} +?> +--CLEAN-- + +--EXPECT-- +timeout PASSED 01-timeout.phpt +timeout PASSED 02-after.phpt +int(0) +timeout child terminated +failure PASSED failure.phpt +int(0) diff --git a/tests/run-test/fork_server_opcache.phpt b/tests/run-test/fork_server_opcache.phpt new file mode 100644 index 000000000000..eb4becc5fb47 --- /dev/null +++ b/tests/run-test/fork_server_opcache.phpt @@ -0,0 +1,128 @@ +--TEST-- +Test fork server is disabled when CLI OPcache is enabled +--EXTENSIONS-- +opcache +--SKIPIF-- + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, +); +fclose($pipes[0]); +stream_get_contents($pipes[1]); +fclose($pipes[1]); +if (proc_close($process) !== 0) { + die('skip fork server is not available'); +} + +$process = proc_open( + [ + getenv('TEST_PHP_EXECUTABLE'), + '-n', + '-r', + 'exit(extension_loaded("Zend OPcache") ? 0 : 1);', + ], + [], + $pipes, +); +if (proc_close($process) !== 0) { + die('skip requires OPcache without additional arguments'); +} +?> +--ENV-- +TEST_PHP_FORK_SERVER=0 +--FILE-- + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, +); +fclose($pipes[0]); +echo stream_get_contents($pipes[1]); +fclose($pipes[1]); +var_dump(proc_close($process)); + +$results = __DIR__ . '/fork_server_opcache_results.txt'; +$command = [ + $php, + dirname(__DIR__, 2) . '/run-tests.php', + '-q', + '-j1', + '-d', + 'opcache.enable=1', + '-d', + 'opcache.enable_cli=1', + '-W', + $results, + dirname(__DIR__, 2) . '/ext/opcache/tests/gh17422/001.phpt', + dirname(__DIR__, 2) . '/ext/opcache/tests/gh17422/003.phpt', +]; +$environment = [ + 'PATH' => getenv('PATH'), + 'TEST_PHP_EXECUTABLE' => getenv('TEST_PHP_EXECUTABLE'), + 'TEST_PHP_FORK_SERVER' => '1', +]; +foreach (['SystemRoot', 'TEMP', 'TMPDIR'] as $name) { + if (($value = getenv($name)) !== false) { + $environment[$name] = $value; + } +} +$process = proc_open( + $command, + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['redirect', 1], + ], + $pipes, + null, + $environment, +); +fclose($pipes[0]); +$output = stream_get_contents($pipes[1]); +fclose($pipes[1]); +$exitCode = proc_close($process); + +foreach (file($results, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $resultLine) { + [$status, $file] = explode("\t", $resultLine, 2); + echo $status, ' ', basename($file), "\n"; +} +var_dump($exitCode); +if ($exitCode !== 0) { + echo $output; +} +?> +--CLEAN-- + +--EXPECT-- +--test-fork-server cannot be used when opcache.enable_cli is enabled. +int(1) +PASSED 001.phpt +PASSED 003.phpt +int(0)