77use function get_cfg_var ;
88use function ini_get ;
99use function is_string ;
10+ use function max ;
1011use function pcntl_exec ;
1112use function php_ini_loaded_file ;
1213use const PHP_BINARY ;
2829 *
2930 * The restarted process also gets OPcache activated — with JIT pinned off,
3031 * whatever the user's ini says — so forked workers share the parent's warm
31- * opcode cache (see getOpcacheArgs ()).
32+ * opcode cache (see resolveOpcacheArgs ()).
3233 *
3334 * The restart carries a marker ini entry with the extension path. It doubles
3435 * as the retry stop (a binary that failed to load leaves the extension
@@ -41,14 +42,23 @@ final class TurboProcessRestarter
4142
4243 public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath ' ;
4344
44- /** Shared memory reserved (not touched) for the opcode cache, in MB — see getOpcacheArgs () for the sizing */
45+ /** Shared memory reserved (not touched) for the opcode cache, in MB — see resolveOpcacheArgs () for the sizing */
4546 private const OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT = 256 ;
4647
4748 /** Carved out of the memory above for interned strings, in MB */
4849 private const OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT = 64 ;
4950
5051 private const OPCACHE_MAX_ACCELERATED_FILES_LIMIT = 20000 ;
5152
53+ /** The php.ini directives resolveOpcacheArgs() reacts to */
54+ private const OPCACHE_INI_INPUTS = [
55+ 'opcache.file_cache_only ' ,
56+ 'opcache.preload ' ,
57+ 'opcache.memory_consumption ' ,
58+ 'opcache.interned_strings_buffer ' ,
59+ 'opcache.max_accelerated_files ' ,
60+ ];
61+
5262 /**
5363 * The extension path this process was restarted with, null when the
5464 * process was not restarted.
@@ -120,7 +130,35 @@ public static function restartIfSuitable(array $argv): void
120130 }
121131
122132 /**
123- * OPcache directives for the restarted process, as `name=value` ini entries.
133+ * `-d` entries activating OPcache for the restarted process — see
134+ * resolveOpcacheArgs() for the reasoning behind each of them.
135+ *
136+ * Nothing is added when OPcache is not loaded at all — real on PHP <= 8.4,
137+ * gone on 8.5+ (always built in and loaded). Loading it from here is not
138+ * worth it: -d zend_extension=opcache emits a startup warning on builds
139+ * without the shared object, and on 8.5+ always.
140+ *
141+ * @return list<string>
142+ */
143+ private static function getOpcacheArgs (): array
144+ {
145+ if (!extension_loaded ('Zend OPcache ' )) {
146+ return [];
147+ }
148+
149+ $ ini = [];
150+ foreach (self ::OPCACHE_INI_INPUTS as $ name ) {
151+ $ ini [$ name ] = ini_get ($ name );
152+ }
153+
154+ return self ::resolveOpcacheArgs ($ ini );
155+ }
156+
157+ /**
158+ * OPcache directives for the restarted process, as `name=value` ini
159+ * entries, given the current values of the OPCACHE_INI_INPUTS directives
160+ * (the restarted process loads the same php.ini, so they are what it
161+ * would otherwise run with).
124162 *
125163 * The exec is paid for anyway, so it doubles as the chance to activate
126164 * OPcache, usually dormant on CLI (opcache.enable_cli defaults to off):
@@ -144,40 +182,72 @@ public static function restartIfSuitable(array $argv): void
144182 *
145183 * Timestamp checks are switched off, or nothing of PHPStan itself would be
146184 * cached: opcache_compile_file() refuses any file whose mtime it reads as
147- * 0, and the members of the distributed phar carry exactly that ( the
148- * build normalizes them for reproducibility ). It reads the mtime whenever
149- * opcache.validate_timestamps *or* opcache.file_update_protection is on,
150- * so both go — for a private cache that dies with the process they
151- * revalidate nothing anyway, and skipping them also drops a stat() per
152- * include. The uncached state is what made OPcache *slower* than no
153- * OPcache for phar runs: code compiled under an active OPcache but not
154- * persisted never gets its strings interned into SHM, so its type names
155- * have no class-entry cache slot and every class-typed parameter, return
156- * and property check falls back to a lowercased-copy class-table lookup
157- * (measured at +27% CPU on slevomat).
185+ * 0, which is what every member of the distributed phar carried until the
186+ * build started stamping them (phar.yml ). It reads the mtime whenever
187+ * opcache.validate_timestamps, opcache.file_update_protection or
188+ * opcache.max_file_size is on, so all three go — for a private cache that
189+ * dies with the process they revalidate nothing anyway, and skipping them
190+ * also drops a stat() per include. The uncached state is what made
191+ * OPcache *slower* than no OPcache for phar runs: code compiled under an
192+ * active OPcache but not persisted never gets its strings interned into
193+ * SHM, so its type names have no class-entry cache slot and every
194+ * class-typed parameter, return and property check falls back to a
195+ * lowercased-copy class-table lookup (measured at +27% CPU on slevomat).
158196 *
159197 * The buffers are raised above the stock 128M/8M/10000 for the same
160198 * reason: once any of them is exhausted, everything compiled afterwards
161- * lands in that same uncached, uninterned state. PHPStan's own phar needs
162- * about 35M of code; project bootstraps loaded by extensions (a Doctrine
163- * objectManagerLoader booting the whole app, say) add hundreds of MB, and
164- * the shared memory is only reserved, not touched, until used. It is not
165- * sized for the largest possible project, though — an SHM reservation that
166- * cannot be satisfied is fatal (exit code 254) in the restarted process,
167- * with no parent left to fall back to.
199+ * lands in that same uncached, uninterned state — the stock 8M of interned
200+ * strings run out during PHPStan's own boot already, which alone costs the
201+ * whole gain. PHPStan's phar needs about 35M of code; project bootstraps
202+ * loaded by extensions (a Doctrine objectManagerLoader booting the whole
203+ * app, say) add hundreds of MB, and the shared memory is only reserved,
204+ * not touched, until used. It is not sized for the largest possible
205+ * project, though — an SHM reservation that cannot be satisfied is fatal
206+ * (exit code 254) in the restarted process, with no parent left to fall
207+ * back to. Sizes the php.ini already grants are never lowered, and the
208+ * interned strings buffer is kept below the memory it is carved out of
209+ * (another fatal startup error otherwise).
210+ *
211+ * That private cache must neither outlive the process nor reach outside
212+ * it, which is what the remaining entries guard against in a php.ini tuned
213+ * for the web server rather than for us:
214+ * - opcache.file_cache is blanked. A file cache is validated by the PHP
215+ * build id and (only with opcache.validate_timestamps) the mtime — so
216+ * with the checks off it would keep serving the previous PHPStan's
217+ * opcodes after an update, the phar path being the same, and it would
218+ * fill the web server's cache directory with this run's scripts.
219+ * - opcache.save_comments is pinned on: stripping doc comments (a common
220+ * web tuning) breaks annotation readers in the project code the
221+ * extensions bootstrap, which worked with OPcache dormant.
168222 *
169- * The only case adding nothing is OPcache not being loaded at all — real
170- * on PHP <= 8.4, gone on 8.5+ (always built in and loaded). Loading it
171- * from here is not worth it: -d zend_extension=opcache emits a startup
172- * warning on builds without the shared object, and on 8.5+ always.
223+ * Two configurations are left alone entirely — no OPcache entries at all,
224+ * so the restarted process runs with what the php.ini says, as before:
225+ * - opcache.file_cache_only: blanking the file cache would be a fatal
226+ * startup error, and it usually means shared memory is unavailable on
227+ * that host on purpose.
228+ * - opcache.preload: the application's preload script would run inside
229+ * PHPStan (there is no CLI exemption), and it cannot be blanked with -d
230+ * (the directive rejects an empty value).
173231 *
232+ * @param array<string, string|false> $ini
174233 * @return list<string>
175234 */
176- private static function getOpcacheArgs ( ): array
235+ public static function resolveOpcacheArgs ( array $ ini ): array
177236 {
178- if (! extension_loaded ( ' Zend OPcache ' )) {
237+ if (self :: isIniOn ( $ ini [ ' opcache.file_cache_only ' ] ?? false )) {
179238 return [];
180239 }
240+ $ preload = $ ini ['opcache.preload ' ] ?? false ;
241+ if ($ preload !== false && $ preload !== '' ) {
242+ return [];
243+ }
244+
245+ $ memory = max (self ::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT , self ::iniInt ($ ini ['opcache.memory_consumption ' ] ?? false ));
246+ $ internedStrings = max (self ::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT , self ::iniInt ($ ini ['opcache.interned_strings_buffer ' ] ?? false ));
247+ if ($ internedStrings >= $ memory ) {
248+ $ memory = $ internedStrings + self ::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT - self ::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT ;
249+ }
250+ $ files = max (self ::OPCACHE_MAX_ACCELERATED_FILES_LIMIT , self ::iniInt ($ ini ['opcache.max_accelerated_files ' ] ?? false ));
181251
182252 return [
183253 'opcache.enable=1 ' ,
@@ -186,10 +256,23 @@ private static function getOpcacheArgs(): array
186256 'opcache.jit_buffer_size=0 ' ,
187257 'opcache.validate_timestamps=0 ' ,
188258 'opcache.file_update_protection=0 ' ,
189- 'opcache.memory_consumption= ' . self ::OPCACHE_MEMORY_CONSUMPTION_MB_LIMIT ,
190- 'opcache.interned_strings_buffer= ' . self ::OPCACHE_INTERNED_STRINGS_BUFFER_MB_LIMIT ,
191- 'opcache.max_accelerated_files= ' . self ::OPCACHE_MAX_ACCELERATED_FILES_LIMIT ,
259+ 'opcache.max_file_size=0 ' ,
260+ 'opcache.file_cache= ' ,
261+ 'opcache.save_comments=1 ' ,
262+ 'opcache.memory_consumption= ' . $ memory ,
263+ 'opcache.interned_strings_buffer= ' . $ internedStrings ,
264+ 'opcache.max_accelerated_files= ' . $ files ,
192265 ];
193266 }
194267
268+ private static function isIniOn (string |false $ value ): bool
269+ {
270+ return $ value !== false && $ value !== '' && $ value !== '0 ' ;
271+ }
272+
273+ private static function iniInt (string |false $ value ): int
274+ {
275+ return $ value === false ? 0 : (int ) $ value ;
276+ }
277+
195278}
0 commit comments