Skip to content

Commit 2fe72c8

Browse files
SanderMullerclaude
andcommitted
Keep PHPStan's compiled code in a persistent OPcache file cache
The turbo restart activates OPcache for one process only, so every run compiles PHPStan's 2000+ files again. Running from the phar, the restart now also points opcache.file_cache at a private directory under the system temp dir, keyed by the phar signature, the turbo binary and --debug, and turns timestamp validation on so that neither an update nor an edited bootstrap file is served stale. The directory must be owned by the user and not writable by others. There is no file cache in CI, where each job usually starts with an empty temp dir, and none on Windows, where each spawned worker has its own opcache.cache_id and so its own file cache. The turbo extension skips its trusted-types pass while a file cache is set; diagnose now says so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 702fde2 commit 2fe72c8

4 files changed

Lines changed: 405 additions & 20 deletions

File tree

‎src/Process/ProcessHelper.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ public static function getWorkerCommand(
135135
* the same from them - optimized opcodes, interned strings, the
136136
* inheritance cache - and, with the turbo extension active in a phar,
137137
* the optimizer pass dropping PHPStan's own run-time type checks, which
138-
* exists only inside OPcache. Without them, a worker on a pcntl host
138+
* exists only inside OPcache (and is skipped while opcache.file_cache is
139+
* set). Without them, a worker on a pcntl host
139140
* re-executed itself through TurboProcessRestarter to get OPcache: one
140141
* exec more per worker, and one that rebuilt the command line from
141142
* scratch, dropping the sys_temp_dir and extension entries of the spawn.

‎src/Turbo/TurboDiagnoseExtension.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PHPStan\DependencyInjection\AutowiredService;
77
use PHPStan\Diagnose\DiagnoseExtension;
88
use PHPStan\Php\PhpVersion;
9+
use function ini_get;
910
use function php_uname;
1011
use function phpversion;
1112
use function sprintf;
@@ -79,6 +80,10 @@ private function describeTrustedTypes(): string
7980
if (!TurboExtensionEnabler::isActive()) {
8081
return 'off (extension inactive)';
8182
}
83+
$fileCache = ini_get('opcache.file_cache');
84+
if ($fileCache !== false && $fileCache !== '') {
85+
return 'off (the extension skips it while opcache.file_cache is set)';
86+
}
8287

8388
return 'off (--debug, or OPcache is not active)';
8489
}

‎src/Turbo/TurboProcessRestarter.php‎

Lines changed: 250 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,45 @@
22

33
namespace PHPStan\Turbo;
44

5+
use FilesystemIterator;
6+
use Phar;
7+
use RecursiveDirectoryIterator;
8+
use RecursiveIteratorIterator;
9+
use Throwable;
10+
use function class_exists;
511
use function explode;
612
use function extension_loaded;
13+
use function filemtime;
14+
use function fileowner;
15+
use function fileperms;
16+
use function filesize;
717
use function function_exists;
818
use function get_cfg_var;
19+
use function getenv;
20+
use function implode;
921
use function in_array;
1022
use function ini_get;
23+
use function is_dir;
24+
use function is_link;
1125
use function is_string;
1226
use function max;
27+
use function mkdir;
1328
use function pcntl_exec;
1429
use 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;
1535
use function strtolower;
36+
use function substr;
37+
use function sys_get_temp_dir;
38+
use function time;
39+
use function touch;
1640
use function trim;
41+
use function unlink;
1742
use 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

Comments
 (0)