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: