22
33namespace PHPStan \Turbo ;
44
5+ use FilesystemIterator ;
6+ use Phar ;
7+ use RecursiveDirectoryIterator ;
8+ use RecursiveIteratorIterator ;
9+ use Throwable ;
10+ use function class_exists ;
511use function explode ;
612use function extension_loaded ;
13+ use function filemtime ;
14+ use function fileowner ;
15+ use function fileperms ;
16+ use function filesize ;
717use function function_exists ;
818use function get_cfg_var ;
19+ use function getenv ;
20+ use function implode ;
921use function in_array ;
1022use function ini_get ;
23+ use function is_dir ;
24+ use function is_link ;
1125use function is_string ;
1226use function max ;
27+ use function mkdir ;
1328use function pcntl_exec ;
1429use function php_ini_loaded_file ;
30+ use function phpversion ;
31+ use function posix_geteuid ;
32+ use function rmdir ;
33+ use function scandir ;
34+ use function sha1 ;
1535use function strtolower ;
36+ use function substr ;
37+ use function sys_get_temp_dir ;
38+ use function time ;
39+ use function touch ;
1640use function trim ;
41+ use function unlink ;
1742use const PHP_BINARY ;
43+ use const PHP_OS_FAMILY ;
1844
1945/**
2046 * Restarts the main PHPStan process via pcntl_exec() when the process it
@@ -67,6 +93,9 @@ final class TurboProcessRestarter
6793
6894 private const OPCACHE_MAX_ACCELERATED_FILES_LIMIT = 20000 ;
6995
96+ /** A file cache directory that no run has used for this long is deleted when a new one is created */
97+ private const OPCACHE_FILE_CACHE_UNUSED_SECONDS_LIMIT = 7 * 24 * 60 * 60 ;
98+
7099 /** PHP's default opcache.optimization_level, pinned so the optimizer (and the extension's pass in it) always runs */
71100 private const OPCACHE_OPTIMIZATION_LEVEL = '0x7FFEBFFF ' ;
72101
@@ -79,6 +108,17 @@ final class TurboProcessRestarter
79108 'opcache.max_accelerated_files ' ,
80109 ];
81110
111+ /**
112+ * Environment variables that CI services set (to a non-empty value other
113+ * than "false"): a CI job usually starts with an empty temp dir, so a file
114+ * cache there costs its writes on every run and is never read
115+ */
116+ private const CI_ENVIRONMENT_VARIABLES = ['CI ' , 'GITHUB_ACTIONS ' , 'GITLAB_CI ' , 'BUILDKITE ' , 'TF_BUILD ' , 'JENKINS_URL ' , 'TEAMCITY_VERSION ' ];
117+
118+ private static ?string $ fileCacheDirectory = null ;
119+
120+ private static bool $ fileCacheDirectoryResolved = false ;
121+
82122 /**
83123 * The extension path this process was given through -d — by the restart,
84124 * or by ProcessHelper when spawned as a worker. Null when the extension
@@ -245,7 +285,181 @@ public static function getOpcacheArgs(): array
245285 $ ini [$ name ] = ini_get ($ name );
246286 }
247287
248- return self ::resolveOpcacheArgs ($ ini );
288+ return self ::resolveOpcacheArgs ($ ini , self ::getFileCacheDirectory ());
289+ }
290+
291+ /**
292+ * The directory for a persistent OPcache file cache, created if needed —
293+ * null when there should be none. See resolveOpcacheArgs() for why the
294+ * cache is safe to keep between runs.
295+ *
296+ * There is one directory per user under the system temp dir, and in it
297+ * one per key (see resolveFileCacheKey()). The directories must be owned
298+ * by this user and not writable by anyone else: whatever is in them runs
299+ * as opcodes, unchecked, inside PHPStan.
300+ *
301+ * Only runs from the phar get one: a source checkout changes all the time
302+ * and has no build to key the directory by. Not in CI (see
303+ * resolveContinuousIntegration()): an empty temp dir at the start of every
304+ * job would make the cache pure cost, and with a file cache the
305+ * extension's trusted-types pass is off. Not on Windows either: every
306+ * spawned worker there gets its own opcache.cache_id (see ProcessHelper),
307+ * and OPcache then keeps a separate file cache per worker that no later
308+ * run reuses: 2 GB after one benchmark run on a GitHub runner, and cold
309+ * runs 27-50% slower.
310+ */
311+ private static function getFileCacheDirectory (): ?string
312+ {
313+ if (self ::$ fileCacheDirectoryResolved ) {
314+ return self ::$ fileCacheDirectory ;
315+ }
316+
317+ self ::$ fileCacheDirectoryResolved = true ;
318+ if (PHP_OS_FAMILY === 'Windows ' || !function_exists ('posix_geteuid ' ) || !class_exists ('Phar ' , false )) {
319+ return null ;
320+ }
321+ if (self ::resolveContinuousIntegration (getenv ())) {
322+ return null ;
323+ }
324+
325+ $ pharPath = Phar::running (false );
326+ if ($ pharPath === '' ) {
327+ return null ;
328+ }
329+
330+ try {
331+ $ signature = (new Phar ($ pharPath ))->getSignature ();
332+ } catch (Throwable ) {
333+ return null ;
334+ }
335+
336+ $ argv = $ _SERVER ['argv ' ] ?? [];
337+ $ key = self ::resolveFileCacheKey ($ signature ['hash ' ], self ::describeTurboBinary (), in_array ('--debug ' , $ argv , true ));
338+
339+ $ userId = posix_geteuid ();
340+ $ baseDirectory = sys_get_temp_dir () . '/phpstan-opcache- ' . $ userId ;
341+ $ directory = $ baseDirectory . '/ ' . $ key ;
342+ $ created = !is_dir ($ directory );
343+ if ($ created ) {
344+ @mkdir ($ directory , 0700 , true );
345+ }
346+ if (!self ::isPrivateDirectory ($ baseDirectory , $ userId ) || !self ::isPrivateDirectory ($ directory , $ userId )) {
347+ return null ;
348+ }
349+
350+ // the mtime marks the directory as in use, for the pruning below
351+ @touch ($ directory );
352+ if ($ created ) {
353+ self ::pruneFileCacheDirectories ($ baseDirectory , $ key , time ());
354+ }
355+
356+ return self ::$ fileCacheDirectory = $ directory ;
357+ }
358+
359+ /**
360+ * Everything that changes the opcodes compiled out of the same phar on the
361+ * same PHP build (OPcache itself separates builds): which extension binary
362+ * is loaded, and --debug, which keeps the type checks the extension's
363+ * optimizer pass drops (TurboExtensionEnabler::trustOwnTypesIfSuitable()).
364+ * The extension refuses that pass while a file cache is configured, since
365+ * stripped opcodes would outlive the run, so today it is off in every run
366+ * with a file cache. Keying by both keeps the states apart if that ever
367+ * changes.
368+ *
369+ * @param string $turboBinary see describeTurboBinary()
370+ */
371+ public static function resolveFileCacheKey (string $ pharSignature , string $ turboBinary , bool $ debug ): string
372+ {
373+ return substr (sha1 (implode ("\0" , [$ pharSignature , $ turboBinary , $ debug ? 'debug ' : '' ])), 0 , 16 );
374+ }
375+
376+ /**
377+ * @param array<string, string> $environment getenv()
378+ */
379+ public static function resolveContinuousIntegration (array $ environment ): bool
380+ {
381+ foreach (self ::CI_ENVIRONMENT_VARIABLES as $ name ) {
382+ $ value = $ environment [$ name ] ?? '' ;
383+ if ($ value !== '' && strtolower ($ value ) !== 'false ' ) {
384+ return true ;
385+ }
386+ }
387+
388+ return false ;
389+ }
390+
391+ /**
392+ * The same answer before the restart and in the restarted process: the
393+ * binary the restart loads with -d, or the version of one loaded by the
394+ * php.ini, or none.
395+ */
396+ private static function describeTurboBinary (): string
397+ {
398+ $ path = self ::getRestartExtensionPath ();
399+ if ($ path === null && !extension_loaded ('phpstan_turbo ' )) {
400+ $ path = TurboExtensionSelector::findExtension ();
401+ }
402+ if ($ path !== null ) {
403+ return 'binary: ' . $ path . ': ' . @filesize ($ path ) . ': ' . @filemtime ($ path );
404+ }
405+ if (extension_loaded ('phpstan_turbo ' )) {
406+ return 'ini: ' . phpversion ('phpstan_turbo ' );
407+ }
408+
409+ return 'none ' ;
410+ }
411+
412+ public static function isPrivateDirectory (string $ directory , int $ userId ): bool
413+ {
414+ if (is_link ($ directory ) || !is_dir ($ directory )) {
415+ return false ;
416+ }
417+
418+ $ permissions = @fileperms ($ directory );
419+
420+ return @fileowner ($ directory ) === $ userId && $ permissions !== false && ($ permissions & 0022 ) === 0 ;
421+ }
422+
423+ /**
424+ * Deletes the other key directories that no run has used for
425+ * OPCACHE_FILE_CACHE_UNUSED_SECONDS_LIMIT — the caches of PHPStan versions
426+ * no longer installed. Runs only when a new key directory was created, so
427+ * about once per update.
428+ */
429+ public static function pruneFileCacheDirectories (string $ baseDirectory , string $ currentKey , int $ now ): void
430+ {
431+ $ entries = @scandir ($ baseDirectory );
432+ if ($ entries === false ) {
433+ return ;
434+ }
435+
436+ foreach ($ entries as $ entry ) {
437+ if ($ entry === '. ' || $ entry === '.. ' || $ entry === $ currentKey ) {
438+ continue ;
439+ }
440+ $ directory = $ baseDirectory . '/ ' . $ entry ;
441+ if (is_link ($ directory ) || !is_dir ($ directory )) {
442+ continue ;
443+ }
444+ $ mtime = @filemtime ($ directory );
445+ if ($ mtime === false || $ now - $ mtime < self ::OPCACHE_FILE_CACHE_UNUSED_SECONDS_LIMIT ) {
446+ continue ;
447+ }
448+
449+ try {
450+ $ files = new RecursiveIteratorIterator (new RecursiveDirectoryIterator ($ directory , FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST );
451+ foreach ($ files as $ file ) {
452+ if ($ file ->isDir () && !$ file ->isLink ()) {
453+ @rmdir ($ file ->getPathname ());
454+ } else {
455+ @unlink ($ file ->getPathname ());
456+ }
457+ }
458+ } catch (Throwable ) {
459+ continue ;
460+ }
461+ @rmdir ($ directory );
462+ }
249463 }
250464
251465 /**
@@ -274,14 +488,16 @@ public static function getOpcacheArgs(): array
274488 * (https://php.watch/versions/8.4/opcache-jit-ini-default-changes).
275489 * Pinning both directives covers both generations of defaults.
276490 *
277- * Timestamp checks are switched off, or nothing of PHPStan itself would be
278- * cached: opcache_compile_file() refuses any file whose mtime it reads as
279- * 0, which is what every member of the distributed phar carried until the
280- * build started stamping them (phar.yml). It reads the mtime whenever
281- * opcache.validate_timestamps, opcache.file_update_protection or
282- * opcache.max_file_size is on, so all three go — for a private cache that
283- * dies with the process they revalidate nothing anyway, and skipping them
284- * also drops a stat() per include. The uncached state is what made
491+ * Without a file cache, timestamp checks are switched off: for a private
492+ * cache that dies with the process they revalidate nothing, and skipping
493+ * them drops a stat() per include. They also used to be the difference
494+ * between caching PHPStan and not: opcache_compile_file() refuses any file
495+ * whose mtime it reads as 0, which is what every member of the
496+ * distributed phar carried until the build started stamping them
497+ * (phar.yml, and compiler/build/resign.php fails on a member left at 0).
498+ * It reads the mtime whenever opcache.validate_timestamps,
499+ * opcache.file_update_protection or opcache.max_file_size is on. The
500+ * uncached state is what made
285501 * OPcache *slower* than no OPcache for phar runs: code compiled under an
286502 * active OPcache but not persisted never gets its strings interned into
287503 * SHM, so its type names have no class-entry cache slot and every
@@ -302,14 +518,28 @@ public static function getOpcacheArgs(): array
302518 * interned strings buffer is kept below the memory it is carved out of
303519 * (another fatal startup error otherwise).
304520 *
305- * That private cache must neither outlive the process nor reach outside
521+ * Running from the phar, the opcodes also go to a persistent file cache
522+ * in a directory of PHPStan's own (see getFileCacheDirectory()), so the
523+ * next run loads PHPStan instead of compiling it again: a warm run on a
524+ * small project takes about half the time. The extension's trusted-types
525+ * pass stays off while a file cache is configured (see
526+ * resolveFileCacheKey()); on a large project that cost and the saved
527+ * compilation about cancel out. A file cache is validated by
528+ * the PHP build id and, only with opcache.validate_timestamps, the mtime,
529+ * so the checks are on in that case, at PHP's defaults: without them it
530+ * would serve the previous PHPStan's opcodes after an update (the phar
531+ * path being the same), and a project's bootstrap file as it was before
532+ * an edit. opcache.file_update_protection keeps a file changed in the
533+ * last seconds out of the cache, because the mtime has a resolution of
534+ * one second. The directory is keyed by what else changes the compiled
535+ * code (resolveFileCacheKey()).
536+ *
537+ * Otherwise the cache must neither outlive the process nor reach outside
306538 * it, which is what the remaining entries guard against in a php.ini tuned
307539 * for the web server rather than for us:
308- * - opcache.file_cache is blanked. A file cache is validated by the PHP
309- * build id and (only with opcache.validate_timestamps) the mtime — so
310- * with the checks off it would keep serving the previous PHPStan's
311- * opcodes after an update, the phar path being the same, and it would
312- * fill the web server's cache directory with this run's scripts.
540+ * - opcache.file_cache is set to that directory, or blanked. The web
541+ * server's own file cache directory would fill with this run's scripts,
542+ * and it is validated with whatever that php.ini says.
313543 * - opcache.save_comments is pinned on: stripping doc comments (a common
314544 * web tuning) breaks annotation readers in the project code the
315545 * extensions bootstrap, which worked with OPcache dormant.
@@ -328,9 +558,10 @@ public static function getOpcacheArgs(): array
328558 * (the directive rejects an empty value).
329559 *
330560 * @param array<string, string|false> $ini
561+ * @param string|null $fileCacheDirectory see getFileCacheDirectory()
331562 * @return list<string>
332563 */
333- public static function resolveOpcacheArgs (array $ ini ): array
564+ public static function resolveOpcacheArgs (array $ ini, ? string $ fileCacheDirectory = null ): array
334565 {
335566 if (self ::isIniOn ($ ini ['opcache.file_cache_only ' ] ?? false )) {
336567 return [];
@@ -352,10 +583,10 @@ public static function resolveOpcacheArgs(array $ini): array
352583 'opcache.enable_cli=1 ' ,
353584 'opcache.jit=disable ' ,
354585 'opcache.jit_buffer_size=0 ' ,
355- 'opcache.validate_timestamps=0 ' ,
356- 'opcache.file_update_protection=0 ' ,
586+ 'opcache.validate_timestamps= ' . ( $ fileCacheDirectory !== null ? ' 1 ' : ' 0 ' ) ,
587+ 'opcache.file_update_protection= ' . ( $ fileCacheDirectory !== null ? ' 2 ' : ' 0 ' ) ,
357588 'opcache.max_file_size=0 ' ,
358- 'opcache.file_cache= ' ,
589+ 'opcache.file_cache= ' . ( $ fileCacheDirectory ?? '' ) ,
359590 'opcache.save_comments=1 ' ,
360591 'opcache.optimization_level= ' . self ::OPCACHE_OPTIMIZATION_LEVEL ,
361592 'opcache.memory_consumption= ' . $ memory ,
0 commit comments