diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7945c6f431b..37ea9c0bb01 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -2052,6 +2052,25 @@ jobs: ../bashunit assert file_not_contains output.txt "Process restarting" coverage: "pcov" ini-values: "pcov.enabled=0" + - script: | + # One restart removes pcov and turns OPcache on: the main process starts twice, not more. + cd e2e/restart-once + echo "auto_prepend_file=$PWD/start-log.php" | sudo tee -a "$(php -r 'echo php_ini_loaded_file();')" + ../../bin/phpstan analyse --no-progress + cat starts.log + ../bashunit assert equals "2" "$(wc -l < starts.log | tr -d ' ')" + ../bashunit assert equals "opcache=1 pcov=0 xdebug=0" "$(tail -n 1 starts.log)" + coverage: "pcov" + ini-values: "pcov.enabled=1" + - script: | + # One restart removes Xdebug and turns OPcache on: the main process starts twice, not more. + cd e2e/restart-once + echo "auto_prepend_file=$PWD/start-log.php" | sudo tee -a "$(php -r 'echo php_ini_loaded_file();')" + ../../bin/phpstan analyse --no-progress + cat starts.log + ../bashunit assert equals "2" "$(wc -l < starts.log | tr -d ' ')" + ../bashunit assert equals "opcache=1 pcov=0 xdebug=0" "$(tail -n 1 starts.log)" + coverage: "xdebug" steps: - name: Harden the runner (Audit all outbound calls) diff --git a/bin/phpstan b/bin/phpstan index 5280bed96aa..69a963ed2de 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -28,12 +28,13 @@ use Symfony\Component\Console\Helper\ProgressBar; require_once __DIR__ . '/../src/Turbo/TurboExtensionEnabler.php'; require_once __DIR__ . '/../src/Turbo/TurboExtensionSelector.php'; require_once __DIR__ . '/../src/Turbo/TurboProcessRestarter.php'; - TurboProcessRestarter::restartIfSuitable($_SERVER['argv']); + TurboProcessRestarter::restartIfSuitable($_SERVER['argv'], null, __DIR__ . '/../vendor/autoload.php'); TurboExtensionEnabler::trustOwnTypesIfSuitable($_SERVER['argv']); $analysisStartTime = microtime(true); - $devOrPharLoader = require_once __DIR__ . '/../vendor/autoload.php'; + // not require_once: the restart above may have loaded it already + $devOrPharLoader = require __DIR__ . '/../vendor/autoload.php'; TurboExtensionEnabler::activateIfCompatible(); require_once __DIR__ . '/../preload.php'; $cwd = getcwd(); diff --git a/build/composer-dependency-analyser.php b/build/composer-dependency-analyser.php index ebaec5d9750..7a3e1c77e99 100644 --- a/build/composer-dependency-analyser.php +++ b/build/composer-dependency-analyser.php @@ -33,6 +33,13 @@ $unknownClasses[] = 'PHPStanTurbo\Runtime'; } +$unknownFunctions = []; +if (!function_exists('xdebug_info')) { + // provided by the Xdebug extension, which TurboProcessRestarter checks for; + // conditional for the same reason as PHPStanTurbo\Runtime above + $unknownFunctions[] = 'xdebug_info'; +} + return $config ->addPathToScan(__DIR__ . '/../bin', true) ->ignoreErrorsOnPackages( @@ -44,4 +51,5 @@ ) ->ignoreErrorsOnPackage('phpunit/phpunit', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // prepared test tooling ->ignoreErrorsOnPath(__DIR__ . '/../tests', [ErrorType::UNKNOWN_CLASS, ErrorType::UNKNOWN_FUNCTION, ErrorType::SHADOW_DEPENDENCY]) // to be able to test invalid symbols - ->ignoreUnknownClasses($unknownClasses); + ->ignoreUnknownClasses($unknownClasses) + ->ignoreUnknownFunctions($unknownFunctions); diff --git a/e2e/restart-once/phpstan.neon b/e2e/restart-once/phpstan.neon new file mode 100644 index 00000000000..6b4b43534ee --- /dev/null +++ b/e2e/restart-once/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 0 + paths: + - test.php diff --git a/e2e/restart-once/start-log.php b/e2e/restart-once/start-log.php new file mode 100644 index 00000000000..c26e572702f --- /dev/null +++ b/e2e/restart-once/start-log.php @@ -0,0 +1,12 @@ + */ private array $extensions = []; + /** @var list|null */ + private ?array $execIniArgs = null; + + private string $envAllowXdebugName; + + public function __construct(string $envPrefix) + { + parent::__construct($envPrefix); + $this->envAllowXdebugName = strtoupper($envPrefix) . self::SUFFIX_ALLOW; + } + + /** + * Replaces this process via pcntl_exec() instead of waiting for a child, + * with these `-d` entries on top of the stripped ini. TurboProcessRestarter + * passes its own entries, so one restart covers both of them. + * + * @param list $iniArgs `name=value` entries + */ + public function setExecIniArgs(array $iniArgs): self + { + $this->execIniArgs = $iniArgs; + return $this; + } + + /** + * Prints the lines that XdebugHandler::doRestart() prints under + * XDEBUG_HANDLER_DEBUG, which the exec skips. Its Status class is internal, + * so the format is repeated here. + * + * @param non-empty-list $command + */ + private function reportRestarting(array $command): void + { + $debug = getenv(self::DEBUG); + if (in_array($debug, [false, ''], true)) { + return; + } + + $lines = [ + sprintf('Process restarting (%s=%s)', $this->envAllowXdebugName, (string) getenv($this->envAllowXdebugName)), + sprintf('Running: [%s]', implode(', ', $command)), + ]; + foreach ($lines as $line) { + fwrite(STDERR, sprintf('xdebug-handler[%d] %s%s', (int) getmypid(), $line, PHP_EOL)); + } + } + + /** + * Nobody waits for the exec, so a forked process takes the role of the + * waiting parent of a proc_open() restart: it ignores Ctrl-C, outlives the + * main process and then removes the ini. It ends by exec'ing rm, so PHP's + * shutdown sequence (shutdown functions, destructors, Xdebug output) never + * runs in it. + */ + private function startTmpIniWatchdog(): bool + { + $tmpIni = (string) $this->tmpIni; + $parentPid = getmypid(); + $pid = pcntl_fork(); + if ($pid === -1) { + return false; + } + if ($pid > 0) { + return true; + } + + foreach ([SIGINT, SIGTERM, SIGHUP, SIGQUIT] as $signal) { + pcntl_signal($signal, SIG_IGN); + } + fclose(STDIN); + fclose(STDOUT); + fclose(STDERR); + while (posix_getppid() === $parentPid) { + usleep(self::WATCHDOG_POLL_MICROSECONDS); + } + pcntl_exec('/bin/rm', ['-f', $tmpIni]); + @unlink($tmpIni); + exit(0); + } + #[Override] protected function requiresRestart(bool $default): bool { @@ -50,6 +157,17 @@ protected function restart(array $command): void } } + if ($this->execIniArgs !== null && function_exists('pcntl_exec') && $this->startTmpIniWatchdog()) { + $args = []; + foreach ($this->execIniArgs as $iniArg) { + $args[] = '-d'; + $args[] = $iniArg; + } + $this->reportRestarting(array_merge([$command[0]], $args, array_slice($command, 1))); + pcntl_exec($command[0], array_merge($args, array_slice($command, 1))); + // pcntl_exec() returns only on failure, wait for a child instead + } + parent::restart($command); } diff --git a/src/Turbo/TurboProcessRestarter.php b/src/Turbo/TurboProcessRestarter.php index 8dab17bd1c8..98aefca0df6 100644 --- a/src/Turbo/TurboProcessRestarter.php +++ b/src/Turbo/TurboProcessRestarter.php @@ -2,10 +2,12 @@ namespace PHPStan\Turbo; +use PHPStan\Process\ProcessRestarter; use function explode; use function extension_loaded; use function function_exists; use function get_cfg_var; +use function getenv; use function in_array; use function ini_get; use function is_string; @@ -13,7 +15,9 @@ use function pcntl_exec; use function php_ini_loaded_file; use function strtolower; +use function strtoupper; use function trim; +use function xdebug_info; use const PHP_BINARY; /** @@ -100,10 +104,18 @@ public static function getRestartExtensionPath(): ?string * Another program that loads PHPStan's phar, Rector for example, passes * the path to that phar: Phar::running() only knows the phar that runs. * + * When Xdebug, pcov or tideways has to go as well, the same exec does it: + * ProcessRestarter writes the ini without them and execs with the entries + * built here, so the process restarts once instead of three times. That + * path needs the Composer autoloader, which is fine for a process about + * to be replaced. $autoloadPath is required for it when given; a caller + * that already loaded its autoloader passes null. $appName is the + * xdebug-handler prefix of the caller's environment variables. + * * @api * @param list $argv */ - public static function restartIfSuitable(array $argv, ?string $pharPath = null): void + public static function restartIfSuitable(array $argv, ?string $pharPath = null, ?string $autoloadPath = null, string $appName = 'phpstan'): void { if (get_cfg_var(self::RESTARTED_INI) !== false) { // already restarted — whatever did not take effect (a binary that @@ -127,12 +139,36 @@ public static function restartIfSuitable(array $argv, ?string $pharPath = null): || !function_exists('posix_kill') ) { // fork mode is impossible here (see ForkParallelChecker) and - // spawned workers get the extension from ProcessHelper already + // spawned workers get the extension from ProcessHelper already; + // CommandHelper restarts without Xdebug in a child process return; } $extensionPath = extension_loaded('phpstan_turbo') ? null : TurboExtensionSelector::findExtension($pharPath); $opcacheArgs = self::getOpcacheArgs(); + + $iniArgs = ['memory_limit=' . ini_get('memory_limit')]; + foreach ($opcacheArgs as $opcacheArg) { + $iniArgs[] = $opcacheArg; + } + if ($extensionPath !== null) { + $iniArgs[] = 'extension=' . $extensionPath; + $iniArgs[] = self::EXTENSION_PATH_INI . '=' . $extensionPath; + } + $iniArgs[] = self::RESTARTED_INI . '=1'; + + if (self::mayNeedExtensionsRemoved($argv, $appName)) { + if ($autoloadPath !== null) { + require_once $autoloadPath; + } + $restarter = new ProcessRestarter($appName); + $restarter->setPersistent(); + $restarter->setExecIniArgs($iniArgs); + $restarter->check(); + // returns when ProcessRestarter decided against a restart + unset($restarter); + } + if ( $extensionPath === null && !self::resolveOpcacheRestartNeeded($opcacheArgs, self::getCurrentIniValues($opcacheArgs)) @@ -146,20 +182,10 @@ public static function restartIfSuitable(array $argv, ?string $pharPath = null): $args[] = '-c'; $args[] = $phpIni; } - $args[] = '-d'; - $args[] = 'memory_limit=' . ini_get('memory_limit'); - foreach ($opcacheArgs as $opcacheArg) { + foreach ($iniArgs as $iniArg) { $args[] = '-d'; - $args[] = $opcacheArg; + $args[] = $iniArg; } - if ($extensionPath !== null) { - $args[] = '-d'; - $args[] = 'extension=' . $extensionPath; - $args[] = '-d'; - $args[] = self::EXTENSION_PATH_INI . '=' . $extensionPath; - } - $args[] = '-d'; - $args[] = self::RESTARTED_INI . '=1'; foreach ($argv as $arg) { $args[] = $arg; } @@ -168,6 +194,27 @@ public static function restartIfSuitable(array $argv, ?string $pharPath = null): // pcntl_exec() returns only on failure — continue as we are } + /** + * A cheap pre-check without the autoloader. ProcessRestarter makes the + * real decision, for example about an Xdebug in mode "off". + * + * @param list $argv + */ + private static function mayNeedExtensionsRemoved(array $argv, string $appName): bool + { + if (in_array('--xdebug', $argv, true)) { + return false; + } + if ((bool) getenv(strtoupper($appName) . '_ALLOW_XDEBUG')) { + return false; + } + + // xdebug_info() exists since Xdebug 3.1, older versions count as active + return (extension_loaded('xdebug') && (!function_exists('xdebug_info') || xdebug_info('mode') !== [])) + || (extension_loaded('pcov') && ini_get('pcov.enabled') === '1') + || extension_loaded('tideways'); + } + /** * Whether the OPcache configuration the restart would set differs from * the one in effect — the second reason to restart, independent of turbo: