Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions bin/phpstan
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this line now is doing the xdebug auto-restart, I think we can drop the lines in CommandHelper?

if (!$allowXdebug) {
$xdebug = new ProcessRestarter('phpstan');
$xdebug->setPersistent();
$xdebug->check();
unset($xdebug);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet in this version. Without pcntl (Windows, or pcntl in disable_functions), the check() in CommandHelper is still the only Xdebug restart. In the restarted process it also feeds the --debug note. Both can move into bin/phpstan, so CommandHelper keeps only the notes. I'd do that together with the class merge from the other thread.

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();
Expand Down
10 changes: 9 additions & 1 deletion build/composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
4 changes: 4 additions & 0 deletions e2e/restart-once/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
level: 0
paths:
- test.php
12 changes: 12 additions & 0 deletions e2e/restart-once/start-log.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php declare(strict_types = 1);

// Loaded through auto_prepend_file in php.ini. Writes one line for every
// start of the main "analyse" process, so the test can count the restarts.
if (in_array('analyse', $_SERVER['argv'] ?? [], true)) {
file_put_contents(__DIR__ . '/starts.log', sprintf(
"opcache=%d pcov=%d xdebug=%d\n",
(int) ini_get('opcache.enable_cli'),
(int) (extension_loaded('pcov') && ini_get('pcov.enabled') === '1'),
(int) extension_loaded('xdebug'),
), FILE_APPEND);
}
3 changes: 3 additions & 0 deletions e2e/restart-once/test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

echo 'Hello';
118 changes: 118 additions & 0 deletions src/Process/ProcessRestarter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,128 @@

use Composer\XdebugHandler\XdebugHandler;
use Override;
use function array_merge;
use function array_slice;
use function count;
use function extension_loaded;
use function fclose;
use function file_get_contents;
use function file_put_contents;
use function function_exists;
use function fwrite;
use function getenv;
use function getmypid;
use function implode;
use function in_array;
use function ini_get;
use function pcntl_exec;
use function pcntl_fork;
use function pcntl_signal;
use function posix_getppid;
use function preg_replace;
use function sprintf;
use function strtoupper;
use function unlink;
use function usleep;
use const PHP_EOL;
use const SIG_IGN;
use const SIGHUP;
use const SIGINT;
use const SIGQUIT;
use const SIGTERM;
use const STDERR;
use const STDIN;
use const STDOUT;

// see https://github.com/composer/xdebug-handler/issues/174
final class ProcessRestarter extends XdebugHandler

@staabm staabm Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make more sense that we consolidate the 2 restarter classes?

the PR as is has the pcntl_exec handling in 2 places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, pcntl_exec should live in one place. AJenbo's manager may already do that, so I'll line this PR up with it once it's posted (see my comment on #6757).

{

private const WATCHDOG_POLL_MICROSECONDS = 100_000;

/** @var array<string> */
private array $extensions = [];

/** @var list<string>|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<string> $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<string> $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
{
Expand Down Expand Up @@ -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);
}

Expand Down
75 changes: 61 additions & 14 deletions src/Turbo/TurboProcessRestarter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@

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;
use function max;
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;

/**
Expand Down Expand Up @@ -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<string> $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
Expand All @@ -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))
Expand All @@ -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;
}
Expand All @@ -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<string> $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:
Expand Down
Loading