| 1: | <?php declare(strict_types = 1); |
| 2: | |
| 3: | namespace PHPStan\Turbo; |
| 4: | |
| 5: | use function explode; |
| 6: | use function extension_loaded; |
| 7: | use function function_exists; |
| 8: | use function get_cfg_var; |
| 9: | use function in_array; |
| 10: | use function ini_get; |
| 11: | use function is_string; |
| 12: | use function max; |
| 13: | use function pcntl_exec; |
| 14: | use function php_ini_loaded_file; |
| 15: | use function strtolower; |
| 16: | use function trim; |
| 17: | use const PHP_BINARY; |
| 18: | |
| 19: | /** |
| 20: | * Restarts the main PHPStan process via pcntl_exec() when the process it |
| 21: | * was started as is not the one PHPStan wants to run — for either of two |
| 22: | * reasons, checked independently. |
| 23: | * |
| 24: | * The distributed turbo extension is available but not loaded. Parallel |
| 25: | * analysis prefers pcntl_fork()ed workers over spawned ones (see |
| 26: | * ForkParallelChecker) — a forked worker inherits the booted process instead |
| 27: | * of paying a full re-boot. But a forked worker also inherits the parent's |
| 28: | * loaded extensions: the spawn-time `-d extension=` injection |
| 29: | * (ProcessHelper) cannot reach it, and dl() cannot load a binary from next |
| 30: | * to the phar. So the whole process is re-executed with the extension |
| 31: | * before anything else runs — forked workers then inherit the extension, |
| 32: | * its shadowing classes, and (when running from a phar) the phar-fork-guard |
| 33: | * that keeps phar:// reads safe across fork. |
| 34: | * |
| 35: | * The OPcache configuration in effect is not the one PHPStan wants (see |
| 36: | * resolveOpcacheArgs()) — usually because OPcache is dormant on CLI, or has |
| 37: | * JIT on. The restarted process runs with it activated, JIT pinned off, and |
| 38: | * forked workers share the parent's warm opcode cache. This reason stands |
| 39: | * on its own so that a php.ini which already loads turbo (a source checkout |
| 40: | * with a locally built extension, say) does not leave OPcache dormant just |
| 41: | * because there is no extension left to restart for. |
| 42: | * |
| 43: | * The restart carries two marker ini entries: RESTARTED_INI is the retry |
| 44: | * stop (a binary that failed to load, or an OPcache that could not start, |
| 45: | * would otherwise restart forever), and EXTENSION_PATH_INI tells |
| 46: | * TurboExtensionSelector that spawned workers still need the -d flag |
| 47: | * (command-line -d flags, unlike php.ini, are not inherited). ProcessHelper |
| 48: | * sets both on the workers it spawns, along with the OPcache entries: a |
| 49: | * worker's configuration is decided by the process spawning it, so it never |
| 50: | * restarts itself — before that, every spawned worker on a pcntl host |
| 51: | * re-executed itself once to activate OPcache, rebuilding its command line |
| 52: | * without the sys_temp_dir and extension entries of the spawn. |
| 53: | */ |
| 54: | final class TurboProcessRestarter |
| 55: | { |
| 56: | |
| 57: | public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath'; |
| 58: | |
| 59: | /** Set by the restart, and by ProcessHelper on spawned workers, so the process never restarts itself */ |
| 60: | public const RESTARTED_INI = 'phpstan.restarted'; |
| 61: | |
| 62: | /** Shared memory reserved (not touched) for the opcode cache, in MB — see resolveOpcacheArgs() for the sizing */ |
| 63: | private const OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT = 256; |
| 64: | |
| 65: | /** Carved out of the memory above for interned strings, in MB */ |
| 66: | private const OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT = 64; |
| 67: | |
| 68: | private const OPCACHE_MAX_ACCELERATED_FILES_LIMIT = 20000; |
| 69: | |
| 70: | /** PHP's default opcache.optimization_level, pinned so the optimizer (and the extension's pass in it) always runs */ |
| 71: | private const OPCACHE_OPTIMIZATION_LEVEL = '0x7FFEBFFF'; |
| 72: | |
| 73: | /** The php.ini directives resolveOpcacheArgs() reacts to */ |
| 74: | private const OPCACHE_INI_INPUTS = [ |
| 75: | 'opcache.file_cache_only', |
| 76: | 'opcache.preload', |
| 77: | 'opcache.memory_consumption', |
| 78: | 'opcache.interned_strings_buffer', |
| 79: | 'opcache.max_accelerated_files', |
| 80: | ]; |
| 81: | |
| 82: | /** |
| 83: | * The extension path this process was given through -d — by the restart, |
| 84: | * or by ProcessHelper when spawned as a worker. Null when the extension |
| 85: | * came from the php.ini or is not loaded at all. |
| 86: | */ |
| 87: | public static function getRestartExtensionPath(): ?string |
| 88: | { |
| 89: | $path = get_cfg_var(self::EXTENSION_PATH_INI); |
| 90: | if (!is_string($path) || $path === '') { |
| 91: | return null; |
| 92: | } |
| 93: | |
| 94: | return $path; |
| 95: | } |
| 96: | |
| 97: | /** |
| 98: | * On success the call never returns — the process image is replaced. |
| 99: | * |
| 100: | * Another program that loads PHPStan's phar, Rector for example, passes |
| 101: | * the path to that phar: Phar::running() only knows the phar that runs. |
| 102: | * |
| 103: | * @api |
| 104: | * @param list<string> $argv |
| 105: | */ |
| 106: | public static function restartIfSuitable(array $argv, ?string $pharPath = null): void |
| 107: | { |
| 108: | if (get_cfg_var(self::RESTARTED_INI) !== false) { |
| 109: | // already restarted — whatever did not take effect (a binary that |
| 110: | // failed to load, an OPcache that could not start) will not on a |
| 111: | // second try either |
| 112: | return; |
| 113: | } |
| 114: | if ( |
| 115: | isset($_SERVER['BLACKFIRE_AGENT_SOCKET']) |
| 116: | ) { |
| 117: | // pcntl_exec() is not supported by blackfire |
| 118: | // see https://support.blackfire.platform.sh/hc/en-us/articles/4843014509202-Conflicts-with-pcntl-exec-calls |
| 119: | return; |
| 120: | } |
| 121: | if ( |
| 122: | !function_exists('pcntl_exec') |
| 123: | || !function_exists('pcntl_fork') |
| 124: | || !function_exists('pcntl_waitpid') |
| 125: | || !function_exists('pcntl_wifexited') |
| 126: | || !function_exists('pcntl_wexitstatus') |
| 127: | || !function_exists('posix_kill') |
| 128: | ) { |
| 129: | // fork mode is impossible here (see ForkParallelChecker) and |
| 130: | // spawned workers get the extension from ProcessHelper already |
| 131: | return; |
| 132: | } |
| 133: | |
| 134: | $extensionPath = extension_loaded('phpstan_turbo') ? null : TurboExtensionSelector::findExtension($pharPath); |
| 135: | $opcacheArgs = self::getOpcacheArgs(); |
| 136: | if ( |
| 137: | $extensionPath === null |
| 138: | && !self::resolveOpcacheRestartNeeded($opcacheArgs, self::getCurrentIniValues($opcacheArgs)) |
| 139: | ) { |
| 140: | return; |
| 141: | } |
| 142: | |
| 143: | $args = []; |
| 144: | $phpIni = php_ini_loaded_file(); |
| 145: | if ($phpIni !== false) { |
| 146: | $args[] = '-c'; |
| 147: | $args[] = $phpIni; |
| 148: | } |
| 149: | $args[] = '-d'; |
| 150: | $args[] = 'memory_limit=' . ini_get('memory_limit'); |
| 151: | foreach ($opcacheArgs as $opcacheArg) { |
| 152: | $args[] = '-d'; |
| 153: | $args[] = $opcacheArg; |
| 154: | } |
| 155: | if ($extensionPath !== null) { |
| 156: | $args[] = '-d'; |
| 157: | $args[] = 'extension=' . $extensionPath; |
| 158: | $args[] = '-d'; |
| 159: | $args[] = self::EXTENSION_PATH_INI . '=' . $extensionPath; |
| 160: | } |
| 161: | $args[] = '-d'; |
| 162: | $args[] = self::RESTARTED_INI . '=1'; |
| 163: | foreach ($argv as $arg) { |
| 164: | $args[] = $arg; |
| 165: | } |
| 166: | |
| 167: | pcntl_exec(PHP_BINARY, $args); |
| 168: | // pcntl_exec() returns only on failure — continue as we are |
| 169: | } |
| 170: | |
| 171: | /** |
| 172: | * Whether the OPcache configuration the restart would set differs from |
| 173: | * the one in effect — the second reason to restart, independent of turbo: |
| 174: | * with the extension already loaded through php.ini there would be |
| 175: | * nothing else to restart for, and OPcache would stay dormant. |
| 176: | * |
| 177: | * @param list<string> $opcacheArgs `name=value` entries, see resolveOpcacheArgs() |
| 178: | * @param array<string, string|false> $currentIniValues ini_get() of each of those names |
| 179: | */ |
| 180: | public static function resolveOpcacheRestartNeeded(array $opcacheArgs, array $currentIniValues): bool |
| 181: | { |
| 182: | foreach ($opcacheArgs as $opcacheArg) { |
| 183: | [$name, $value] = explode('=', $opcacheArg, 2); |
| 184: | $current = $currentIniValues[$name] ?? false; |
| 185: | if ($current === false) { |
| 186: | // unknown directive on this PHP (opcache.jit before 8.0) — nothing a restart could change |
| 187: | continue; |
| 188: | } |
| 189: | if (self::normalizeIniValue($current) !== self::normalizeIniValue($value)) { |
| 190: | return true; |
| 191: | } |
| 192: | } |
| 193: | |
| 194: | return false; |
| 195: | } |
| 196: | |
| 197: | /** |
| 198: | * @param list<string> $opcacheArgs |
| 199: | * @return array<string, string|false> |
| 200: | */ |
| 201: | private static function getCurrentIniValues(array $opcacheArgs): array |
| 202: | { |
| 203: | $values = []; |
| 204: | foreach ($opcacheArgs as $opcacheArg) { |
| 205: | $name = explode('=', $opcacheArg, 2)[0]; |
| 206: | $values[$name] = ini_get($name); |
| 207: | } |
| 208: | |
| 209: | return $values; |
| 210: | } |
| 211: | |
| 212: | /** |
| 213: | * The ini parser stores booleans as "1" / "" for php.ini and -d values |
| 214: | * alike; ini_set()-style spellings are folded the same way. |
| 215: | */ |
| 216: | private static function normalizeIniValue(string $value): string |
| 217: | { |
| 218: | $value = strtolower(trim($value)); |
| 219: | if (in_array($value, ['', '0', 'off', 'false', 'no', 'none'], true)) { |
| 220: | return '0'; |
| 221: | } |
| 222: | if (in_array($value, ['1', 'on', 'true', 'yes'], true)) { |
| 223: | return '1'; |
| 224: | } |
| 225: | |
| 226: | return $value; |
| 227: | } |
| 228: | |
| 229: | /** |
| 230: | * `-d` entries activating OPcache for the restarted process, and for the |
| 231: | * workers ProcessHelper spawns — see resolveOpcacheArgs() for the |
| 232: | * reasoning behind each of them. |
| 233: | * |
| 234: | * Nothing is added when OPcache is not loaded at all — real on PHP <= 8.4, |
| 235: | * gone on 8.5+ (always built in and loaded). Loading it from here is not |
| 236: | * worth it: -d zend_extension=opcache emits a startup warning on builds |
| 237: | * without the shared object, and on 8.5+ always. |
| 238: | * |
| 239: | * @return list<string> |
| 240: | */ |
| 241: | public static function getOpcacheArgs(): array |
| 242: | { |
| 243: | if (!extension_loaded('Zend OPcache')) { |
| 244: | return []; |
| 245: | } |
| 246: | |
| 247: | $ini = []; |
| 248: | foreach (self::OPCACHE_INI_INPUTS as $name) { |
| 249: | $ini[$name] = ini_get($name); |
| 250: | } |
| 251: | |
| 252: | return self::resolveOpcacheArgs($ini); |
| 253: | } |
| 254: | |
| 255: | /** |
| 256: | * OPcache directives for the restarted process, as `name=value` ini |
| 257: | * entries, given the current values of the OPCACHE_INI_INPUTS directives |
| 258: | * (the restarted process loads the same php.ini, so they are what it |
| 259: | * would otherwise run with). |
| 260: | * |
| 261: | * The exec is paid for anyway, so it doubles as the chance to activate |
| 262: | * OPcache, usually dormant on CLI (opcache.enable_cli defaults to off): |
| 263: | * optimized opcodes and the inheritance cache speed up the whole run, and |
| 264: | * the pcntl_fork()ed workers inherit the parent's warm shared memory |
| 265: | * instead of each compiling lazily-loaded classes for itself. Concurrent |
| 266: | * population of that inherited cache is safe — it is php-fpm's normal |
| 267: | * operating model (see ForkParallelChecker). |
| 268: | * |
| 269: | * JIT is always pinned off, even when the user's ini enables it — it is a |
| 270: | * measured slowdown for PHPStan's workload and its shared code buffer is |
| 271: | * not fork-safe, so honoring it would cost twice (the same treatment the |
| 272: | * xdebug-handler restart gives xdebug). Merely flipping |
| 273: | * opcache.enable_cli=1 could also activate it behind the user's back: on |
| 274: | * PHP <= 8.3 opcache.jit defaults to `tracing`, so an ini setting just a |
| 275: | * non-zero opcache.jit_buffer_size (common web-tuning advice) suddenly |
| 276: | * JITs; on PHP >= 8.4 the buffer defaults to 64M, so an ini setting just |
| 277: | * opcache.jit does |
| 278: | * (https://php.watch/versions/8.4/opcache-jit-ini-default-changes). |
| 279: | * Pinning both directives covers both generations of defaults. |
| 280: | * |
| 281: | * Timestamp checks are switched off, or nothing of PHPStan itself would be |
| 282: | * cached: opcache_compile_file() refuses any file whose mtime it reads as |
| 283: | * 0, which is what every member of the distributed phar carried until the |
| 284: | * build started stamping them (phar.yml). It reads the mtime whenever |
| 285: | * opcache.validate_timestamps, opcache.file_update_protection or |
| 286: | * opcache.max_file_size is on, so all three go — for a private cache that |
| 287: | * dies with the process they revalidate nothing anyway, and skipping them |
| 288: | * also drops a stat() per include. The uncached state is what made |
| 289: | * OPcache *slower* than no OPcache for phar runs: code compiled under an |
| 290: | * active OPcache but not persisted never gets its strings interned into |
| 291: | * SHM, so its type names have no class-entry cache slot and every |
| 292: | * class-typed parameter, return and property check falls back to a |
| 293: | * lowercased-copy class-table lookup (measured at +27% CPU on slevomat). |
| 294: | * |
| 295: | * The buffers are raised above the stock 128M/8M/10000 for the same |
| 296: | * reason: once any of them is exhausted, everything compiled afterwards |
| 297: | * lands in that same uncached, uninterned state — the stock 8M of interned |
| 298: | * strings run out during PHPStan's own boot already, which alone costs the |
| 299: | * whole gain. PHPStan's phar needs about 35M of code; project bootstraps |
| 300: | * loaded by extensions (a Doctrine objectManagerLoader booting the whole |
| 301: | * app, say) add hundreds of MB, and the shared memory is only reserved, |
| 302: | * not touched, until used. It is not sized for the largest possible |
| 303: | * project, though — an SHM reservation that cannot be satisfied is fatal |
| 304: | * (exit code 254) in the restarted process, with no parent left to fall |
| 305: | * back to. Sizes the php.ini already grants are never lowered, and the |
| 306: | * interned strings buffer is kept below the memory it is carved out of |
| 307: | * (another fatal startup error otherwise). |
| 308: | * |
| 309: | * That private cache must neither outlive the process nor reach outside |
| 310: | * it, which is what the remaining entries guard against in a php.ini tuned |
| 311: | * for the web server rather than for us: |
| 312: | * - opcache.file_cache is blanked. A file cache is validated by the PHP |
| 313: | * build id and (only with opcache.validate_timestamps) the mtime — so |
| 314: | * with the checks off it would keep serving the previous PHPStan's |
| 315: | * opcodes after an update, the phar path being the same, and it would |
| 316: | * fill the web server's cache directory with this run's scripts. |
| 317: | * - opcache.save_comments is pinned on: stripping doc comments (a common |
| 318: | * web tuning) breaks annotation readers in the project code the |
| 319: | * extensions bootstrap, which worked with OPcache dormant. |
| 320: | * - opcache.optimization_level is pinned to PHP's default: the |
| 321: | * extension's pass dropping PHPStan's own type checks |
| 322: | * (TurboExtensionEnabler::trustOwnTypesIfSuitable()) runs inside the |
| 323: | * optimizer, which a php.ini can switch off entirely. |
| 324: | * |
| 325: | * Two configurations are left alone entirely — no OPcache entries at all, |
| 326: | * so the restarted process runs with what the php.ini says, as before: |
| 327: | * - opcache.file_cache_only: blanking the file cache would be a fatal |
| 328: | * startup error, and it usually means shared memory is unavailable on |
| 329: | * that host on purpose. |
| 330: | * - opcache.preload: the application's preload script would run inside |
| 331: | * PHPStan (there is no CLI exemption), and it cannot be blanked with -d |
| 332: | * (the directive rejects an empty value). |
| 333: | * |
| 334: | * @param array<string, string|false> $ini |
| 335: | * @return list<string> |
| 336: | */ |
| 337: | public static function resolveOpcacheArgs(array $ini): array |
| 338: | { |
| 339: | if (self::isIniOn($ini['opcache.file_cache_only'] ?? false)) { |
| 340: | return []; |
| 341: | } |
| 342: | $preload = $ini['opcache.preload'] ?? false; |
| 343: | if ($preload !== false && $preload !== '') { |
| 344: | return []; |
| 345: | } |
| 346: | |
| 347: | $memory = max(self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT, self::iniInt($ini['opcache.memory_consumption'] ?? false)); |
| 348: | $internedStrings = max(self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT, self::iniInt($ini['opcache.interned_strings_buffer'] ?? false)); |
| 349: | if ($internedStrings >= $memory) { |
| 350: | $memory = $internedStrings + self::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT - self::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT; |
| 351: | } |
| 352: | $files = max(self::OPCACHE_MAX_ACCELERATED_FILES_LIMIT, self::iniInt($ini['opcache.max_accelerated_files'] ?? false)); |
| 353: | |
| 354: | return [ |
| 355: | 'opcache.enable=1', |
| 356: | 'opcache.enable_cli=1', |
| 357: | 'opcache.jit=disable', |
| 358: | 'opcache.jit_buffer_size=0', |
| 359: | 'opcache.validate_timestamps=0', |
| 360: | 'opcache.file_update_protection=0', |
| 361: | 'opcache.max_file_size=0', |
| 362: | 'opcache.file_cache=', |
| 363: | 'opcache.save_comments=1', |
| 364: | 'opcache.optimization_level=' . self::OPCACHE_OPTIMIZATION_LEVEL, |
| 365: | 'opcache.memory_consumption=' . $memory, |
| 366: | 'opcache.interned_strings_buffer=' . $internedStrings, |
| 367: | 'opcache.max_accelerated_files=' . $files, |
| 368: | ]; |
| 369: | } |
| 370: | |
| 371: | private static function isIniOn(string|false $value): bool |
| 372: | { |
| 373: | return $value !== false && $value !== '' && $value !== '0'; |
| 374: | } |
| 375: | |
| 376: | private static function iniInt(string|false $value): int |
| 377: | { |
| 378: | return $value === false ? 0 : (int) $value; |
| 379: | } |
| 380: | |
| 381: | } |
| 382: |