From 41077c0785ce098f862c0bddd8d819c6433ca091 Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Wed, 21 Oct 2026 12:37:25 +0200 Subject: [PATCH] Implement own multi extension restart manager --- .github/workflows/e2e-tests.yml | 207 ++++++- .github/workflows/phar.yml | 6 +- .gitignore | 3 + Makefile | 2 +- bin/phpstan | 11 +- build/composer-dependency-analyser.php | 16 +- build/phpstan.neon | 2 +- composer.json | 1 - composer.lock | 197 +------ e2e/bug-15407/pcov-check.php | 5 +- e2e/restart-manager/check.php | 136 +++++ e2e/restart-manager/failing.neon | 4 + e2e/restart-manager/failing/Failing.php | 7 + e2e/restart-manager/phpstan.neon | 10 + e2e/restart-manager/probe.php | 32 ++ e2e/restart-manager/src/file1.php | 6 + e2e/restart-manager/src/file2.php | 6 + e2e/restart-manager/src/file3.php | 6 + e2e/restart-manager/src/file4.php | 6 + e2e/restart-manager/src/file5.php | 6 + e2e/restart-manager/src/file6.php | 6 + e2e/restart-manager/src/file7.php | 6 + e2e/restart-manager/src/file8.php | 6 + e2e/restart-manager/start-log.php | 9 + src/Command/AnalyseCommand.php | 2 +- src/Command/ClearResultCacheCommand.php | 2 +- src/Command/CommandHelper.php | 29 +- src/Command/FixerWorkerCommand.php | 2 +- src/Command/ResultCacheInfoCommand.php | 2 +- src/Command/WorkerCommand.php | 2 +- src/Parallel/ForkParallelChecker.php | 14 +- src/Process/ProcessHelper.php | 23 +- src/Process/ProcessRestarter.php | 56 -- src/Process/Restart/ExtensionRegistry.php | 363 ++++++++++++ src/Process/Restart/IniComposer.php | 220 ++++++++ src/Process/Restart/OpcacheSettings.php | 255 +++++++++ .../Restart/RestartDiagnoseExtension.php | 67 +++ src/Process/Restart/RestartExecutor.php | 120 ++++ src/Process/Restart/RestartManager.php | 520 ++++++++++++++++++ src/Process/Restart/RestartReason.php | 87 +++ src/Turbo/TurboDiagnoseExtension.php | 7 +- src/Turbo/TurboExtensionSelector.php | 32 +- src/Turbo/TurboProcessRestarter.php | 369 +------------ tests/PHPStan/Process/ProcessHelperTest.php | 7 +- .../Process/Restart/ExtensionRegistryTest.php | 295 ++++++++++ .../Process/Restart/IniComposerTest.php | 205 +++++++ .../Restart/OpcacheSettingsTest.php} | 20 +- .../Process/Restart/RestartManagerTest.php | 156 ++++++ tests/PHPStan/Process/Restart/data/main.ini | 8 + .../PHPStan/Process/Restart/data/scanned.ini | 6 + .../PHPStan/Process/Restart/data/tideways.ini | 3 + turbo-ext/CLAUDE.md | 2 +- 52 files changed, 2878 insertions(+), 692 deletions(-) create mode 100644 e2e/restart-manager/check.php create mode 100644 e2e/restart-manager/failing.neon create mode 100644 e2e/restart-manager/failing/Failing.php create mode 100644 e2e/restart-manager/phpstan.neon create mode 100644 e2e/restart-manager/probe.php create mode 100644 e2e/restart-manager/src/file1.php create mode 100644 e2e/restart-manager/src/file2.php create mode 100644 e2e/restart-manager/src/file3.php create mode 100644 e2e/restart-manager/src/file4.php create mode 100644 e2e/restart-manager/src/file5.php create mode 100644 e2e/restart-manager/src/file6.php create mode 100644 e2e/restart-manager/src/file7.php create mode 100644 e2e/restart-manager/src/file8.php create mode 100644 e2e/restart-manager/start-log.php delete mode 100644 src/Process/ProcessRestarter.php create mode 100644 src/Process/Restart/ExtensionRegistry.php create mode 100644 src/Process/Restart/IniComposer.php create mode 100644 src/Process/Restart/OpcacheSettings.php create mode 100644 src/Process/Restart/RestartDiagnoseExtension.php create mode 100644 src/Process/Restart/RestartExecutor.php create mode 100644 src/Process/Restart/RestartManager.php create mode 100644 src/Process/Restart/RestartReason.php create mode 100644 tests/PHPStan/Process/Restart/ExtensionRegistryTest.php create mode 100644 tests/PHPStan/Process/Restart/IniComposerTest.php rename tests/PHPStan/{Turbo/TurboProcessRestarterTest.php => Process/Restart/OpcacheSettingsTest.php} (88%) create mode 100644 tests/PHPStan/Process/Restart/RestartManagerTest.php create mode 100644 tests/PHPStan/Process/Restart/data/main.ini create mode 100644 tests/PHPStan/Process/Restart/data/scanned.ini create mode 100644 tests/PHPStan/Process/Restart/data/tideways.ini diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7945c6f431b..7a4ddf727cc 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -2037,19 +2037,20 @@ jobs: ../bashunit assert not_contains 'Ds\Seq' "$OUTPUT" - script: | cd e2e/bug-15407 - XDEBUG_HANDLER_DEBUG=1 ../../bin/phpstan analyse --no-progress 2>&1 | tee -a output.txt - ../bashunit assert file_contains output.txt "Process restarting" + ../../bin/phpstan analyse --no-progress -vvv 2>&1 | tee -a output.txt + ../bashunit assert file_contains output.txt "Restarting process: pcov (pcov.enabled=1)" coverage: "pcov" - script: | cd e2e/bug-15407 - XDEBUG_HANDLER_DEBUG=1 ../../bin/phpstan analyse --no-progress 2>&1 | tee -a output.txt - ../bashunit assert file_contains output.txt "Process restarting" + ../../bin/phpstan analyse --no-progress -vvv 2>&1 | tee -a output.txt + ../bashunit assert file_contains output.txt "Restarting process: pcov (pcov.enabled=1)" coverage: "pcov" ini-values: "pcov.enabled=1" - script: | cd e2e/bug-15407 - XDEBUG_HANDLER_DEBUG=1 ../../bin/phpstan analyse --no-progress 2>&1 | tee -a output.txt - ../bashunit assert file_not_contains output.txt "Process restarting" + # the restart may still happen to activate OPcache, just not for pcov + ../../bin/phpstan analyse --no-progress -vvv 2>&1 | tee -a output.txt + ../bashunit assert file_not_contains output.txt "pcov (pcov.enabled" coverage: "pcov" ini-values: "pcov.enabled=0" @@ -2117,8 +2118,8 @@ jobs: run: make build-turbo - name: "Load the turbo extension through php.ini" - # TurboProcessRestarter re-executes the process with the loaded php.ini - # to activate OPcache; an extension passed as -d would not survive that, + # the restart re-executes the process with the loaded ini files to + # activate OPcache; an extension passed as -d would not survive that, # one from the ini scan dir is loaded again run: | echo "extension=$PWD/turbo-ext/phpstan_turbo.so" | sudo tee "$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')/99-phpstan-turbo.ini" @@ -2246,8 +2247,8 @@ jobs: make -j"$(nproc)" - name: "Load both extensions through php.ini" - # TurboProcessRestarter re-executes the process with the loaded php.ini - # to activate OPcache; extensions passed as -d would not survive that, + # the restart re-executes the process with the loaded ini files to + # activate OPcache; extensions passed as -d would not survive that, # ones from the ini scan dir are loaded again run: | printf 'extension=%s\nextension=%s\n' "$PWD/turbo-ext/phpstan_turbo.so" "$RUNNER_TEMP/forkunsafe/modules/forkunsafe.so" | sudo tee "$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')/99-phpstan-turbo.ini" @@ -2281,3 +2282,189 @@ jobs: echo "$OUTPUT" ../bashunit assert contains 'Mechanism: fork (pcntl_fork)' "$OUTPUT" ../bashunit assert contains '[OK] No errors' "$OUTPUT" + + process-restart-e2e-tests: + name: "Process restart E2E tests" + runs-on: ${{ matrix.operating-system }} + timeout-minutes: 30 + + defaults: + run: + shell: bash + + strategy: + fail-fast: false + matrix: + include: + # pcov armed: one restart, every process - the forked worker + # included, which no per-worker `-d` injection could reach - runs + # disarmed. pcov stays loaded: the rows disarm rather than unload, + # which keeps the extension's own symbols reflectable. setup-php + # loads OPcache dormant on CLI, so the same restart activates it in + # every Linux row + - operating-system: "ubuntu-latest" + coverage: "pcov" + extensions: "" + script: | + php ../../bin/phpstan analyse --no-progress + php check.php restarted-for=pcov,opcache pcov-armed=no pcov-loaded=yes opcache-cli-on=yes processes=2+ starts=2 + + # Windows has no pcntl_exec, so the restart runs as a child process + # whose exit code is passed on. OPcache is left alone there: the + # workers are spawned, and ProcessHelper activates it for each + - operating-system: "windows-latest" + coverage: "pcov" + extensions: "" + script: | + php ../../bin/phpstan analyse --no-progress + php check.php restarted-for=pcov pcov-armed=no processes=1+ starts=2 + set +e + php ../../bin/phpstan analyse --no-progress --configuration failing.neon + EXIT_CODE=$? + set -e + if [ "$EXIT_CODE" != "1" ]; then + echo "FAIL: the restarted process' exit code was not passed on, got $EXIT_CODE" + exit 1 + fi + + # xdebug: one restart disarms it via xdebug.mode=off, and it stays + # loaded so xdebug_*() remains reflectable. No note about it being + # active either + - operating-system: "ubuntu-latest" + coverage: "xdebug" + extensions: "" + script: | + php ../../bin/phpstan analyse --no-progress 2>&1 | tee out.txt + if grep -q 'The Xdebug PHP extension is active, but' out.txt; then + echo "FAIL: xdebug survived the restart armed" + exit 1 + fi + php check.php restarted-for=xdebug,opcache xdebug-armed=no xdebug-loaded=yes processes=2+ starts=2 + + # XDEBUG_MODE overrides xdebug.mode, so flagging the ini alone would + # be silently overridable - the row exports the env var too + - operating-system: "ubuntu-latest" + coverage: "xdebug" + extensions: "" + script: | + XDEBUG_MODE=develop,debug php ../../bin/phpstan analyse --no-progress + php check.php restarted-for=xdebug,opcache xdebug-armed=no xdebug-loaded=yes processes=2+ starts=2 + + # --xdebug keeps xdebug armed and does not restart for it - only for + # OPcache + - operating-system: "ubuntu-latest" + coverage: "xdebug" + extensions: "" + script: | + php ../../bin/phpstan analyse --no-progress --xdebug 2>&1 | tee out.txt + if ! grep -q 'enabled, and the Xdebug PHP extension is active' out.txt; then + echo "FAIL: --xdebug did not keep xdebug" + exit 1 + fi + php check.php restarted-for=opcache xdebug-armed=yes processes=1+ starts=2 + + # xdebug and pcov together: one restart handles both. Both come in + # through `extensions` because setup-php's `coverage: xdebug` unloads + # pcov. xdebug is armed by its default mode, pcov on the command + # line - which also proves the composed ini carries runtime `-d` + # overrides over without reconstructing them from $argv + - operating-system: "ubuntu-latest" + coverage: "" + extensions: "xdebug, pcov" + script: | + php -d pcov.enabled=1 ../../bin/phpstan analyse --no-progress + php check.php restarted-for=xdebug,pcov,opcache pcov-armed=no xdebug-armed=no processes=2+ starts=2 + + # --xdebug with pcov armed: xdebug stays armed, pcov is still + # disarmed by the same restart - something xdebug-handler could never + # express + - operating-system: "ubuntu-latest" + coverage: "" + extensions: "xdebug, pcov" + script: | + php -d pcov.enabled=1 ../../bin/phpstan analyse --no-progress --xdebug + php check.php restarted-for=pcov,opcache pcov-armed=no xdebug-armed=yes processes=1+ starts=2 + + # uopz rewrites opcodes unless uopz.disable=1 + - operating-system: "ubuntu-latest" + coverage: "none" + extensions: "uopz" + script: | + php ../../bin/phpstan analyse --no-progress + php check.php restarted-for=uopz,opcache uopz-armed=no processes=2+ starts=2 + + # OPcache is dormant on CLI by default - the restart activates it, + # and the forked workers inherit the parent's warm opcode cache + - operating-system: "ubuntu-latest" + coverage: "none" + extensions: "opcache" + script: | + php -d opcache.enable_cli=0 ../../bin/phpstan analyse --no-progress -vvv > out.txt 2>&1 + cat out.txt + php check.php restarted-for=opcache opcache-cli-on=yes processes=2+ starts=2 + if ! grep -qE 'Mechanism: +fork' out.txt; then + echo "FAIL: the workers were not forked under the activated OPcache" + exit 1 + fi + + # --debug restarts like any other run - it only makes the analysis + # serial, --xdebug is what keeps xdebug + - operating-system: "ubuntu-latest" + coverage: "pcov" + extensions: "" + script: | + php ../../bin/phpstan analyse --no-progress --debug + php check.php restarted-for=pcov,opcache pcov-armed=no processes=1 starts=2 + + # diagnose never restarts either, it reports what a run would do + - operating-system: "ubuntu-latest" + coverage: "pcov" + extensions: "" + script: | + php ../../bin/phpstan diagnose 2>&1 | tee out.txt + if ! grep -qE 'Restart if started now: +yes - pcov' out.txt; then + echo "FAIL: diagnose did not report the restart it would perform" + exit 1 + fi + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: "Checkout" + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: "Install PHP" + uses: "shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962" # 2.40.0 + with: + coverage: ${{ matrix.coverage }} + php-version: "8.2" + extensions: ${{ matrix.extensions }} + + - uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0 + + - name: "Test" + working-directory: "e2e/restart-manager" + env: + SCRIPT: ${{ matrix.script }} + # Symfony wraps its notes to the terminal width and prefixes wrapped + # lines with "!", which can split a phrase in a way no post-processing + # rejoins - and would make the negative assertions pass vacuously + COLUMNS: "400" + run: | + # count every start of the main process, the one before the restart + # included - which only an ini-level hook sees + LINE=$(php -r 'echo "auto_prepend_file=\"", getcwd(), DIRECTORY_SEPARATOR, "start-log.php\"";') + INI=$(php -r 'echo php_ini_loaded_file();') + if [ "$RUNNER_OS" = "Windows" ]; then + echo "$LINE" >> "$INI" + else + echo "$LINE" | sudo tee -a "$INI" > /dev/null + fi + php ../../bin/phpstan clear-result-cache + rm -f probe.log starts.log + eval "$SCRIPT" diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml index a8517b75e93..c8160142ab2 100644 --- a/.github/workflows/phar.yml +++ b/.github/workflows/phar.yml @@ -106,7 +106,7 @@ jobs: # Box leaves the members with mtime 0 here, which OPcache refuses to # cache at all; the commit date also keeps consecutive builds apart for - # opcache.validate_timestamps (see TurboProcessRestarter::resolveOpcacheArgs()). + # opcache.validate_timestamps (see OpcacheSettings::resolveIniEntries()). # resign.php fails if any member is left at mtime 0. - name: "Stamp PHAR member timestamps" run: php compiler/build/resign.php tmp/phpstan.phar "$(git log -1 --format=%cI)" @@ -2276,8 +2276,8 @@ jobs: - name: "Install PHP" # Deliberately without `ini-file: development`: that ini lists the - # pcntl functions in disable_functions, and TurboProcessRestarter then - # returns before TurboExtensionSelector ever runs. With pcntl available + # pcntl functions in disable_functions, and RestartManager then never + # asks TurboExtensionSelector for the binary. With pcntl available # the phar boots through the turbo restart code, which bin/phpstan # loads before the Composer autoloader - on PHP < 8.0 without the # symfony polyfills (https://github.com/phpstan/phpstan/issues/15137). diff --git a/.gitignore b/.gitignore index 87ab31be431..094b9884408 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ e2e/bashunit /e2e/result-cache-stale-objects/tmp /e2e/result-cache-truncated/tmp /e2e/result-cache-worker-count/tmp +e2e/restart-manager/probe.log +e2e/restart-manager/starts.log +e2e/restart-manager/out.txt diff --git a/Makefile b/Makefile index ea78ec2e2cb..d544a92335d 100644 --- a/Makefile +++ b/Makefile @@ -225,7 +225,7 @@ download-coverage: # `make build-turbo PHP_CONFIG=/path/to/php-config` (command-line variables # reach the sub-make through MAKEFLAGS). Nothing is installed: load the # result with `php -d extension=...`, or from the ini scan dir when the run -# re-executes itself (TurboProcessRestarter drops a -d extension). +# re-executes itself (RestartManager drops a -d extension). TURBO_JOBS ?= $(shell getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4) .PHONY: build-turbo diff --git a/bin/phpstan b/bin/phpstan index 5280bed96aa..48efeee92a8 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -10,8 +10,8 @@ use PHPStan\Command\FixerWorkerCommand; use PHPStan\Command\ResultCacheInfoCommand; use PHPStan\Command\WorkerCommand; use PHPStan\Internal\ComposerHelper; +use PHPStan\Process\Restart\RestartManager; use PHPStan\Turbo\TurboExtensionEnabler; -use PHPStan\Turbo\TurboProcessRestarter; use Symfony\Component\Console\Helper\ProgressBar; (function () { @@ -27,8 +27,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']); + require_once __DIR__ . '/../src/Process/Restart/RestartReason.php'; + require_once __DIR__ . '/../src/Process/Restart/OpcacheSettings.php'; + require_once __DIR__ . '/../src/Process/Restart/ExtensionRegistry.php'; + require_once __DIR__ . '/../src/Process/Restart/IniComposer.php'; + require_once __DIR__ . '/../src/Process/Restart/RestartExecutor.php'; + require_once __DIR__ . '/../src/Process/Restart/RestartManager.php'; + RestartManager::run($_SERVER['argv']); TurboExtensionEnabler::trustOwnTypesIfSuitable($_SERVER['argv']); $analysisStartTime = microtime(true); diff --git a/build/composer-dependency-analyser.php b/build/composer-dependency-analyser.php index ebaec5d9750..e46cf402576 100644 --- a/build/composer-dependency-analyser.php +++ b/build/composer-dependency-analyser.php @@ -33,6 +33,19 @@ $unknownClasses[] = 'PHPStanTurbo\Runtime'; } +$unknownFunctions = []; +foreach ([ + 'uopz_allow_exit', // ext-uopz, only called when the extension is loaded + 'sapi_windows_set_ctrl_handler', // Windows-only, guarded by function_exists() +] as $function) { + // conditional for the same reason as PHPStanTurbo\Runtime above + if (function_exists($function)) { + continue; + } + + $unknownFunctions[] = $function; +} + return $config ->addPathToScan(__DIR__ . '/../bin', true) ->ignoreErrorsOnPackages( @@ -44,4 +57,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/build/phpstan.neon b/build/phpstan.neon index c56533ae6cb..bfdfb8d37f4 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -159,7 +159,7 @@ parameters: - # called from bin/phpstan before the autoloader, outside the analysed paths identifier: shipmonk.deadMethod - message: '#^Unused PHPStan\\Turbo\\TurboProcessRestarter::restartIfSuitable#' + message: '#^Unused PHPStan\\Process\\Restart\\RestartManager::run#' reportUnmatched: false - # called from bin/phpstan before the autoloader, outside the analysed paths diff --git a/composer.json b/composer.json index 2cacebaac07..4aa026deb9e 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,6 @@ "composer-runtime-api": "^2.0", "clue/ndjson-react": "^1.0", "composer/semver": "^3.4", - "composer/xdebug-handler": "^3.0.3", "fidry/cpu-core-counter": "^1.2", "guzzlehttp/guzzle": "^7.9", "hoa/compiler": "3.17.08.08", diff --git a/composer.lock b/composer.lock index 1e1c8034f47..25efda9df84 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "11369100d2f311b85c9afd000980a96c", + "content-hash": "ecb4c72bbfe50089cd3e8ee6fdb0c711", "packages": [ { "name": "clue/ndjson-react", @@ -70,85 +70,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "composer/pcre", - "version": "3.3.1", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.11.10", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - }, - "phpstan": { - "includes": [ - "extension.neon" - ] - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.1" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-08-27T18:44:43+00:00" - }, { "name": "composer/semver", "version": "3.4.4", @@ -226,72 +147,6 @@ ], "time": "2025-08-20T19:15:30+00:00" }, - { - "name": "composer/xdebug-handler", - "version": "3.0.5", - "source": { - "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", - "shasum": "" - }, - "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" - }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Composer\\XdebugHandler\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" - } - ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-05-06T16:37:16+00:00" - }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -2864,56 +2719,6 @@ }, "time": "2023-04-04T09:54:51+00:00" }, - { - "name": "psr/log", - "version": "2.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/ef29f6d262798707a9edd554e2b82517ef3a9376", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/2.0.0" - }, - "time": "2021-07-14T16:41:46+00:00" - }, { "name": "ralouphie/getallheaders", "version": "3.0.3", diff --git a/e2e/bug-15407/pcov-check.php b/e2e/bug-15407/pcov-check.php index 4c4934b8333..455c70e1b94 100644 --- a/e2e/bug-15407/pcov-check.php +++ b/e2e/bug-15407/pcov-check.php @@ -1,6 +1,7 @@ = ... + * + * restarted-for=pcov,xdebug the marker every process must carry, "" for "no restart happened" + * xdebug-armed=no whether xdebug's hooks may be installed (effective mode is not off) + * pcov-armed=no whether pcov's call hook may be installed + * uopz-armed=no whether uopz may be rewriting opcodes + * opcache-cli-on=no whether OPcache may be enabled for CLI + * xdebug-loaded=yes whether the extension may be *present* - the flag rows disarm + * rather than unload, so this stays yes where xdebug-armed=no + * processes=2+ how many processes the probe must have seen + * starts=2 how often the main process must have started, counted by + * start-log.php: 2 is one restart, more would be a second one + */ + +$log = __DIR__ . '/probe.log'; +if (!is_file($log)) { + fwrite(STDERR, "FAIL: the probe never ran - no probe.log\n"); + exit(1); +} + +$contents = file_get_contents($log); +if ($contents === false) { + fwrite(STDERR, "FAIL: probe.log is not readable\n"); + exit(1); +} + +$records = []; +foreach (explode("\n", trim($contents)) as $line) { + if ($line === '') { + continue; + } + + $records[] = json_decode($line, true, 512, JSON_THROW_ON_ERROR); +} + +$expectations = []; +foreach (array_slice($argv, 1) as $argument) { + [$name, $value] = explode('=', $argument, 2); + $expectations[$name] = $value; +} + +/** Whether each switch is armed, given one probe record. */ +$isArmed = [ + 'xdebug-armed' => static function (array $record): bool { + if ($record['xdebug_loaded'] !== true) { + return false; + } + + foreach (explode(',', str_replace(' ', '', strtolower($record['xdebug_mode']))) as $mode) { + if ($mode !== '' && $mode !== 'off') { + return true; + } + } + + return false; + }, + 'pcov-armed' => static fn (array $record): bool => $record['pcov_loaded'] === true + && !in_array($record['pcov_enabled'], ['', '0'], true), + 'uopz-armed' => static fn (array $record): bool => $record['uopz_loaded'] === true + && in_array($record['uopz_disable'], ['', '0'], true), + 'opcache-cli-on' => static fn (array $record): bool => !in_array($record['opcache_cli'], ['', '0'], true), +]; + +$isPresent = [ + 'xdebug-loaded' => static fn (array $record): bool => $record['xdebug_loaded'] === true, + 'pcov-loaded' => static fn (array $record): bool => $record['pcov_loaded'] === true, +]; + +$failures = []; + +$minimumProcesses = (int) rtrim($expectations['processes'] ?? '1+', '+'); +if (count($records) < $minimumProcesses) { + $failures[] = sprintf('expected at least %d processes, the probe saw %d', $minimumProcesses, count($records)); +} + +foreach ($records as $index => $record) { + $where = sprintf('process %d (pid %s)', $index + 1, $record['pid']); + + if (array_key_exists('restarted-for', $expectations) && $record['restarted_for'] !== $expectations['restarted-for']) { + $failures[] = sprintf( + '%s: PHPSTAN_RESTART is "%s", expected "%s"', + $where, + $record['restarted_for'], + $expectations['restarted-for'], + ); + } + + foreach ($isArmed + $isPresent as $name => $predicate) { + if (!array_key_exists($name, $expectations)) { + continue; + } + + $expected = $expectations[$name] === 'yes'; + if ($predicate($record) === $expected) { + continue; + } + + $failures[] = sprintf('%s: %s is %s, expected %s', $where, $name, $expected ? 'no' : 'yes', $expectations[$name]); + } + + // the pointer to the composed ini must never reach a descendant, or a + // worker would delete the file its siblings are still reading + if ($index > 0 && $record['restart_ini_pointer'] !== '') { + $failures[] = sprintf('%s: inherited PHPSTAN_RESTART_INI="%s"', $where, $record['restart_ini_pointer']); + } +} + +if (array_key_exists('starts', $expectations)) { + $startsLog = __DIR__ . '/starts.log'; + $starts = is_file($startsLog) ? count(file($startsLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) : 0; + if ($starts !== (int) $expectations['starts']) { + $failures[] = sprintf('the main process started %d times, expected %s', $starts, $expectations['starts']); + } +} + +// a second restart would compose a second ini and leak the first one +$leftovers = glob(sys_get_temp_dir() . '/phpstan-restart-ini-*'); +if ($leftovers !== false && $leftovers !== []) { + $failures[] = sprintf('composed ini files left behind: %s', implode(', ', $leftovers)); +} + +foreach ($records as $record) { + echo json_encode($record, JSON_THROW_ON_ERROR), "\n"; +} + +if ($failures !== []) { + fwrite(STDERR, "FAIL:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} + +echo sprintf("OK: %d processes checked\n", count($records)); diff --git a/e2e/restart-manager/failing.neon b/e2e/restart-manager/failing.neon new file mode 100644 index 00000000000..37d41b1e961 --- /dev/null +++ b/e2e/restart-manager/failing.neon @@ -0,0 +1,4 @@ +parameters: + level: 0 + paths: + - failing diff --git a/e2e/restart-manager/failing/Failing.php b/e2e/restart-manager/failing/Failing.php new file mode 100644 index 00000000000..f2596b83341 --- /dev/null +++ b/e2e/restart-manager/failing/Failing.php @@ -0,0 +1,7 @@ + getmypid(), + 'xdebug_loaded' => extension_loaded('xdebug'), + 'xdebug_mode' => $xdebugMode, + 'pcov_loaded' => extension_loaded('pcov'), + 'pcov_enabled' => (string) ini_get('pcov.enabled'), + 'uopz_loaded' => extension_loaded('uopz'), + 'uopz_disable' => (string) ini_get('uopz.disable'), + 'opcache_cli' => (string) ini_get('opcache.enable_cli'), + 'restarted_for' => (string) getenv('PHPSTAN_RESTART'), + 'restart_ini_pointer' => (string) getenv('PHPSTAN_RESTART_INI'), + 'phprc' => (string) getenv('PHPRC'), + 'loaded_ini' => (string) php_ini_loaded_file(), + 'memory_limit' => (string) ini_get('memory_limit'), + ], JSON_THROW_ON_ERROR) . "\n", + FILE_APPEND, +); diff --git a/e2e/restart-manager/src/file1.php b/e2e/restart-manager/src/file1.php new file mode 100644 index 00000000000..15f9f4bfb27 --- /dev/null +++ b/e2e/restart-manager/src/file1.php @@ -0,0 +1,6 @@ +getErrorOutput() : $output; $errorOutput = new SymfonyOutput($symfonyErrorOutput, new SymfonyStyle(new ErrorsConsoleStyle($input, $symfonyErrorOutput))); - if (!$allowXdebug) { - $xdebug = new ProcessRestarter('phpstan'); - $xdebug->setPersistent(); - $xdebug->check(); - unset($xdebug); - } - if ($allowXdebug) { - if (!ProcessRestarter::isXdebugActive()) { + if (!ExtensionRegistry::isXdebugActive()) { $errorOutput->getStyle()->note('You are running with "--xdebug" enabled, but the Xdebug PHP extension is not active. The process will not halt at breakpoints.'); } else { $errorOutput->getStyle()->note("You are running with \"--xdebug\" enabled, and the Xdebug PHP extension is active.\nThe process will halt at breakpoints, but PHPStan will run much slower.\nUse this only if you are debugging PHPStan itself or your custom extensions."); } - } elseif (ProcessRestarter::isXdebugActive()) { + } elseif (ExtensionRegistry::isXdebugActive()) { $errorOutput->getStyle()->note('The Xdebug PHP extension is active, but "--xdebug" is not used. This may slow down performance and the process will not halt at breakpoints.'); - } elseif ($debugEnabled) { - $v = ProcessRestarter::getSkippedVersion(); - if ($v !== '') { - $errorOutput->getStyle()->note( - "The Xdebug PHP extension is active, but \"--xdebug\" is not used.\n" . - "The process was restarted and it will not halt at breakpoints.\n" . - 'Use "--xdebug" if you want to halt at breakpoints.', - ); - } + } elseif ($debugEnabled && RestartManager::wasRestartedFor('xdebug')) { + $errorOutput->getStyle()->note( + "The Xdebug PHP extension is active, but \"--xdebug\" is not used.\n" . + "The process was restarted and it will not halt at breakpoints.\n" . + 'Use "--xdebug" if you want to halt at breakpoints.', + ); } if ($memoryLimit !== null) { diff --git a/src/Command/FixerWorkerCommand.php b/src/Command/FixerWorkerCommand.php index 54c0528f0be..60357599096 100644 --- a/src/Command/FixerWorkerCommand.php +++ b/src/Command/FixerWorkerCommand.php @@ -40,7 +40,7 @@ protected function configure(): void new InputOption(AnalyseCommand::OPTION_LEVEL, 'l', InputOption::VALUE_REQUIRED, 'Level of rule options - the higher the stricter'), new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), new InputOption('memory-limit', mode: InputOption::VALUE_REQUIRED, description: 'Memory limit for analysis'), - new InputOption('xdebug', mode: InputOption::VALUE_NONE, description: 'Allow running with Xdebug for debugging purposes'), + new InputOption('xdebug', mode: InputOption::VALUE_NONE, description: 'Allow running with Xdebug for debugging purposes (pcov and uopz are still turned off)'), new InputOption('server-port', mode: InputOption::VALUE_REQUIRED, description: 'Server port for FixerApplication'), ]) ->setHidden(true); diff --git a/src/Command/ResultCacheInfoCommand.php b/src/Command/ResultCacheInfoCommand.php index 23f03c0fc0d..326896d818e 100644 --- a/src/Command/ResultCacheInfoCommand.php +++ b/src/Command/ResultCacheInfoCommand.php @@ -51,7 +51,7 @@ protected function configure(): void new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), new InputOption('debug', mode: InputOption::VALUE_NONE, description: 'Show debug information - do not catch internal errors'), new InputOption('memory-limit', mode: InputOption::VALUE_REQUIRED, description: 'Memory limit for reading the result cache'), - new InputOption('xdebug', mode: InputOption::VALUE_NONE, description: 'Allow running with Xdebug for debugging purposes'), + new InputOption('xdebug', mode: InputOption::VALUE_NONE, description: 'Allow running with Xdebug for debugging purposes (pcov and uopz are still turned off)'), new InputOption('json', mode: InputOption::VALUE_NONE, description: 'Print the information as JSON instead of a human-readable summary'), new InputOption('fail-without-result-cache', mode: InputOption::VALUE_NONE, description: 'Return non-zero exit code when the result cache would not be used'), ]); diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 4ebaa02439f..3c18e87b4b2 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -43,7 +43,7 @@ protected function configure(): void new InputOption(AnalyseCommand::OPTION_LEVEL, 'l', InputOption::VALUE_REQUIRED, 'Level of rule options - the higher the stricter'), new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'), new InputOption('memory-limit', mode: InputOption::VALUE_REQUIRED, description: 'Memory limit for analysis'), - new InputOption('xdebug', mode: InputOption::VALUE_NONE, description: 'Allow running with Xdebug for debugging purposes'), + new InputOption('xdebug', mode: InputOption::VALUE_NONE, description: 'Allow running with Xdebug for debugging purposes (pcov and uopz are still turned off)'), new InputOption('port', mode: InputOption::VALUE_REQUIRED), new InputOption('identifier', mode: InputOption::VALUE_REQUIRED), new InputOption('arena', mode: InputOption::VALUE_REQUIRED), diff --git a/src/Parallel/ForkParallelChecker.php b/src/Parallel/ForkParallelChecker.php index 87f95feede6..38da59a37ba 100644 --- a/src/Parallel/ForkParallelChecker.php +++ b/src/Parallel/ForkParallelChecker.php @@ -7,8 +7,8 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Diagnose\DiagnoseExtension; use PHPStan\Process\ProcessHelper; +use PHPStan\Process\Restart\OpcacheSettings; use PHPStan\Turbo\TurboExtensionEnabler; -use PHPStan\Turbo\TurboProcessRestarter; use function function_exists; use function getmypid; use function opcache_get_status; @@ -28,9 +28,9 @@ * version. The extension arms the phar-fork-guard: libphar serves phar:// * reads through one shared per-archive fd whose seek cursor forked * processes race on, corrupting reads — the guard gives each forked child - * a private cursor. TurboProcessRestarter re-executes the main process - * with the distributed binary loaded when needed (a forked worker inherits - * the parent's extensions; the spawn-time `-d extension=` injection cannot + * a private cursor. RestartManager re-executes the main process with the + * distributed binary loaded when needed (a forked worker inherits the + * parent's extensions; the spawn-time `-d extension=` injection cannot * reach it). Outside a phar there is nothing to guard, and no turbo is * lost by forking either: the distributed binaries only exist next to a * phar, so spawned workers of a source checkout run without turbo too — @@ -38,8 +38,8 @@ * - JIT off — its shared code buffer is not safe to populate concurrently * from forked children. Plain OPcache is fine: concurrent population of * its SHM under the allocator lock is php-fpm's normal operating model, - * and TurboProcessRestarter deliberately activates it for the restarted - * process (with JIT pinned off). + * and RestartManager deliberately activates it for the restarted process + * (with JIT pinned off). * * A forked worker ends with the extension's _exit() instead of PHP's full * teardown (see ForkedChildTerminator): the child inherits every loaded @@ -72,7 +72,7 @@ public function print(Output $output): void // (see ProcessHelper); the extension path is on the turbo lines $parentPid = getmypid(); $output->writeLineFormatted('Worker -d entries:'); - foreach (ProcessHelper::resolveWorkerIniEntries(TurboProcessRestarter::getOpcacheArgs(), PHP_OS_FAMILY, $parentPid === false ? 0 : $parentPid, 1) as $iniEntry) { + foreach (ProcessHelper::resolveWorkerIniEntries(OpcacheSettings::getIniEntries(), PHP_OS_FAMILY, $parentPid === false ? 0 : $parentPid, 1) as $iniEntry) { $output->writeLineFormatted(sprintf( ' %s%s', $iniEntry, diff --git a/src/Process/ProcessHelper.php b/src/Process/ProcessHelper.php index 2af7feadfd7..a24975c10f5 100644 --- a/src/Process/ProcessHelper.php +++ b/src/Process/ProcessHelper.php @@ -3,8 +3,9 @@ namespace PHPStan\Process; use PHPStan\Command\AnalyseCommand; +use PHPStan\Process\Restart\OpcacheSettings; +use PHPStan\Process\Restart\RestartManager; use PHPStan\Turbo\TurboExtensionSelector; -use PHPStan\Turbo\TurboProcessRestarter; use Symfony\Component\Console\Input\InputInterface; use function array_merge; use function escapeshellarg; @@ -26,7 +27,7 @@ * configuration the worker runs with. The php.ini is inherited through * `-c`, but command-line `-d` entries are not, so whatever the spawning * process got that way - the turbo extension and the OPcache setup of the - * TurboProcessRestarter restart - is repeated here; see + * restart (RestartManager) - is repeated here; see * resolveWorkerIniEntries() for the set and the reasoning. */ final class ProcessHelper @@ -70,12 +71,12 @@ public static function getWorkerCommand( // TurboExtensionSelector then knows the extension came through -d, // not the php.ini - nothing that process spawns would inherit it $processCommandArray[] = '-d'; - $processCommandArray[] = TurboProcessRestarter::EXTENSION_PATH_INI . '=' . escapeshellarg("'" . $turboExtension . "'"); + $processCommandArray[] = TurboExtensionSelector::EXTENSION_PATH_INI . '=' . escapeshellarg("'" . $turboExtension . "'"); } $parentPid = getmypid(); self::$spawnedWorkerCount++; - foreach (self::resolveWorkerIniEntries(TurboProcessRestarter::getOpcacheArgs(), PHP_OS_FAMILY, $parentPid === false ? 0 : $parentPid, self::$spawnedWorkerCount) as $iniEntry) { + foreach (self::resolveWorkerIniEntries(OpcacheSettings::getIniEntries(), PHP_OS_FAMILY, $parentPid === false ? 0 : $parentPid, self::$spawnedWorkerCount) as $iniEntry) { $processCommandArray[] = '-d'; $processCommandArray[] = $iniEntry; } @@ -129,14 +130,14 @@ public static function getWorkerCommand( * The ini entries a spawned worker gets as `-d name=value`, besides * memory_limit, sys_temp_dir and the extension. * - * The OPcache entries are the ones the TurboProcessRestarter restart - * gives the main process (see its resolveOpcacheArgs() for what each - * does). A spawned worker compiles the whole application again and gains + * The OPcache entries are the ones the restart gives the main process + * (see OpcacheSettings::resolveIniEntries() for what each does). + * A spawned worker compiles the whole application again and gains * the same from them - optimized opcodes, interned strings, the * inheritance cache - and, with the turbo extension active in a phar, * the optimizer pass dropping PHPStan's own run-time type checks, which * exists only inside OPcache. Without them, a worker on a pcntl host - * re-executed itself through TurboProcessRestarter to get OPcache: one + * re-executed itself through the restart to get OPcache: one * exec more per worker, and one that rebuilt the command line from * scratch, dropping the sys_temp_dir and extension entries of the spawn. * A Windows worker ran without OPcache altogether. @@ -154,9 +155,9 @@ public static function getWorkerCommand( * mapping private to the process. * * The restart marker closes the loop: the worker's configuration is - * decided here, so the worker must not run TurboProcessRestarter itself. + * decided here, so the worker must not restart itself. * - * @param list $opcacheArgs TurboProcessRestarter::getOpcacheArgs() of the spawning process + * @param list $opcacheArgs OpcacheSettings::getIniEntries() of the spawning process * @return list */ public static function resolveWorkerIniEntries(array $opcacheArgs, string $osFamily, int $parentPid, int $workerNumber): array @@ -165,7 +166,7 @@ public static function resolveWorkerIniEntries(array $opcacheArgs, string $osFam if ($entries !== [] && $osFamily === 'Windows') { $entries[] = sprintf('opcache.cache_id=phpstan-%d-%d', $parentPid, $workerNumber); } - $entries[] = TurboProcessRestarter::RESTARTED_INI . '=1'; + $entries[] = RestartManager::RESTARTED_INI . '=1'; return $entries; } diff --git a/src/Process/ProcessRestarter.php b/src/Process/ProcessRestarter.php deleted file mode 100644 index ca767701803..00000000000 --- a/src/Process/ProcessRestarter.php +++ /dev/null @@ -1,56 +0,0 @@ - */ - private array $extensions = []; - - #[Override] - protected function requiresRestart(bool $default): bool - { - // pcov has a perf impact similar to xdebug, so we want to disable it - if (extension_loaded('pcov') && ini_get('pcov.enabled') === '1') { - $this->extensions[] = 'pcov'; - } - - // tideways hooks deep in the php engine, so it can segfault when running phpstan with turbo. - if (extension_loaded('tideways')) { - $this->extensions[] = 'tideways'; - } - - return $default || count($this->extensions) > 0; - } - - #[Override] - protected function restart(array $command): void - { - if (count($this->extensions) > 0) { - $content = file_get_contents((string) $this->tmpIni); - - if ($content !== false) { - foreach ($this->extensions as $extension) { - $regex = '/^\s*((?:zend_)?extension\s*=.*' . $extension . '.*)$/mi'; - $content = preg_replace($regex, ';$1', $content) . PHP_EOL; - } - file_put_contents((string) $this->tmpIni, $content); - } - } - - parent::restart($command); - } - -} diff --git a/src/Process/Restart/ExtensionRegistry.php b/src/Process/Restart/ExtensionRegistry.php new file mode 100644 index 00000000000..e08538e0d47 --- /dev/null +++ b/src/Process/Restart/ExtensionRegistry.php @@ -0,0 +1,363 @@ + + */ + public static function collectReasons(bool $allowXdebug, ?string $pharPath = null): array + { + $iniSettings = []; + foreach (['xdebug.mode', 'pcov.enabled', 'uopz.disable'] as $name) { + $value = ini_get($name); + if ($value === false) { + continue; + } + + $iniSettings[$name] = $value; + } + + // XDEBUG_MODE wins over the ini file, so the effective mode - not the + // configured one - is what decides. Resolved here rather than in + // collectReasonsFor(), which stays a pure function of its arguments + $environmentMode = getenv('XDEBUG_MODE'); + if ($environmentMode !== false && $environmentMode !== '') { + $iniSettings['xdebug.mode'] = $environmentMode; + } + + $opcacheIniEntries = self::isForkCapable() ? OpcacheSettings::getIniEntries() : []; + + return self::collectReasonsFor( + get_loaded_extensions(), + $iniSettings, + self::findTurboExtensionToLoad($pharPath), + $allowXdebug, + $opcacheIniEntries, + OpcacheSettings::getCurrentIniValues($opcacheIniEntries), + ); + } + + /** + * @param list $loadedExtensions + * @param array $iniSettings + * @param string|null $turboExtensionPath the turbo binary to load, null when there is none to load or loading it would not pay off + * @param list $opcacheIniEntries `name=value` entries of OpcacheSettings::getIniEntries(), empty when OPcache is to be left alone + * @param array $currentOpcacheIniValues ini_get() of each of those names + * @return list + */ + public static function collectReasonsFor( + array $loadedExtensions, + array $iniSettings, + ?string $turboExtensionPath, + bool $allowXdebug, + array $opcacheIniEntries = [], + array $currentOpcacheIniValues = [], + ): array + { + $reasons = []; + + if (!$allowXdebug && in_array('xdebug', $loadedExtensions, true) && self::isXdebugArmed($iniSettings)) { + $reasons[] = new RestartReason( + 'xdebug', + sprintf('xdebug.mode=%s', $iniSettings['xdebug.mode'] ?? 'develop'), + iniSettings: ['xdebug.mode' => 'off'], + // xdebug reads XDEBUG_MODE in preference to the ini setting, so + // flagging the ini alone would be silently overridable + environmentVariables: ['XDEBUG_MODE' => 'off'], + ); + } + + if ( + in_array('pcov', $loadedExtensions, true) + && self::isIniSettingEnabled($iniSettings['pcov.enabled'] ?? '0') + ) { + $reasons[] = new RestartReason( + 'pcov', + 'pcov.enabled=1', + iniSettings: ['pcov.enabled' => '0'], + ); + } + + if ( + in_array('uopz', $loadedExtensions, true) + && !self::isIniSettingEnabled($iniSettings['uopz.disable'] ?? '0') + ) { + $reasons[] = new RestartReason( + 'uopz', + 'uopz.disable=0', + iniSettings: ['uopz.disable' => '1'], + ); + } + + if ($turboExtensionPath !== null && !in_array('phpstan_turbo', $loadedExtensions, true)) { + $reasons[] = new RestartReason( + 'phpstan_turbo', + sprintf('not loaded, binary available at %s', $turboExtensionPath), + commandLineArguments: [ + '-d', + 'extension=' . $turboExtensionPath, + // doubles as the marker telling TurboExtensionSelector that + // spawned workers need the -d flag passed explicitly: + // command-line -d flags, unlike php.ini, are not inherited + '-d', + TurboExtensionSelector::EXTENSION_PATH_INI . '=' . $turboExtensionPath, + ], + ); + } + + if ( + in_array('tideways', $loadedExtensions, true) + && ($turboExtensionPath !== null || in_array('phpstan_turbo', $loadedExtensions, true)) + ) { + $reasons[] = new RestartReason( + 'tideways', + 'crashes the turbo extension', + strippedExtensions: ['tideways'], + ); + } + + if (OpcacheSettings::isRestartNeeded($opcacheIniEntries, $currentOpcacheIniValues)) { + $opcacheSettings = []; + foreach ($opcacheIniEntries as $entry) { + [$name, $value] = explode('=', $entry, 2); + $opcacheSettings[$name] = $value; + } + + $reasons[] = new RestartReason( + 'opcache', + 'not configured the way PHPStan runs fastest', + iniSettings: $opcacheSettings, + ); + } + + return $reasons; + } + + /** + * Coverage and profiler extensions loaded in the current process, described + * for diagnose output. + * + * @return list + */ + public static function describeDetectedExtensions(): array + { + $descriptions = []; + + if (extension_loaded('xdebug')) { + $descriptions[] = sprintf( + 'xdebug %s (xdebug.mode=%s)', + self::describeVersion('xdebug'), + self::describeIniSetting('xdebug.mode'), + ); + } + + if (extension_loaded('pcov')) { + $descriptions[] = sprintf( + 'pcov %s (pcov.enabled=%s)', + self::describeVersion('pcov'), + self::describeIniSetting('pcov.enabled'), + ); + } + + if (extension_loaded('uopz')) { + $descriptions[] = sprintf( + 'uopz %s (uopz.disable=%s)', + self::describeVersion('uopz'), + self::describeIniSetting('uopz.disable'), + ); + } + + if (extension_loaded('tideways')) { + $descriptions[] = sprintf('tideways %s (unloaded wherever the turbo extension runs)', self::describeVersion('tideways')); + } + + foreach (self::UNMEASURED_PROFILER_EXTENSIONS as $extension) { + if (!extension_loaded($extension)) { + continue; + } + + $descriptions[] = sprintf('%s %s (idle cost not measured)', $extension, self::describeVersion($extension)); + } + + foreach (self::MEASURED_FREE_EXTENSIONS as $extension) { + if (!extension_loaded($extension)) { + continue; + } + + $descriptions[] = sprintf('%s %s (free while idle)', $extension, self::describeVersion($extension)); + } + + return $descriptions; + } + + /** + * Whether xdebug would actually halt at breakpoints and slow the process + * down. Also drives the "--xdebug" notes in CommandHelper. + */ + public static function isXdebugActive(): bool + { + if (!extension_loaded('xdebug')) { + return false; + } + + $mode = (string) getenv('XDEBUG_MODE'); + if ($mode === '') { + $iniMode = ini_get('xdebug.mode'); + if ($iniMode === false) { + // xdebug older than 3.0 has no modes and is always armed + return true; + } + + $mode = $iniMode; + } + + return self::isXdebugArmed(['xdebug.mode' => $mode]); + } + + /** + * @param array $iniSettings the effective settings, with any XDEBUG_MODE override already applied + */ + private static function isXdebugArmed(array $iniSettings): bool + { + if (!array_key_exists('xdebug.mode', $iniSettings)) { + return true; + } + + foreach (explode(',', str_replace(' ', '', $iniSettings['xdebug.mode'])) as $singleMode) { + if ($singleMode !== '' && $singleMode !== 'off') { + return true; + } + } + + return false; + } + + private static function isForkCapable(): bool + { + return function_exists('pcntl_fork') + && function_exists('pcntl_waitpid') + && function_exists('pcntl_wifexited') + && function_exists('pcntl_wexitstatus') + && function_exists('posix_kill'); + } + + /** + * The turbo binary the restart should load, null when there is none or + * when loading it would not buy anything. + */ + public static function findTurboExtensionToLoad(?string $pharPath = null): ?string + { + if (!self::isForkCapable()) { + // fork mode is impossible here (see ForkParallelChecker) and spawned + // workers get the extension from ProcessHelper already + return null; + } + + return TurboExtensionSelector::findExtension($pharPath); + } + + private static function isIniSettingEnabled(string $value): bool + { + // bool ini settings normalize to "" or "1", but a hand-written php.ini + // value reaches ini_get() the way it was written + return !in_array(strtolower($value), ['', '0', 'off', 'false', 'no'], true); + } + + private static function describeIniSetting(string $name): string + { + $value = ini_get($name); + + return $value === false || $value === '' ? 'unset' : $value; + } + + private static function describeVersion(string $extension): string + { + $version = phpversion($extension); + + return $version === false ? 'unknown version' : $version; + } + +} diff --git a/src/Process/Restart/IniComposer.php b/src/Process/Restart/IniComposer.php new file mode 100644 index 00000000000..147dcee01fa --- /dev/null +++ b/src/Process/Restart/IniComposer.php @@ -0,0 +1,220 @@ + $strippedExtensions + * @param array $iniSettings + * @return string|null the path of the composed ini, null when it could not be built + */ + public static function compose(array $strippedExtensions, array $iniSettings): ?string + { + $runtimeSettings = ini_get_all(details: false); + if ($runtimeSettings === false) { + return null; + } + + $content = self::composeContent( + self::getAllIniFiles(), + $strippedExtensions, + $iniSettings, + $runtimeSettings, + ); + if ($content === null) { + return null; + } + + $file = @tempnam(sys_get_temp_dir(), 'phpstan-restart-ini-'); + if ($file === false) { + return null; + } + + if (@file_put_contents($file, $content) === false) { + @unlink($file); + return null; + } + + return $file; + } + + /** + * @param list $iniFiles + * @param list $strippedExtensions + * @param array $iniSettings + * @param array $runtimeSettings as returned by ini_get_all(null, false) + * @return string|null null when one of the ini files could not be read or parsed + */ + public static function composeContent( + array $iniFiles, + array $strippedExtensions, + array $iniSettings, + array $runtimeSettings, + ): ?string + { + $content = ''; + foreach ($iniFiles as $file) { + $data = @file_get_contents($file); + if ($data === false) { + return null; + } + + $content .= self::stripExtensionLines(self::cutAtFirstSection($data), $strippedExtensions) . PHP_EOL; + } + + $parsed = @parse_ini_string($content); + if ($parsed === false) { + return null; + } + + foreach ($runtimeSettings as $name => $value) { + if (!is_string($value)) { + continue; + } + if ($name === 'apc.mmap_file_mask') { + // cannot be set from an ini file, see composer/xdebug-handler + continue; + } + if (self::belongsToExtension($name, $strippedExtensions)) { + continue; + } + if (array_key_exists($name, $parsed) && $parsed[$name] === $value) { + continue; + } + + $content .= $name . '="' . addcslashes($value, '\\"') . '"' . PHP_EOL; + } + + // last, so they win over the user's files and over the runtime diff + foreach ($iniSettings as $name => $value) { + $content .= $name . '="' . addcslashes($value, '\\"') . '"' . PHP_EOL; + } + + return $content; + } + + /** + * Every ini file the current process loaded, in load order. + * + * @return list + */ + public static function getAllIniFiles(): array + { + $files = []; + + $loadedFile = php_ini_loaded_file(); + if ($loadedFile !== false) { + $files[] = $loadedFile; + } + + $scannedFiles = php_ini_scanned_files(); + if ($scannedFiles !== false) { + foreach (explode(',', $scannedFiles) as $scannedFile) { + $scannedFile = trim($scannedFile); + if ($scannedFile === '') { + continue; + } + + $files[] = $scannedFile; + } + } + + return $files; + } + + /** + * Directives after a [PATH=] or [HOST=] section only apply to that path or + * host, so copying them into an unsectioned file would apply them to + * everything. + */ + private static function cutAtFirstSection(string $data): string + { + if (preg_match('/^\s*\[(?:PATH|HOST)\s*=/mi', $data, $matches, PREG_OFFSET_CAPTURE) !== 1) { + return $data; + } + + return substr($data, 0, $matches[0][1]); + } + + /** + * @param list $strippedExtensions + */ + private static function stripExtensionLines(string $data, array $strippedExtensions): string + { + foreach ($strippedExtensions as $extension) { + $replaced = preg_replace( + // not followed by a name character, so stripping tideways leaves tideways_xhprof loaded + '/^\s*((?:zend_)?extension\s*=.*' . preg_quote($extension, '/') . '(?![\w-]).*)$/mi', + ';$1', + $data, + ); + if ($replaced === null) { + continue; + } + + $data = $replaced; + } + + return $data; + } + + /** + * A stripped extension's own settings have no owner in the restarted + * process; carrying them over would only make php complain. + * + * @param list $strippedExtensions + */ + private static function belongsToExtension(string $settingName, array $strippedExtensions): bool + { + foreach ($strippedExtensions as $extension) { + if ($settingName === $extension || substr($settingName, 0, strlen($extension) + 1) === $extension . '.') { + return true; + } + } + + return false; + } + +} diff --git a/src/Process/Restart/OpcacheSettings.php b/src/Process/Restart/OpcacheSettings.php new file mode 100644 index 00000000000..2ce9fe60fee --- /dev/null +++ b/src/Process/Restart/OpcacheSettings.php @@ -0,0 +1,255 @@ + $opcacheArgs `name=value` entries, see resolveIniEntries() + * @param array $currentIniValues ini_get() of each of those names + */ + public static function isRestartNeeded(array $opcacheArgs, array $currentIniValues): bool + { + foreach ($opcacheArgs as $opcacheArg) { + [$name, $value] = explode('=', $opcacheArg, 2); + $current = $currentIniValues[$name] ?? false; + if ($current === false) { + // unknown directive on this PHP (opcache.jit before 8.0) — nothing a restart could change + continue; + } + if (self::normalizeIniValue($current) !== self::normalizeIniValue($value)) { + return true; + } + } + + return false; + } + + /** + * @param list $opcacheArgs + * @return array + */ + public static function getCurrentIniValues(array $opcacheArgs): array + { + $values = []; + foreach ($opcacheArgs as $opcacheArg) { + $name = explode('=', $opcacheArg, 2)[0]; + $values[$name] = ini_get($name); + } + + return $values; + } + + /** + * The ini parser stores booleans as "1" / "" for php.ini and -d values + * alike; ini_set()-style spellings are folded the same way. + */ + private static function normalizeIniValue(string $value): string + { + $value = strtolower(trim($value)); + if (in_array($value, ['', '0', 'off', 'false', 'no', 'none'], true)) { + return '0'; + } + if (in_array($value, ['1', 'on', 'true', 'yes'], true)) { + return '1'; + } + + return $value; + } + + /** + * Entries activating OPcache for the restarted process, and for the + * workers ProcessHelper spawns — see resolveIniEntries() for the + * reasoning behind each of them. + * + * Nothing is added when OPcache is not loaded at all — real on PHP <= 8.4, + * gone on 8.5+ (always built in and loaded). Loading it from here is not + * worth it: zend_extension=opcache emits a startup warning on builds + * without the shared object, and on 8.5+ always. + * + * @return list + */ + public static function getIniEntries(): array + { + if (!extension_loaded('Zend OPcache')) { + return []; + } + + $ini = []; + foreach (self::OPCACHE_INI_INPUTS as $name) { + $ini[$name] = ini_get($name); + } + + return self::resolveIniEntries($ini); + } + + /** + * OPcache directives for the restarted process, as `name=value` ini + * entries, given the current values of the OPCACHE_INI_INPUTS directives + * (the restarted process loads the same php.ini, so they are what it + * would otherwise run with). + * + * The exec is paid for anyway, so it doubles as the chance to activate + * OPcache, usually dormant on CLI (opcache.enable_cli defaults to off): + * optimized opcodes and the inheritance cache speed up the whole run, and + * the pcntl_fork()ed workers inherit the parent's warm shared memory + * instead of each compiling lazily-loaded classes for itself. Concurrent + * population of that inherited cache is safe — it is php-fpm's normal + * operating model (see ForkParallelChecker). + * + * JIT is always pinned off, even when the user's ini enables it — it is a + * measured slowdown for PHPStan's workload and its shared code buffer is + * not fork-safe, so honoring it would cost twice (the same treatment the + * restart gives xdebug). Merely flipping + * opcache.enable_cli=1 could also activate it behind the user's back: on + * PHP <= 8.3 opcache.jit defaults to `tracing`, so an ini setting just a + * non-zero opcache.jit_buffer_size (common web-tuning advice) suddenly + * JITs; on PHP >= 8.4 the buffer defaults to 64M, so an ini setting just + * opcache.jit does + * (https://php.watch/versions/8.4/opcache-jit-ini-default-changes). + * Pinning both directives covers both generations of defaults. + * + * Timestamp checks are switched off, or nothing of PHPStan itself would be + * cached: opcache_compile_file() refuses any file whose mtime it reads as + * 0, which is what every member of the distributed phar carried until the + * build started stamping them (phar.yml). It reads the mtime whenever + * opcache.validate_timestamps, opcache.file_update_protection or + * opcache.max_file_size is on, so all three go — for a private cache that + * dies with the process they revalidate nothing anyway, and skipping them + * also drops a stat() per include. The uncached state is what made + * OPcache *slower* than no OPcache for phar runs: code compiled under an + * active OPcache but not persisted never gets its strings interned into + * SHM, so its type names have no class-entry cache slot and every + * class-typed parameter, return and property check falls back to a + * lowercased-copy class-table lookup (measured at +27% CPU on slevomat). + * + * The buffers are raised above the stock 128M/8M/10000 for the same + * reason: once any of them is exhausted, everything compiled afterwards + * lands in that same uncached, uninterned state — the stock 8M of interned + * strings run out during PHPStan's own boot already, which alone costs the + * whole gain. PHPStan's phar needs about 35M of code; project bootstraps + * loaded by extensions (a Doctrine objectManagerLoader booting the whole + * app, say) add hundreds of MB, and the shared memory is only reserved, + * not touched, until used. It is not sized for the largest possible + * project, though — an SHM reservation that cannot be satisfied is fatal + * (exit code 254) in the restarted process, with no parent left to fall + * back to. Sizes the php.ini already grants are never lowered, and the + * interned strings buffer is kept below the memory it is carved out of + * (another fatal startup error otherwise). + * + * That private cache must neither outlive the process nor reach outside + * it, which is what the remaining entries guard against in a php.ini tuned + * for the web server rather than for us: + * - opcache.file_cache is blanked. A file cache is validated by the PHP + * build id and (only with opcache.validate_timestamps) the mtime — so + * with the checks off it would keep serving the previous PHPStan's + * opcodes after an update, the phar path being the same, and it would + * fill the web server's cache directory with this run's scripts. + * - opcache.save_comments is pinned on: stripping doc comments (a common + * web tuning) breaks annotation readers in the project code the + * extensions bootstrap, which worked with OPcache dormant. + * - opcache.optimization_level is pinned to PHP's default: the + * extension's pass dropping PHPStan's own type checks + * (TurboExtensionEnabler::trustOwnTypesIfSuitable()) runs inside the + * optimizer, which a php.ini can switch off entirely. + * + * Two configurations are left alone entirely — no OPcache entries at all, + * so the restarted process runs with what the php.ini says, as before: + * - opcache.file_cache_only: blanking the file cache would be a fatal + * startup error, and it usually means shared memory is unavailable on + * that host on purpose. + * - opcache.preload: the application's preload script would run inside + * PHPStan (there is no CLI exemption), and it cannot be blanked (the + * directive rejects an empty value). + * + * @param array $ini + * @return list + */ + public static function resolveIniEntries(array $ini): array + { + if (self::isIniOn($ini['opcache.file_cache_only'] ?? false)) { + return []; + } + $preload = $ini['opcache.preload'] ?? false; + if ($preload !== false && $preload !== '') { + return []; + } + + $memory = max(self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT, self::iniInt($ini['opcache.memory_consumption'] ?? false)); + $internedStrings = max(self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT, self::iniInt($ini['opcache.interned_strings_buffer'] ?? false)); + if ($internedStrings >= $memory) { + $memory = $internedStrings + self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT - self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT; + } + $files = max(self::OPCACHE_MAX_ACCELERATED_FILES_LIMIT, self::iniInt($ini['opcache.max_accelerated_files'] ?? false)); + + return [ + 'opcache.enable=1', + 'opcache.enable_cli=1', + 'opcache.jit=disable', + 'opcache.jit_buffer_size=0', + 'opcache.validate_timestamps=0', + 'opcache.file_update_protection=0', + 'opcache.max_file_size=0', + 'opcache.file_cache=', + 'opcache.save_comments=1', + 'opcache.optimization_level=' . self::OPCACHE_OPTIMIZATION_LEVEL, + 'opcache.memory_consumption=' . $memory, + 'opcache.interned_strings_buffer=' . $internedStrings, + 'opcache.max_accelerated_files=' . $files, + ]; + } + + private static function isIniOn(string|false $value): bool + { + return $value !== false && $value !== '' && $value !== '0'; + } + + private static function iniInt(string|false $value): int + { + return $value === false ? 0 : (int) $value; + } + +} diff --git a/src/Process/Restart/RestartDiagnoseExtension.php b/src/Process/Restart/RestartDiagnoseExtension.php new file mode 100644 index 00000000000..57430461a7e --- /dev/null +++ b/src/Process/Restart/RestartDiagnoseExtension.php @@ -0,0 +1,67 @@ +writeLineFormatted('Process restart:'); + + $detected = ExtensionRegistry::describeDetectedExtensions(); + $output->writeLineFormatted(sprintf( + 'Coverage/profilers found: %s', + $detected === [] ? 'none' : implode(', ', $detected), + )); + + $performed = RestartManager::getPerformedRestartReasons(); + if ($performed !== []) { + $output->writeLineFormatted(sprintf( + 'Restarted for: %s', + implode(', ', $performed), + )); + } + + $output->writeLineFormatted(sprintf( + 'Restart if started now: %s', + $this->describeRestartIfStartedNow(), + )); + $output->writeLineFormatted('Never restarts: under Blackfire, and the worker, diagnose and cache commands'); + $output->writeLineFormatted(''); + } + + private function describeRestartIfStartedNow(): string + { + // the diagnose command does not restart, so it reports the answer an + // analyse run started the same way would get. --xdebug is a per-run + // option, so it is reported as not given + $reasons = ExtensionRegistry::collectReasons(false); + if ($reasons === []) { + return 'no, nothing to neutralize or load'; + } + + $descriptions = array_map( + static fn (RestartReason $reason): string => sprintf('%s (%s)', $reason->getExtension(), $reason->getDescription()), + $reasons, + ); + + if (!RestartExecutor::isSupported()) { + return sprintf( + 'not possible - neither pcntl_exec() nor proc_open() is usable, so %s stays as it is', + implode(', ', $descriptions), + ); + } + + return sprintf('yes - %s', implode(', ', $descriptions)); + } + +} diff --git a/src/Process/Restart/RestartExecutor.php b/src/Process/Restart/RestartExecutor.php new file mode 100644 index 00000000000..4ef711edd99 --- /dev/null +++ b/src/Process/Restart/RestartExecutor.php @@ -0,0 +1,120 @@ + $arguments php arguments, the script and its own arguments included + * @param string|null $temporaryIni composed ini to delete once the restarted command is done + */ + public static function execute(array $arguments, ?string $temporaryIni): void + { + if (function_exists('pcntl_exec')) { + @pcntl_exec(PHP_BINARY, $arguments); + // returns only on failure — fall through to the passthrough loop + } + + if (!function_exists('proc_open')) { + return; + } + + if (self::isExitDisabledByUopz() && function_exists('uopz_allow_exit')) { + @uopz_allow_exit(true); + } + + // the child inherits the terminal, so let it be the one that reacts to + // Ctrl+C; this process only waits for it + self::ignoreInterrupts(); + + $process = @proc_open(array_merge([PHP_BINARY], $arguments), [], $pipes); + if (!is_resource($process)) { + return; + } + + $exitCode = proc_close($process); + + if ($temporaryIni !== null) { + @unlink($temporaryIni); + } + + exit($exitCode === -1 ? self::UNKNOWN_EXIT_CODE : $exitCode); + } + + private static function isExitDisabledByUopz(): bool + { + return extension_loaded('uopz') && ini_get('uopz.disable') !== '1'; + } + + private static function ignoreInterrupts(): void + { + if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, SIG_IGN); + } + + if (!function_exists('sapi_windows_set_ctrl_handler')) { + return; + } + + // CTRL events are only reliably available in the child when the parent + // ignores them + sapi_windows_set_ctrl_handler(static function (int $event): void { + }); + } + +} diff --git a/src/Process/Restart/RestartManager.php b/src/Process/Restart/RestartManager.php new file mode 100644 index 00000000000..3e298dbe1a2 --- /dev/null +++ b/src/Process/Restart/RestartManager.php @@ -0,0 +1,520 @@ + $argv + * @param string|null $pharPath the phar to look for the turbo binary next to, see TurboProcessRestarter + * @param list|null $extensions restart only for these reasons, null for all of them + * @param string $appName prefix of the _ALLOW_XDEBUG environment variable, see TurboProcessRestarter + */ + public static function run(array $argv, ?string $pharPath = null, ?array $extensions = null, string $appName = 'phpstan'): void + { + self::cleanUpComposedIni(); + + $reasons = []; + foreach (self::collectReasons($argv, $pharPath, $appName) as $reason) { + if ($extensions !== null && !in_array($reason->getExtension(), $extensions, true)) { + continue; + } + + $reasons[] = $reason; + } + if ($reasons === []) { + return; + } + + if (!RestartExecutor::isSupported()) { + // continue armed, silently; diagnose explains + return; + } + + self::restart($argv, $reasons); + } + + /** + * The reasons this process should restart for. Empty when it must not + * restart at all — see isEligible(). + * + * @param list $argv + * @return list + */ + public static function collectReasons(array $argv, ?string $pharPath = null, string $appName = 'phpstan'): array + { + if (!self::isEligible($argv)) { + return []; + } + + return ExtensionRegistry::collectReasons(self::isXdebugAllowed($argv, $appName), $pharPath); + } + + /** + * Whether the command in $argv may restart at all, regardless of what is + * loaded. + * + * @param list $argv + */ + public static function isEligible(array $argv): bool + { + if (PHP_SAPI !== 'cli') { + return false; + } + + if (self::wasRestarted()) { + return false; + } + + if (isset($_SERVER['BLACKFIRE_AGENT_SOCKET'])) { + // the probe cannot follow pcntl_exec() - the profile is truncated or + // PHP segfaults - and a passthrough restart would only profile the + // parent waiting for its child; see + // https://support.blackfire.platform.sh/hc/en-us/articles/4843014509202-Conflicts-with-pcntl-exec-calls + return false; + } + + $commandName = self::findCommandName($argv); + + return $commandName === null || !in_array($commandName, self::NON_RESTARTING_COMMANDS, true); + } + + public static function wasRestarted(): bool + { + return getenv(self::REASONS_ENV_VARIABLE) !== false || get_cfg_var(self::RESTARTED_INI) !== false; + } + + /** + * The extensions the running process was restarted for, empty when it was + * not restarted. + * + * @return list + */ + public static function getPerformedRestartReasons(): array + { + $reasons = (string) getenv(self::REASONS_ENV_VARIABLE); + if ($reasons === '') { + return []; + } + + return explode(',', $reasons); + } + + public static function wasRestartedFor(string $extension): bool + { + return in_array($extension, self::getPerformedRestartReasons(), true); + } + + /** + * @param list $argv + * @param non-empty-list $reasons + */ + private static function restart(array $argv, array $reasons): void + { + $strippedExtensions = []; + $iniSettings = []; + $environmentVariables = []; + $phpArguments = []; + $reasonNames = []; + + foreach ($reasons as $reason) { + $reasonNames[] = $reason->getExtension(); + foreach ($reason->getStrippedExtensions() as $extension) { + $strippedExtensions[] = $extension; + } + foreach ($reason->getIniSettings() as $name => $value) { + $iniSettings[$name] = $value; + } + foreach ($reason->getEnvironmentVariables() as $name => $value) { + $environmentVariables[$name] = $value; + } + foreach ($reason->getCommandLineArguments() as $argument) { + $phpArguments[] = $argument; + } + } + + $temporaryIni = null; + $rollbacks = [self::exportEnvironment($environmentVariables)]; + $restoreEnvironment = static function () use (&$rollbacks): void { + foreach (array_reverse($rollbacks) as $rollback) { + $rollback(); + } + }; + + if ($strippedExtensions !== [] || $iniSettings !== []) { + $temporaryIni = IniComposer::compose($strippedExtensions, $iniSettings); + if ($temporaryIni === null) { + $restoreEnvironment(); + return; + } + + $restore = self::propagateComposedIni($temporaryIni); + if ($restore === null) { + $restoreEnvironment(); + @unlink($temporaryIni); + return; + } + + $rollbacks[] = $restore; + } else { + // no ini had to be composed, but $argv contains neither the -c nor + // the -d flags this process was started with — carry over the ones + // that matter by hand + $phpArguments = array_merge(self::getPassedThroughArguments(), $phpArguments); + } + + if (!putenv(self::REASONS_ENV_VARIABLE . '=' . implode(',', $reasonNames))) { + $restoreEnvironment(); + self::deleteComposedIni($temporaryIni); + return; + } + + if (self::isRestartReported($argv)) { + self::writeRestartLine($reasons); + } + + RestartExecutor::execute(array_merge($phpArguments, $argv), $temporaryIni); + + // the restart could not be performed after all — run on as this process + // is, without leaving the composed ini behind for this run's workers + putenv(self::REASONS_ENV_VARIABLE); + $restoreEnvironment(); + self::deleteComposedIni($temporaryIni); + } + + /** + * Exports the arming switches an ini file cannot express — xdebug reads + * XDEBUG_MODE in preference to xdebug.mode, so flagging the ini alone would + * be silently overridable by the environment PHPStan was started in. + * + * @param array $environmentVariables + * @return callable(): void the rollback + */ + private static function exportEnvironment(array $environmentVariables): callable + { + $original = []; + foreach ($environmentVariables as $name => $value) { + $original[$name] = getenv($name); + putenv($name . '=' . $value); + } + + return static function () use ($original): void { + foreach ($original as $name => $value) { + putenv($value === false ? $name : $name . '=' . $value); + } + }; + } + + /** + * Exports the composed ini so every descendant PHP process picks it up: + * spawned workers read it from PHPRC, forked ones inherit it in memory. + * + * @return (callable(): void)|null the environment rollback, null when the environment could not be written + */ + private static function propagateComposedIni(string $temporaryIni): ?callable + { + $phprc = getenv('PHPRC'); + $scanDir = getenv('PHP_INI_SCAN_DIR'); + + $restore = static function () use ($phprc, $scanDir): void { + putenv($phprc === false ? 'PHPRC' : 'PHPRC=' . $phprc); + putenv($scanDir === false ? 'PHP_INI_SCAN_DIR' : 'PHP_INI_SCAN_DIR=' . $scanDir); + putenv(self::TEMPORARY_INI_ENV_VARIABLE); + }; + + if ( + !putenv('PHPRC=' . $temporaryIni) + || !putenv('PHP_INI_SCAN_DIR=') + || !putenv(self::TEMPORARY_INI_ENV_VARIABLE . '=' . $temporaryIni) + ) { + $restore(); + return null; + } + + return $restore; + } + + /** + * The restarted process owns the composed ini: it outlives every worker + * reading it, and pcntl_exec() left the process that composed it no chance + * to delete anything. + * + * A shutdown function deletes it when the run ends - Ctrl+C and SIGTERM + * included, which end the run through the event loop. A watchdog covers + * the rest: SIGKILL, and the very crashes some of the restart reasons are + * about (https://github.com/phpstan/phpstan/issues/15407). + */ + private static function cleanUpComposedIni(): void + { + $temporaryIni = (string) getenv(self::TEMPORARY_INI_ENV_VARIABLE); + if ($temporaryIni === '') { + return; + } + + // descendants must not delete the file their siblings are still reading + putenv(self::TEMPORARY_INI_ENV_VARIABLE); + + register_shutdown_function(static function () use ($temporaryIni): void { + @unlink($temporaryIni); + }); + + self::startComposedIniWatchdog($temporaryIni); + } + + /** + * Forks a process that waits for this one to end, whichever way, and then + * deletes the composed ini - the role the waiting parent plays for a + * proc_open() restart, which pcntl_exec() does not have. It runs before + * the autoloader, so it holds next to nothing of the parent's memory. + * + * It ignores the terminal's signals (a Ctrl+C reaches the whole process + * group) and closes the standard streams, so that a caller reading + * PHPStan's output through a pipe sees it end with PHPStan. It ends by + * SIGKILLing itself: PHP's teardown would run the module shutdown of every + * extension it inherited, which a fork-unsafe one wedges (see + * ForkedChildTerminator). + */ + private static function startComposedIniWatchdog(string $temporaryIni): void + { + if ( + !function_exists('pcntl_fork') + || !function_exists('pcntl_signal') + || !function_exists('posix_getppid') + || !function_exists('posix_kill') + ) { + return; + } + + $parentPid = getmypid(); + $pid = @pcntl_fork(); + if ($pid !== 0) { + // the parent, or a failed fork - the shutdown function still covers the common ways out + return; + } + + foreach (['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as $signal) { + if (!defined($signal)) { + continue; + } + + pcntl_signal(constant($signal), SIG_IGN); + } + fclose(STDIN); + fclose(STDOUT); + fclose(STDERR); + + while (posix_getppid() === $parentPid) { + usleep(self::WATCHDOG_POLL_INTERVAL_MICROSECONDS); + } + + @unlink($temporaryIni); + posix_kill((int) getmypid(), SIGKILL); + } + + private static function deleteComposedIni(?string $temporaryIni): void + { + if ($temporaryIni === null) { + return; + } + + @unlink($temporaryIni); + } + + /** + * @return list + */ + private static function getPassedThroughArguments(): array + { + $arguments = []; + + $loadedIni = php_ini_loaded_file(); + if ($loadedIni !== false) { + $arguments[] = '-c'; + $arguments[] = $loadedIni; + } + + $arguments[] = '-d'; + $arguments[] = 'memory_limit=' . ini_get('memory_limit'); + + return $arguments; + } + + /** + * The first argument that is not an option — what Symfony resolves the + * command name from. The value of a space-separated option can be mistaken + * for it, which can only ever produce a name that is not in + * NON_RESTARTING_COMMANDS, so the restart happens as it would anyway. + * + * @param list $argv + */ + private static function findCommandName(array $argv): ?string + { + foreach (array_slice($argv, 1) as $argument) { + if ($argument === '' || strpos($argument, '-') === 0) { + continue; + } + + return $argument; + } + + return null; + } + + /** + * Whether the run asked for xdebug to be left alone, through --xdebug or + * through the deprecated environment variable. + * + * @param list $argv + */ + public static function isXdebugAllowed(array $argv, string $appName = 'phpstan'): bool + { + if (in_array('--xdebug', $argv, true)) { + return true; + } + + $allowed = (string) getenv(strtoupper($appName) . self::ALLOW_XDEBUG_ENV_VARIABLE_SUFFIX); + + return $allowed !== '' && $allowed !== '0'; + } + + /** + * @param list $argv + */ + private static function isRestartReported(array $argv): bool + { + $xdebugHandlerDebug = (string) getenv(self::XDEBUG_HANDLER_DEBUG_ENV_VARIABLE); + + return in_array('-vvv', $argv, true) + || in_array('--verbose=3', $argv, true) + || ($xdebugHandlerDebug !== '' && $xdebugHandlerDebug !== '0'); + } + + /** + * @param non-empty-list $reasons + */ + private static function writeRestartLine(array $reasons): void + { + $descriptions = []; + foreach ($reasons as $reason) { + $descriptions[] = sprintf('%s (%s)', $reason->getExtension(), $reason->getDescription()); + } + + fwrite(STDERR, sprintf('Restarting process: %s' . PHP_EOL, implode(', ', $descriptions))); + } + +} diff --git a/src/Process/Restart/RestartReason.php b/src/Process/Restart/RestartReason.php new file mode 100644 index 00000000000..90b1fe2bb4f --- /dev/null +++ b/src/Process/Restart/RestartReason.php @@ -0,0 +1,87 @@ + $iniSettings ini settings forced in the restarted process + * @param array $environmentVariables environment variables exported before the restart + * @param list $strippedExtensions extension names whose extension=/zend_extension= lines get commented out + * @param list $commandLineArguments php arguments prepended to the restarted command + */ + public function __construct( + private string $extension, + private string $description, + private array $iniSettings = [], + private array $environmentVariables = [], + private array $strippedExtensions = [], + private array $commandLineArguments = [], + ) + { + } + + public function getExtension(): string + { + return $this->extension; + } + + public function getDescription(): string + { + return $this->description; + } + + /** @return array */ + public function getIniSettings(): array + { + return $this->iniSettings; + } + + /** @return array */ + public function getEnvironmentVariables(): array + { + return $this->environmentVariables; + } + + /** @return list */ + public function getStrippedExtensions(): array + { + return $this->strippedExtensions; + } + + /** @return list */ + public function getCommandLineArguments(): array + { + return $this->commandLineArguments; + } + +} diff --git a/src/Turbo/TurboDiagnoseExtension.php b/src/Turbo/TurboDiagnoseExtension.php index 4b5cfa1e431..14e8a547d79 100644 --- a/src/Turbo/TurboDiagnoseExtension.php +++ b/src/Turbo/TurboDiagnoseExtension.php @@ -6,6 +6,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Diagnose\DiagnoseExtension; use PHPStan\Php\PhpVersion; +use PHPStan\Process\Restart\ExtensionRegistry; use function php_uname; use function phpversion; use function sprintf; @@ -44,7 +45,7 @@ public function print(Output $output): void )); if (TurboExtensionEnabler::isLoaded()) { - $restartPath = TurboProcessRestarter::getRestartExtensionPath(); + $restartPath = TurboExtensionSelector::getRestartExtensionPath(); if ($restartPath !== null) { $workerBinaryLine = sprintf('%s (loaded via process restart)', $restartPath); } else { @@ -87,6 +88,10 @@ private function describeStatus(?string $workerBinary): string { if (!TurboExtensionEnabler::isLoaded()) { if ($workerBinary !== null) { + if (ExtensionRegistry::findTurboExtensionToLoad() !== null) { + return 'not loaded here, loaded by the restart an analyse run performs'; + } + return 'enabled in worker processes (the main process runs without it)'; } diff --git a/src/Turbo/TurboExtensionSelector.php b/src/Turbo/TurboExtensionSelector.php index e9205de89dc..321c3f4d9aa 100644 --- a/src/Turbo/TurboExtensionSelector.php +++ b/src/Turbo/TurboExtensionSelector.php @@ -5,8 +5,10 @@ use Phar; use function dirname; use function file_get_contents; +use function get_cfg_var; use function is_dir; use function is_file; +use function is_string; use function php_uname; use function sprintf; use function strpos; @@ -20,8 +22,9 @@ /** * Locates the distributed turbo extension binary matching the current runtime * so it can be loaded via `-d extension=` — into spawned worker processes - * (ProcessHelper), or into the restarted main process (TurboProcessRestarter) - * whose pcntl_fork()ed workers then inherit it. + * (ProcessHelper), or into the restarted main process (the turbo row of + * PHPStan\Process\Restart\ExtensionRegistry) whose pcntl_fork()ed workers then + * inherit it. * * The binaries are committed to the phpstan/phpstan repository next to * phpstan.phar (turbo-ext//phpstan_turbo-.so, .dll on @@ -55,15 +58,32 @@ final class TurboExtensionSelector */ public const MINIMUM_PHP_VERSION_ID = 80300; + public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath'; + + /** + * The extension path this process was given through -d — by the restart, + * or by ProcessHelper when spawned as a worker. Null when the extension + * came from the php.ini or is not loaded at all. + */ + public static function getRestartExtensionPath(): ?string + { + $path = get_cfg_var(self::EXTENSION_PATH_INI); + if (!is_string($path) || $path === '') { + return null; + } + + return $path; + } + public static function findExtensionForWorkers(?string $pharPath = null): ?string { if (TurboExtensionEnabler::isLoaded()) { - $restartPath = TurboProcessRestarter::getRestartExtensionPath(); + $restartPath = self::getRestartExtensionPath(); if ($restartPath !== null) { // loaded through a -d flag — the restart's own, or the one - // ProcessHelper gave this process when spawning it as a worker - // (see TurboProcessRestarter): spawned workers do not inherit - // command-line -d flags, so they need it passed explicitly + // ProcessHelper gave this process when spawning it as a worker: + // spawned workers do not inherit command-line -d flags, so they + // need it passed explicitly return $restartPath; } diff --git a/src/Turbo/TurboProcessRestarter.php b/src/Turbo/TurboProcessRestarter.php index 8dab17bd1c8..a671244afa4 100644 --- a/src/Turbo/TurboProcessRestarter.php +++ b/src/Turbo/TurboProcessRestarter.php @@ -2,380 +2,39 @@ namespace PHPStan\Turbo; -use function explode; -use function extension_loaded; -use function function_exists; -use function get_cfg_var; -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 trim; -use const PHP_BINARY; +use PHPStan\Process\Restart\RestartManager; /** - * Restarts the main PHPStan process via pcntl_exec() when the process it - * was started as is not the one PHPStan wants to run — for either of two - * reasons, checked independently. - * - * The distributed turbo extension is available but not loaded. Parallel - * analysis prefers pcntl_fork()ed workers over spawned ones (see - * ForkParallelChecker) — a forked worker inherits the booted process instead - * of paying a full re-boot. But a forked worker also inherits the parent's - * loaded extensions: the spawn-time `-d extension=` injection - * (ProcessHelper) cannot reach it, and dl() cannot load a binary from next - * to the phar. So the whole process is re-executed with the extension - * before anything else runs — forked workers then inherit the extension, - * its shadowing classes, and (when running from a phar) the phar-fork-guard - * that keeps phar:// reads safe across fork. - * - * The OPcache configuration in effect is not the one PHPStan wants (see - * resolveOpcacheArgs()) — usually because OPcache is dormant on CLI, or has - * JIT on. The restarted process runs with it activated, JIT pinned off, and - * forked workers share the parent's warm opcode cache. This reason stands - * on its own so that a php.ini which already loads turbo (a source checkout - * with a locally built extension, say) does not leave OPcache dormant just - * because there is no extension left to restart for. - * - * The restart carries two marker ini entries: RESTARTED_INI is the retry - * stop (a binary that failed to load, or an OPcache that could not start, - * would otherwise restart forever), and EXTENSION_PATH_INI tells - * TurboExtensionSelector that spawned workers still need the -d flag - * (command-line -d flags, unlike php.ini, are not inherited). ProcessHelper - * sets both on the workers it spawns, along with the OPcache entries: a - * worker's configuration is decided by the process spawning it, so it never - * restarts itself — before that, every spawned worker on a pcntl host - * re-executed itself once to activate OPcache, rebuilding its command line - * without the sys_temp_dir and extension entries of the spawn. + * Kept for programs that load PHPStan's phar and restart into PHPStan Turbo + * themselves, Rector for example. PHPStan's own bin/phpstan calls + * RestartManager directly. */ final class TurboProcessRestarter { - public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath'; - - /** Set by the restart, and by ProcessHelper on spawned workers, so the process never restarts itself */ - public const RESTARTED_INI = 'phpstan.restarted'; - - /** Shared memory reserved (not touched) for the opcode cache, in MB — see resolveOpcacheArgs() for the sizing */ - private const OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT = 256; - - /** Carved out of the memory above for interned strings, in MB */ - private const OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT = 64; - - private const OPCACHE_MAX_ACCELERATED_FILES_LIMIT = 20000; - - /** PHP's default opcache.optimization_level, pinned so the optimizer (and the extension's pass in it) always runs */ - private const OPCACHE_OPTIMIZATION_LEVEL = '0x7FFEBFFF'; - - /** The php.ini directives resolveOpcacheArgs() reacts to */ - private const OPCACHE_INI_INPUTS = [ - 'opcache.file_cache_only', - 'opcache.preload', - 'opcache.memory_consumption', - 'opcache.interned_strings_buffer', - 'opcache.max_accelerated_files', - ]; - - /** - * The extension path this process was given through -d — by the restart, - * or by ProcessHelper when spawned as a worker. Null when the extension - * came from the php.ini or is not loaded at all. - */ - public static function getRestartExtensionPath(): ?string - { - $path = get_cfg_var(self::EXTENSION_PATH_INI); - if (!is_string($path) || $path === '') { - return null; - } - - return $path; - } - /** * On success the call never returns — the process image is replaced. * * Another program that loads PHPStan's phar, Rector for example, passes * the path to that phar: Phar::running() only knows the phar that runs. * + * Without $appName only what the turbo extension needs is restarted for, + * as before. With it, the same single restart also neutralizes xdebug, + * pcov and the other hooks PHPStan's own restart does, unless --xdebug is + * in $argv or _ALLOW_XDEBUG is set - the variable composer/xdebug-handler + * reads for that application, so a caller can drop its own xdebug restart. + * * @api * @param list $argv */ - public static function restartIfSuitable(array $argv, ?string $pharPath = null): void + public static function restartIfSuitable(array $argv, ?string $pharPath = null, ?string $appName = null): void { - if (get_cfg_var(self::RESTARTED_INI) !== false) { - // already restarted — whatever did not take effect (a binary that - // failed to load, an OPcache that could not start) will not on a - // second try either - return; - } - if ( - isset($_SERVER['BLACKFIRE_AGENT_SOCKET']) - ) { - // pcntl_exec() is not supported by blackfire - // see https://support.blackfire.platform.sh/hc/en-us/articles/4843014509202-Conflicts-with-pcntl-exec-calls - return; - } - if ( - !function_exists('pcntl_exec') - || !function_exists('pcntl_fork') - || !function_exists('pcntl_waitpid') - || !function_exists('pcntl_wifexited') - || !function_exists('pcntl_wexitstatus') - || !function_exists('posix_kill') - ) { - // fork mode is impossible here (see ForkParallelChecker) and - // spawned workers get the extension from ProcessHelper already - return; - } - - $extensionPath = extension_loaded('phpstan_turbo') ? null : TurboExtensionSelector::findExtension($pharPath); - $opcacheArgs = self::getOpcacheArgs(); - if ( - $extensionPath === null - && !self::resolveOpcacheRestartNeeded($opcacheArgs, self::getCurrentIniValues($opcacheArgs)) - ) { + if ($appName === null) { + RestartManager::run($argv, $pharPath, ['phpstan_turbo', 'tideways', 'opcache']); return; } - $args = []; - $phpIni = php_ini_loaded_file(); - if ($phpIni !== false) { - $args[] = '-c'; - $args[] = $phpIni; - } - $args[] = '-d'; - $args[] = 'memory_limit=' . ini_get('memory_limit'); - foreach ($opcacheArgs as $opcacheArg) { - $args[] = '-d'; - $args[] = $opcacheArg; - } - 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; - } - - pcntl_exec(PHP_BINARY, $args); - // pcntl_exec() returns only on failure — continue as we are - } - - /** - * Whether the OPcache configuration the restart would set differs from - * the one in effect — the second reason to restart, independent of turbo: - * with the extension already loaded through php.ini there would be - * nothing else to restart for, and OPcache would stay dormant. - * - * @param list $opcacheArgs `name=value` entries, see resolveOpcacheArgs() - * @param array $currentIniValues ini_get() of each of those names - */ - public static function resolveOpcacheRestartNeeded(array $opcacheArgs, array $currentIniValues): bool - { - foreach ($opcacheArgs as $opcacheArg) { - [$name, $value] = explode('=', $opcacheArg, 2); - $current = $currentIniValues[$name] ?? false; - if ($current === false) { - // unknown directive on this PHP (opcache.jit before 8.0) — nothing a restart could change - continue; - } - if (self::normalizeIniValue($current) !== self::normalizeIniValue($value)) { - return true; - } - } - - return false; - } - - /** - * @param list $opcacheArgs - * @return array - */ - private static function getCurrentIniValues(array $opcacheArgs): array - { - $values = []; - foreach ($opcacheArgs as $opcacheArg) { - $name = explode('=', $opcacheArg, 2)[0]; - $values[$name] = ini_get($name); - } - - return $values; - } - - /** - * The ini parser stores booleans as "1" / "" for php.ini and -d values - * alike; ini_set()-style spellings are folded the same way. - */ - private static function normalizeIniValue(string $value): string - { - $value = strtolower(trim($value)); - if (in_array($value, ['', '0', 'off', 'false', 'no', 'none'], true)) { - return '0'; - } - if (in_array($value, ['1', 'on', 'true', 'yes'], true)) { - return '1'; - } - - return $value; - } - - /** - * `-d` entries activating OPcache for the restarted process, and for the - * workers ProcessHelper spawns — see resolveOpcacheArgs() for the - * reasoning behind each of them. - * - * Nothing is added when OPcache is not loaded at all — real on PHP <= 8.4, - * gone on 8.5+ (always built in and loaded). Loading it from here is not - * worth it: -d zend_extension=opcache emits a startup warning on builds - * without the shared object, and on 8.5+ always. - * - * @return list - */ - public static function getOpcacheArgs(): array - { - if (!extension_loaded('Zend OPcache')) { - return []; - } - - $ini = []; - foreach (self::OPCACHE_INI_INPUTS as $name) { - $ini[$name] = ini_get($name); - } - - return self::resolveOpcacheArgs($ini); - } - - /** - * OPcache directives for the restarted process, as `name=value` ini - * entries, given the current values of the OPCACHE_INI_INPUTS directives - * (the restarted process loads the same php.ini, so they are what it - * would otherwise run with). - * - * The exec is paid for anyway, so it doubles as the chance to activate - * OPcache, usually dormant on CLI (opcache.enable_cli defaults to off): - * optimized opcodes and the inheritance cache speed up the whole run, and - * the pcntl_fork()ed workers inherit the parent's warm shared memory - * instead of each compiling lazily-loaded classes for itself. Concurrent - * population of that inherited cache is safe — it is php-fpm's normal - * operating model (see ForkParallelChecker). - * - * JIT is always pinned off, even when the user's ini enables it — it is a - * measured slowdown for PHPStan's workload and its shared code buffer is - * not fork-safe, so honoring it would cost twice (the same treatment the - * xdebug-handler restart gives xdebug). Merely flipping - * opcache.enable_cli=1 could also activate it behind the user's back: on - * PHP <= 8.3 opcache.jit defaults to `tracing`, so an ini setting just a - * non-zero opcache.jit_buffer_size (common web-tuning advice) suddenly - * JITs; on PHP >= 8.4 the buffer defaults to 64M, so an ini setting just - * opcache.jit does - * (https://php.watch/versions/8.4/opcache-jit-ini-default-changes). - * Pinning both directives covers both generations of defaults. - * - * Timestamp checks are switched off, or nothing of PHPStan itself would be - * cached: opcache_compile_file() refuses any file whose mtime it reads as - * 0, which is what every member of the distributed phar carried until the - * build started stamping them (phar.yml). It reads the mtime whenever - * opcache.validate_timestamps, opcache.file_update_protection or - * opcache.max_file_size is on, so all three go — for a private cache that - * dies with the process they revalidate nothing anyway, and skipping them - * also drops a stat() per include. The uncached state is what made - * OPcache *slower* than no OPcache for phar runs: code compiled under an - * active OPcache but not persisted never gets its strings interned into - * SHM, so its type names have no class-entry cache slot and every - * class-typed parameter, return and property check falls back to a - * lowercased-copy class-table lookup (measured at +27% CPU on slevomat). - * - * The buffers are raised above the stock 128M/8M/10000 for the same - * reason: once any of them is exhausted, everything compiled afterwards - * lands in that same uncached, uninterned state — the stock 8M of interned - * strings run out during PHPStan's own boot already, which alone costs the - * whole gain. PHPStan's phar needs about 35M of code; project bootstraps - * loaded by extensions (a Doctrine objectManagerLoader booting the whole - * app, say) add hundreds of MB, and the shared memory is only reserved, - * not touched, until used. It is not sized for the largest possible - * project, though — an SHM reservation that cannot be satisfied is fatal - * (exit code 254) in the restarted process, with no parent left to fall - * back to. Sizes the php.ini already grants are never lowered, and the - * interned strings buffer is kept below the memory it is carved out of - * (another fatal startup error otherwise). - * - * That private cache must neither outlive the process nor reach outside - * it, which is what the remaining entries guard against in a php.ini tuned - * for the web server rather than for us: - * - opcache.file_cache is blanked. A file cache is validated by the PHP - * build id and (only with opcache.validate_timestamps) the mtime — so - * with the checks off it would keep serving the previous PHPStan's - * opcodes after an update, the phar path being the same, and it would - * fill the web server's cache directory with this run's scripts. - * - opcache.save_comments is pinned on: stripping doc comments (a common - * web tuning) breaks annotation readers in the project code the - * extensions bootstrap, which worked with OPcache dormant. - * - opcache.optimization_level is pinned to PHP's default: the - * extension's pass dropping PHPStan's own type checks - * (TurboExtensionEnabler::trustOwnTypesIfSuitable()) runs inside the - * optimizer, which a php.ini can switch off entirely. - * - * Two configurations are left alone entirely — no OPcache entries at all, - * so the restarted process runs with what the php.ini says, as before: - * - opcache.file_cache_only: blanking the file cache would be a fatal - * startup error, and it usually means shared memory is unavailable on - * that host on purpose. - * - opcache.preload: the application's preload script would run inside - * PHPStan (there is no CLI exemption), and it cannot be blanked with -d - * (the directive rejects an empty value). - * - * @param array $ini - * @return list - */ - public static function resolveOpcacheArgs(array $ini): array - { - if (self::isIniOn($ini['opcache.file_cache_only'] ?? false)) { - return []; - } - $preload = $ini['opcache.preload'] ?? false; - if ($preload !== false && $preload !== '') { - return []; - } - - $memory = max(self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT, self::iniInt($ini['opcache.memory_consumption'] ?? false)); - $internedStrings = max(self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT, self::iniInt($ini['opcache.interned_strings_buffer'] ?? false)); - if ($internedStrings >= $memory) { - $memory = $internedStrings + self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT - self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT; - } - $files = max(self::OPCACHE_MAX_ACCELERATED_FILES_LIMIT, self::iniInt($ini['opcache.max_accelerated_files'] ?? false)); - - return [ - 'opcache.enable=1', - 'opcache.enable_cli=1', - 'opcache.jit=disable', - 'opcache.jit_buffer_size=0', - 'opcache.validate_timestamps=0', - 'opcache.file_update_protection=0', - 'opcache.max_file_size=0', - 'opcache.file_cache=', - 'opcache.save_comments=1', - 'opcache.optimization_level=' . self::OPCACHE_OPTIMIZATION_LEVEL, - 'opcache.memory_consumption=' . $memory, - 'opcache.interned_strings_buffer=' . $internedStrings, - 'opcache.max_accelerated_files=' . $files, - ]; - } - - private static function isIniOn(string|false $value): bool - { - return $value !== false && $value !== '' && $value !== '0'; - } - - private static function iniInt(string|false $value): int - { - return $value === false ? 0 : (int) $value; + RestartManager::run($argv, $pharPath, appName: $appName); } } diff --git a/tests/PHPStan/Process/ProcessHelperTest.php b/tests/PHPStan/Process/ProcessHelperTest.php index 20f1fcf07b4..13cd94bd7e4 100644 --- a/tests/PHPStan/Process/ProcessHelperTest.php +++ b/tests/PHPStan/Process/ProcessHelperTest.php @@ -2,7 +2,8 @@ namespace PHPStan\Process; -use PHPStan\Turbo\TurboProcessRestarter; +use PHPStan\Process\Restart\OpcacheSettings; +use PHPStan\Process\Restart\RestartManager; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\ArrayInput; @@ -45,14 +46,14 @@ public function testWorkerCommandCarriesTheIniEntries(): void $command = ProcessHelper::getWorkerCommand('bin/phpstan', 'worker', null, ['--port', '1234'], $this->createInput()); $commandAgain = ProcessHelper::getWorkerCommand('bin/phpstan', 'worker', null, ['--port', '1234'], $this->createInput()); - $opcacheArgs = TurboProcessRestarter::getOpcacheArgs(); + $opcacheArgs = OpcacheSettings::getIniEntries(); foreach ($opcacheArgs as $opcacheArg) { $this->assertStringContainsString(sprintf(' -d %s ', $opcacheArg), $command); } // the restart marker is the last -d, right before the script $this->assertStringContainsString( - sprintf(' -d %s=1 %s %s ', TurboProcessRestarter::RESTARTED_INI, escapeshellarg('bin/phpstan'), escapeshellarg('worker')), + sprintf(' -d %s=1 %s %s ', RestartManager::RESTARTED_INI, escapeshellarg('bin/phpstan'), escapeshellarg('worker')), $command, ); diff --git a/tests/PHPStan/Process/Restart/ExtensionRegistryTest.php b/tests/PHPStan/Process/Restart/ExtensionRegistryTest.php new file mode 100644 index 00000000000..123d8a859b9 --- /dev/null +++ b/tests/PHPStan/Process/Restart/ExtensionRegistryTest.php @@ -0,0 +1,295 @@ +, array, string|null, bool, list}> + */ + public static function dataCollectReasonsFor(): iterable + { + yield 'a clean process has nothing to restart for' => [ + ['Core', 'json'], + [], + null, + false, + [], + ]; + + yield 'armed xdebug is disarmed' => [ + ['Core', 'xdebug'], + ['xdebug.mode' => 'develop,debug'], + null, + false, + ['xdebug'], + ]; + + yield 'xdebug.mode=off measures as absent, so it is left alone' => [ + ['Core', 'xdebug'], + ['xdebug.mode' => 'off'], + null, + false, + [], + ]; + + yield 'an empty xdebug.mode list is off' => [ + ['Core', 'xdebug'], + ['xdebug.mode' => ' , '], + null, + false, + [], + ]; + + yield 'xdebug without a mode setting predates modes and is always armed' => [ + ['Core', 'xdebug'], + [], + null, + false, + ['xdebug'], + ]; + + yield '--xdebug keeps xdebug' => [ + ['Core', 'xdebug'], + ['xdebug.mode' => 'debug'], + null, + true, + [], + ]; + + yield 'armed pcov is disarmed' => [ + ['Core', 'pcov'], + ['pcov.enabled' => '1'], + null, + false, + ['pcov'], + ]; + + yield 'pcov that is already disarmed is left alone' => [ + ['Core', 'pcov'], + ['pcov.enabled' => '0'], + null, + false, + [], + ]; + + yield 'the pcov ini setting without the extension is not a reason' => [ + ['Core'], + ['pcov.enabled' => '1'], + null, + false, + [], + ]; + + yield 'uopz rewrites opcodes unless disabled' => [ + ['Core', 'uopz'], + ['uopz.disable' => '0'], + null, + false, + ['uopz'], + ]; + + yield 'uopz.disable=1 is a working off switch' => [ + ['Core', 'uopz'], + ['uopz.disable' => '1'], + null, + false, + [], + ]; + + yield 'the turbo binary is loaded when it is not loaded yet' => [ + ['Core'], + [], + self::TURBO_BINARY, + false, + ['phpstan_turbo'], + ]; + + yield 'an already loaded turbo extension is not a reason' => [ + ['Core', 'phpstan_turbo'], + [], + self::TURBO_BINARY, + false, + [], + ]; + + yield 'tideways is stripped when the turbo binary is about to be loaded' => [ + ['Core', 'tideways'], + [], + self::TURBO_BINARY, + false, + ['phpstan_turbo', 'tideways'], + ]; + + yield 'tideways is stripped when turbo is already loaded' => [ + ['Core', 'tideways', 'phpstan_turbo'], + [], + null, + false, + ['tideways'], + ]; + + yield 'tideways without turbo is left alone' => [ + ['Core', 'tideways'], + [], + null, + false, + [], + ]; + + yield 'one restart handles every armed hook together' => [ + ['Core', 'xdebug', 'pcov', 'uopz'], + ['xdebug.mode' => 'debug', 'pcov.enabled' => '1', 'uopz.disable' => '0'], + null, + false, + ['xdebug', 'pcov', 'uopz'], + ]; + + yield '--xdebug still restarts for the other reasons' => [ + ['Core', 'xdebug', 'pcov'], + ['xdebug.mode' => 'debug', 'pcov.enabled' => '1'], + self::TURBO_BINARY, + true, + ['pcov', 'phpstan_turbo'], + ]; + } + + /** + * @param list $loadedExtensions + * @param array $iniSettings + * @param list $expectedExtensions + */ + #[DataProvider('dataCollectReasonsFor')] + public function testCollectReasonsFor( + array $loadedExtensions, + array $iniSettings, + ?string $turboExtensionPath, + bool $allowXdebug, + array $expectedExtensions, + ): void + { + $reasons = ExtensionRegistry::collectReasonsFor($loadedExtensions, $iniSettings, $turboExtensionPath, $allowXdebug); + + $this->assertSame( + $expectedExtensions, + array_map(static fn (RestartReason $reason): string => $reason->getExtension(), $reasons), + ); + } + + public function testXdebugIsFlaggedRatherThanStripped(): void + { + // stripping would take xdebug_*() out of reflection and so change what + // PHPStan knows about code calling them; the flag measures the same + $reasons = ExtensionRegistry::collectReasonsFor(['xdebug'], ['xdebug.mode' => 'debug'], null, false); + + $this->assertCount(1, $reasons); + $this->assertSame([], $reasons[0]->getStrippedExtensions()); + $this->assertSame(['xdebug.mode' => 'off'], $reasons[0]->getIniSettings()); + // XDEBUG_MODE wins over the ini, so the ini alone would be overridable + $this->assertSame(['XDEBUG_MODE' => 'off'], $reasons[0]->getEnvironmentVariables()); + $this->assertSame([], $reasons[0]->getCommandLineArguments()); + } + + public function testPcovIsFlaggedRatherThanStripped(): void + { + $reasons = ExtensionRegistry::collectReasonsFor(['pcov'], ['pcov.enabled' => '1'], null, false); + + $this->assertCount(1, $reasons); + $this->assertSame([], $reasons[0]->getStrippedExtensions()); + $this->assertSame(['pcov.enabled' => '0'], $reasons[0]->getIniSettings()); + $this->assertSame([], $reasons[0]->getEnvironmentVariables()); + $this->assertSame([], $reasons[0]->getCommandLineArguments()); + } + + public function testTidewaysIsStrippedRatherThanFlagged(): void + { + $reasons = ExtensionRegistry::collectReasonsFor(['tideways', 'phpstan_turbo'], [], null, false); + + $this->assertCount(1, $reasons); + $this->assertSame(['tideways'], $reasons[0]->getStrippedExtensions()); + $this->assertSame([], $reasons[0]->getIniSettings()); + $this->assertSame([], $reasons[0]->getEnvironmentVariables()); + $this->assertSame([], $reasons[0]->getCommandLineArguments()); + } + + public function testTurboIsAppendedToTheCommandLineTogetherWithItsMarker(): void + { + $reasons = ExtensionRegistry::collectReasonsFor([], [], self::TURBO_BINARY, false); + + $this->assertCount(1, $reasons); + $this->assertSame([], $reasons[0]->getStrippedExtensions()); + $this->assertSame([], $reasons[0]->getIniSettings()); + $this->assertSame([ + '-d', + 'extension=' . self::TURBO_BINARY, + '-d', + TurboExtensionSelector::EXTENSION_PATH_INI . '=' . self::TURBO_BINARY, + ], $reasons[0]->getCommandLineArguments()); + } + + public function testOpcacheIsActivatedWhenDormant(): void + { + $entries = ['opcache.enable_cli=1', 'opcache.jit=disable', 'opcache.file_cache=']; + $reasons = ExtensionRegistry::collectReasonsFor([], [], null, false, $entries, [ + 'opcache.enable_cli' => '', + 'opcache.jit' => 'disable', + 'opcache.file_cache' => '', + ]); + + $this->assertCount(1, $reasons); + $this->assertSame('opcache', $reasons[0]->getExtension()); + $this->assertSame([ + 'opcache.enable_cli' => '1', + 'opcache.jit' => 'disable', + 'opcache.file_cache' => '', + ], $reasons[0]->getIniSettings()); + } + + public function testOpcacheAlreadyConfiguredIsNotAReason(): void + { + $entries = ['opcache.enable_cli=1', 'opcache.jit=disable']; + + $this->assertSame([], ExtensionRegistry::collectReasonsFor([], [], null, false, $entries, [ + 'opcache.enable_cli' => '1', + 'opcache.jit' => 'disable', + ])); + // no entries means OPcache is to be left alone - not loaded, preloading, or not fork-capable + $this->assertSame([], ExtensionRegistry::collectReasonsFor([], [], null, false, [], [])); + } + + public function testCollectReasonsDescribesTheCurrentProcess(): void + { + // the test suite runs with coverage off and without the turbo binary of + // a phar installation, so this only asserts that the real-process entry + // point agrees with what the process actually has loaded + foreach (ExtensionRegistry::collectReasons(false) as $reason) { + $this->assertContains($reason->getExtension(), ['xdebug', 'pcov', 'uopz', 'phpstan_turbo', 'tideways', 'opcache']); + } + + $this->assertSame([], ExtensionRegistry::collectReasonsFor([], [], null, false)); + } + + public function testIsXdebugActiveRequiresTheExtension(): void + { + if (extension_loaded('xdebug')) { + $this->markTestSkipped('xdebug is loaded in this process.'); + } + + $this->assertFalse(ExtensionRegistry::isXdebugActive()); + foreach (ExtensionRegistry::describeDetectedExtensions() as $description) { + $this->assertStringNotContainsString('xdebug', $description); + } + } + +} diff --git a/tests/PHPStan/Process/Restart/IniComposerTest.php b/tests/PHPStan/Process/Restart/IniComposerTest.php new file mode 100644 index 00000000000..b940ca87728 --- /dev/null +++ b/tests/PHPStan/Process/Restart/IniComposerTest.php @@ -0,0 +1,205 @@ +assertNotNull($content); + + $this->assertStringContainsString(';zend_extension = /usr/lib/php/20220829/xdebug.so', $content); + $this->assertStringNotContainsString("\nzend_extension = /usr/lib/php/20220829/xdebug.so", $content); + // the other extension lines are untouched + $this->assertStringContainsString("\nextension=pcov.so", $content); + } + + public function testStrippingLeavesAnExtensionWhoseNameMerelyStartsTheSame(): void + { + $content = IniComposer::composeContent([self::TIDEWAYS_INI], ['tideways'], [], ['tideways.enable_cli' => '1']); + $this->assertNotNull($content); + + $this->assertStringContainsString(';extension=tideways.so', $content); + $this->assertStringContainsString("\nextension=tideways_xhprof.so", $content); + } + + public function testStrippingIsCaseInsensitiveAndCoversBothExtensionForms(): void + { + $content = IniComposer::composeContent( + [self::MAIN_INI], + ['xdebug', 'pcov'], + [], + [], + ); + $this->assertNotNull($content); + + $parsed = parse_ini_string($content); + $this->assertNotFalse($parsed); + $this->assertArrayNotHasKey('zend_extension', $parsed); + $this->assertArrayNotHasKey('extension', $parsed); + } + + public function testFlaggedSettingsWinOverTheIniFilesAndTheRuntimeDiff(): void + { + $content = IniComposer::composeContent( + [self::MAIN_INI], + [], + ['pcov.enabled' => '0'], + ['pcov.enabled' => '1'], + ); + $this->assertNotNull($content); + + $parsed = parse_ini_string($content); + $this->assertNotFalse($parsed); + $this->assertSame('0', $parsed['pcov.enabled']); + } + + public function testOpcacheSettingsPassThroughVerbatim(): void + { + $content = IniComposer::composeContent([self::MAIN_INI], ['xdebug'], ['pcov.enabled' => '0'], []); + $this->assertNotNull($content); + + $parsed = parse_ini_string($content); + $this->assertNotFalse($parsed); + $this->assertSame('1', $parsed['opcache.enable_cli']); + $this->assertSame('tracing', $parsed['opcache.jit']); + $this->assertStringNotContainsString('opcache.enable_cli=0', $content); + } + + public function testRuntimeOverridesSurviveWithoutArgvReconstruction(): void + { + $content = IniComposer::composeContent( + [self::MAIN_INI], + [], + [], + // what `php -d memory_limit=2G -d sys_temp_dir='tmp~1'` leaves behind + ['memory_limit' => '2G', 'sys_temp_dir' => 'tmp~1'], + ); + $this->assertNotNull($content); + + $parsed = parse_ini_string($content); + $this->assertNotFalse($parsed); + $this->assertSame('2G', $parsed['memory_limit']); + $this->assertSame('tmp~1', $parsed['sys_temp_dir']); + } + + public function testRuntimeSettingsEqualToTheIniFilesAreNotRepeated(): void + { + $content = IniComposer::composeContent( + [self::MAIN_INI], + [], + [], + ['memory_limit' => '128M'], + ); + $this->assertNotNull($content); + + $this->assertStringNotContainsString('memory_limit="128M"', $content); + } + + public function testStrippedExtensionsDoNotCarryTheirOwnSettingsOver(): void + { + $content = IniComposer::composeContent( + [self::MAIN_INI], + ['xdebug'], + [], + ['xdebug.mode' => 'debug', 'xdebug.client_port' => '9003'], + ); + $this->assertNotNull($content); + + $this->assertStringNotContainsString('xdebug.client_port', $content); + // the line coming from the ini file itself is left as it is, harmlessly: + // it names a setting no loaded extension owns any more + $this->assertStringContainsString('xdebug.mode = debug', $content); + } + + public function testApcMmapFileMaskIsNeverCarriedOver(): void + { + $content = IniComposer::composeContent([self::MAIN_INI], [], [], ['apc.mmap_file_mask' => '/tmp/apc.XXXXXX']); + $this->assertNotNull($content); + + $this->assertStringNotContainsString('apc.mmap_file_mask', $content); + } + + public function testScannedFilesAreConcatenatedInOrderAndCutAtTheFirstPathSection(): void + { + $content = IniComposer::composeContent([self::MAIN_INI, self::SCANNED_INI], [], [], []); + $this->assertNotNull($content); + + $this->assertStringContainsString('extension = /usr/lib/php/20220829/intl.so', $content); + // directives after [PATH=] only apply to that path + $this->assertStringNotContainsString('[PATH=/var/www/other]', $content); + $this->assertStringNotContainsString('memory_limit = 1G', $content); + + $parsed = parse_ini_string($content); + $this->assertNotFalse($parsed); + $this->assertSame('128M', $parsed['memory_limit']); + } + + public function testAnUnreadableIniFileGivesUpRatherThanComposingAPartialFile(): void + { + $this->assertNull(IniComposer::composeContent([__DIR__ . '/data/does-not-exist.ini'], [], [], [])); + } + + public function testNonStringRuntimeSettingsAreSkipped(): void + { + $content = IniComposer::composeContent([self::MAIN_INI], [], [], ['error_prepend_string' => null]); + $this->assertNotNull($content); + + $this->assertStringNotContainsString('error_prepend_string', $content); + } + + public function testValuesAreQuotedAndEscaped(): void + { + $content = IniComposer::composeContent( + [self::MAIN_INI], + [], + [], + ['include_path' => '.:/usr/share/"php"'], + ); + $this->assertNotNull($content); + + $parsed = parse_ini_string($content); + $this->assertNotFalse($parsed); + $this->assertSame('.:/usr/share/"php"', $parsed['include_path']); + } + + public function testComposeWritesAReadableFileForTheCurrentProcess(): void + { + $file = IniComposer::compose(['xdebug'], ['pcov.enabled' => '0']); + $this->assertNotNull($file); + + try { + $this->assertTrue(is_file($file)); + + $contents = file_get_contents($file); + $this->assertNotFalse($contents); + + $parsed = parse_ini_string($contents); + $this->assertNotFalse($parsed); + $this->assertSame('0', $parsed['pcov.enabled']); + // the runtime diff always carries the current limit over, so the + // restarted process does not fall back to the compiled-in default + $this->assertSame(ini_get('memory_limit'), $parsed['memory_limit']); + } finally { + unlink($file); + } + } + +} diff --git a/tests/PHPStan/Turbo/TurboProcessRestarterTest.php b/tests/PHPStan/Process/Restart/OpcacheSettingsTest.php similarity index 88% rename from tests/PHPStan/Turbo/TurboProcessRestarterTest.php rename to tests/PHPStan/Process/Restart/OpcacheSettingsTest.php index d4de751402e..1c2c04df09d 100644 --- a/tests/PHPStan/Turbo/TurboProcessRestarterTest.php +++ b/tests/PHPStan/Process/Restart/OpcacheSettingsTest.php @@ -1,13 +1,13 @@ , list}> */ - public static function dataResolveOpcacheArgs(): iterable + public static function dataResolveIniEntries(): iterable { $stock = [ 'opcache.file_cache_only' => '0', @@ -90,10 +90,10 @@ public static function dataResolveOpcacheArgs(): iterable * @param array $ini * @param list $expected */ - #[DataProvider('dataResolveOpcacheArgs')] - public function testResolveOpcacheArgs(array $ini, array $expected): void + #[DataProvider('dataResolveIniEntries')] + public function testResolveIniEntries(array $ini, array $expected): void { - $this->assertSame($expected, TurboProcessRestarter::resolveOpcacheArgs($ini)); + $this->assertSame($expected, OpcacheSettings::resolveIniEntries($ini)); } /** @@ -112,7 +112,7 @@ private static function withSizes(int $memory, int $interned, int $files): array /** * @return iterable, bool}> */ - public static function dataResolveOpcacheRestartNeeded(): iterable + public static function dataIsRestartNeeded(): iterable { // what the restarted process reads back through ini_get() for the stock args $inEffect = [ @@ -171,10 +171,10 @@ public static function dataResolveOpcacheRestartNeeded(): iterable /** * @param array $currentIniValues */ - #[DataProvider('dataResolveOpcacheRestartNeeded')] - public function testResolveOpcacheRestartNeeded(array $currentIniValues, bool $expected): void + #[DataProvider('dataIsRestartNeeded')] + public function testIsRestartNeeded(array $currentIniValues, bool $expected): void { - $this->assertSame($expected, TurboProcessRestarter::resolveOpcacheRestartNeeded(self::STOCK_ARGS, $currentIniValues)); + $this->assertSame($expected, OpcacheSettings::isRestartNeeded(self::STOCK_ARGS, $currentIniValues)); } } diff --git a/tests/PHPStan/Process/Restart/RestartManagerTest.php b/tests/PHPStan/Process/Restart/RestartManagerTest.php new file mode 100644 index 00000000000..5f380bef330 --- /dev/null +++ b/tests/PHPStan/Process/Restart/RestartManagerTest.php @@ -0,0 +1,156 @@ + */ + private array $originalEnvironment = []; + + #[Override] + protected function setUp(): void + { + foreach (self::ENV_VARIABLES as $name) { + $this->originalEnvironment[$name] = getenv($name); + putenv($name); + } + } + + #[Override] + protected function tearDown(): void + { + foreach ($this->originalEnvironment as $name => $value) { + putenv($value === false ? $name : sprintf('%s=%s', $name, $value)); + } + } + + /** + * @return iterable, bool}> + */ + public static function dataIsEligible(): iterable + { + yield 'the default command restarts' => [['bin/phpstan'], true]; + yield 'analyse restarts' => [['bin/phpstan', 'analyse', '-l', '8', 'src'], true]; + yield 'analyze restarts' => [['bin/phpstan', 'analyze', 'src'], true]; + yield 'fix restarts' => [['bin/phpstan', 'analyse', '--fix', 'src'], true]; + yield 'bisect restarts' => [['bin/phpstan', 'bisect'], true]; + + yield 'worker never restarts' => [['bin/phpstan', 'worker', '--port', '1234'], false]; + yield 'fixer:worker never restarts' => [['bin/phpstan', 'fixer:worker'], false]; + yield 'diagnose never restarts' => [['bin/phpstan', 'diagnose'], false]; + // these analyse nothing, so an exec would buy no speed + yield 'clear-result-cache never restarts' => [['bin/phpstan', 'clear-result-cache'], false]; + yield 'dump-parameters never restarts' => [['bin/phpstan', 'dump-parameters'], false]; + + // --xdebug keeps xdebug; --debug only makes the analysis serial + yield '--debug restarts' => [['bin/phpstan', 'analyse', '--debug', 'src'], true]; + + yield 'an option value is not mistaken for a non-restarting command' => [ + ['bin/phpstan', 'analyse', '-c', 'worker', 'src'], + true, + ]; + yield 'paths after -- do not hide the command name' => [ + ['bin/phpstan', 'worker', '--', 'src'], + false, + ]; + } + + /** + * @param list $argv + */ + #[DataProvider('dataIsEligible')] + public function testIsEligible(array $argv, bool $expected): void + { + $this->assertSame($expected, RestartManager::isEligible($argv)); + } + + public function testTheMarkerStopsASecondRestart(): void + { + $argv = ['bin/phpstan', 'analyse', 'src']; + $this->assertTrue(RestartManager::isEligible($argv)); + + putenv(sprintf('%s=xdebug,pcov', RestartManager::REASONS_ENV_VARIABLE)); + + $this->assertFalse(RestartManager::isEligible($argv)); + $this->assertSame([], RestartManager::collectReasons($argv)); + } + + public function testABlackfireProfiledProcessIsNotRestarted(): void + { + $argv = ['bin/phpstan', 'analyse', 'src']; + $this->assertTrue(RestartManager::isEligible($argv)); + + $_SERVER['BLACKFIRE_AGENT_SOCKET'] = 'unix:///var/run/blackfire/agent.sock'; + try { + $this->assertFalse(RestartManager::isEligible($argv)); + } finally { + unset($_SERVER['BLACKFIRE_AGENT_SOCKET']); + } + } + + public function testPerformedReasonsComeFromTheMarker(): void + { + $this->assertFalse(RestartManager::wasRestarted()); + $this->assertSame([], RestartManager::getPerformedRestartReasons()); + $this->assertFalse(RestartManager::wasRestartedFor('xdebug')); + + putenv(sprintf('%s=xdebug,pcov', RestartManager::REASONS_ENV_VARIABLE)); + + $this->assertTrue(RestartManager::wasRestarted()); + $this->assertSame(['xdebug', 'pcov'], RestartManager::getPerformedRestartReasons()); + $this->assertTrue(RestartManager::wasRestartedFor('xdebug')); + $this->assertTrue(RestartManager::wasRestartedFor('pcov')); + $this->assertFalse(RestartManager::wasRestartedFor('phpstan_turbo')); + } + + public function testTheDeprecatedAllowXdebugEnvVariableIsHonored(): void + { + $argv = ['bin/phpstan', 'analyse', 'src']; + + $this->assertFalse(RestartManager::isXdebugAllowed($argv)); + $this->assertTrue(RestartManager::isXdebugAllowed(['bin/phpstan', 'analyse', '--xdebug', 'src'])); + + putenv('PHPSTAN_ALLOW_XDEBUG=1'); + $this->assertTrue(RestartManager::isXdebugAllowed($argv)); + + putenv('PHPSTAN_ALLOW_XDEBUG=0'); + $this->assertFalse(RestartManager::isXdebugAllowed($argv)); + + putenv('PHPSTAN_ALLOW_XDEBUG='); + $this->assertFalse(RestartManager::isXdebugAllowed($argv)); + } + + public function testAnotherApplicationHasItsOwnAllowXdebugEnvVariable(): void + { + $argv = ['vendor/bin/rector', 'process', 'src']; + + putenv('PHPSTAN_ALLOW_XDEBUG=1'); + $this->assertFalse(RestartManager::isXdebugAllowed($argv, 'rector')); + + putenv('RECTOR_ALLOW_XDEBUG=1'); + $this->assertTrue(RestartManager::isXdebugAllowed($argv, 'rector')); + } + + public function testNonRestartingCommandsCollectNoReasons(): void + { + $this->assertSame([], RestartManager::collectReasons(['bin/phpstan', 'worker'])); + $this->assertSame([], RestartManager::collectReasons(['bin/phpstan', 'diagnose'])); + } + +} diff --git a/tests/PHPStan/Process/Restart/data/main.ini b/tests/PHPStan/Process/Restart/data/main.ini new file mode 100644 index 00000000000..281f3d6f8a2 --- /dev/null +++ b/tests/PHPStan/Process/Restart/data/main.ini @@ -0,0 +1,8 @@ +[PHP] +memory_limit = 128M +zend_extension = /usr/lib/php/20220829/xdebug.so +xdebug.mode = debug +extension=pcov.so +pcov.enabled = 1 +opcache.enable_cli = 1 +opcache.jit = tracing diff --git a/tests/PHPStan/Process/Restart/data/scanned.ini b/tests/PHPStan/Process/Restart/data/scanned.ini new file mode 100644 index 00000000000..e9e45e546da --- /dev/null +++ b/tests/PHPStan/Process/Restart/data/scanned.ini @@ -0,0 +1,6 @@ +extension = /usr/lib/php/20220829/intl.so +; a commented out extension stays commented out +;zend_extension = xdebug.so + +[PATH=/var/www/other] +memory_limit = 1G diff --git a/tests/PHPStan/Process/Restart/data/tideways.ini b/tests/PHPStan/Process/Restart/data/tideways.ini new file mode 100644 index 00000000000..51e44543d1d --- /dev/null +++ b/tests/PHPStan/Process/Restart/data/tideways.ini @@ -0,0 +1,3 @@ +extension=tideways.so +tideways.enable_cli = 1 +extension=tideways_xhprof.so diff --git a/turbo-ext/CLAUDE.md b/turbo-ext/CLAUDE.md index 6c2654a593d..e89c01a70fb 100644 --- a/turbo-ext/CLAUDE.md +++ b/turbo-ext/CLAUDE.md @@ -304,7 +304,7 @@ PHP_INI_SCAN_DIR=":/tmp/turbo-ini" /usr/bin/time php -d memory_limit=6G bin/phps ``` Never load it with `php -d extension=` for a `bin/phpstan` run: when the -OPcache settings are not the ones TurboProcessRestarter wants (OPcache off +OPcache settings are not the ones RestartManager wants (OPcache off for CLI, timestamps validated — the CI images and a default Homebrew PHP both), PHPStan re-executes itself, and a command-line `-d extension=` does not survive the restart. The run then measures PHP alone, while