From fdb90bf31ecaf95e096f7ff409256d1047fd4e21 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 15 May 2026 11:06:08 +0200 Subject: [PATCH 1/3] Lock Composer autoload around phar:// reads in forked workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternative approach to making the experimental pcntl_fork() worker path work when PHPStan is run from a .phar. Where #5669 extracts the phar before forking and reroutes every phar:// read to disk, this takes the opposite tack: leave PHP's built-in phar:// wrapper in place but serialise the racy operation. PHP's built-in phar:// stream wrapper caches a single fd for the running .phar; after pcntl_fork() that fd's open file description (and its seek cursor) is shared between parent and every forked child, so concurrent lazy class loads across workers can interleave and read garbage offsets — surfacing as spurious parse errors against phar-internal files. PharAutoloaderLock::install() wraps every registered Composer ClassLoader so loadClass() acquires an exclusive flock on a tmp file before its `include` and releases it after. Two workers can never hold the lock simultaneously, so they can never be inside `include 'phar://…'` simultaneously, so the cursor can't be moved out from under either of them. Cost: per-class load takes one flock pair. Each worker contends with siblings only during its initial "loading wave" — a few hundred classes touched once. After its symbol table is populated the lock is never taken again and workers run fully parallel for the rest of the analysis. Covers only class autoload. Non-class phar reads (file_get_contents('phar://…'), stat, dir iteration) still go through the unlocked built-in wrapper; in practice those happen during boot, which is pre-fork, so they don't race. ParallelAnalyser and FixerApplication call the (idempotent) install() once they have decided to take the fork path. No-op when not running inside a phar. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Command/FixerApplication.php | 9 ++- src/Parallel/ParallelAnalyser.php | 4 ++ src/Parallel/PharAutoloaderLock.php | 100 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/Parallel/PharAutoloaderLock.php diff --git a/src/Command/FixerApplication.php b/src/Command/FixerApplication.php index d0deb1607a..800d79f490 100644 --- a/src/Command/FixerApplication.php +++ b/src/Command/FixerApplication.php @@ -25,6 +25,7 @@ use PHPStan\Internal\DirectoryCreatorException; use PHPStan\Internal\HttpClientFactory; use PHPStan\Parallel\ForkParallelChecker; +use PHPStan\Parallel\PharAutoloaderLock; use PHPStan\PhpDoc\StubFilesProvider; use PHPStan\Process\ForkedProcessPromise; use PHPStan\Process\ProcessCanceledException; @@ -100,6 +101,7 @@ public function __construct( private HttpClientFactory $httpClientFactory, private ForkParallelChecker $forkParallelChecker, private FixerWorkerRunner $fixerWorkerRunner, + private PharAutoloaderLock $pharAutoloaderLock, ) { } @@ -457,8 +459,13 @@ private function analyse( }); }); + $useFork = $this->forkParallelChecker->isSupported(); + if ($useFork) { + $this->pharAutoloaderLock->install(); + } + $process = $this->createProcessPromise( - $this->forkParallelChecker->isSupported(), + $useFork, $loop, $server, $mainScript, diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index c5be7a4e54..5ba28f70d1 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -54,6 +54,7 @@ public function __construct( private int $decoderBufferSize, private ForkParallelChecker $forkParallelChecker, private WorkerRunner $workerRunner, + private PharAutoloaderLock $pharAutoloaderLock, ) { $this->processTimeout = max($processTimeout, self::DEFAULT_TIMEOUT); @@ -175,6 +176,9 @@ public function analyse( }; $useFork = $this->forkParallelChecker->isSupported(); + if ($useFork) { + $this->pharAutoloaderLock->install(); + } for ($i = 0; $i < $numberOfProcesses; $i++) { if (count($jobs) === 0) { diff --git a/src/Parallel/PharAutoloaderLock.php b/src/Parallel/PharAutoloaderLock.php new file mode 100644 index 0000000000..4717662b4e --- /dev/null +++ b/src/Parallel/PharAutoloaderLock.php @@ -0,0 +1,100 @@ +installed) { + return; + } + $this->installed = true; + + if (Phar::running(false) === '') { + return; + } + + $lockPath = sys_get_temp_dir() . '/phpstan-fork-phar-lock-' . getmypid() . '-' . uniqid(); + touch($lockPath); + + foreach (ClassLoader::getRegisteredLoaders() as $loader) { + $loader->unregister(); + spl_autoload_register(static function (string $class) use ($loader, $lockPath): void { + $fh = fopen($lockPath, 'r'); + if ($fh === false) { + $loader->loadClass($class); + return; + } + flock($fh, LOCK_EX); + try { + $loader->loadClass($class); + } finally { + flock($fh, LOCK_UN); + fclose($fh); + } + }); + } + + $parentPid = getmypid(); + register_shutdown_function(static function () use ($parentPid, $lockPath): void { + if (getmypid() !== $parentPid) { + return; + } + @unlink($lockPath); + }); + } + +} From 9a6d6f537a5362dc8480151542255d9704b85bcb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 15 May 2026 11:27:23 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Wrap=20autoloaders=20via=20spl=5Fautoload?= =?UTF-8?q?=5Ffunctions()=20=E2=80=94=20no=20Composer=20namespace=20refere?= =?UTF-8?q?nce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Referencing Composer\Autoload\ClassLoader makes php-scoper rewrite it to the prefixed _PHPStan_xxx\Composer\Autoload\ClassLoader form when building the phar, but Composer's autoloader is exempted from prefixing by the scoper patcher whitelist in compiler/build/scoper.inc.php (it lives at the unscoped name at runtime). So the prefixed reference resolves to a non-existent class and PharAutoloaderLock::install() fatals on first call. Switch to spl_autoload_functions() / spl_autoload_register / spl_autoload_unregister, working with the registered callables directly. No reference to Composer's namespace, no dependency on the scoper patcher's whitelist, no Composer-version assumptions (getRegisteredLoaders() is 2.0+). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Parallel/PharAutoloaderLock.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Parallel/PharAutoloaderLock.php b/src/Parallel/PharAutoloaderLock.php index 4717662b4e..b7cc6737fa 100644 --- a/src/Parallel/PharAutoloaderLock.php +++ b/src/Parallel/PharAutoloaderLock.php @@ -2,7 +2,6 @@ namespace PHPStan\Parallel; -use Composer\Autoload\ClassLoader; use Phar; use PHPStan\DependencyInjection\AutowiredService; use function fclose; @@ -10,7 +9,9 @@ use function fopen; use function getmypid; use function register_shutdown_function; +use function spl_autoload_functions; use function spl_autoload_register; +use function spl_autoload_unregister; use function sys_get_temp_dir; use function touch; use function uniqid; @@ -70,17 +71,22 @@ public function install(): void $lockPath = sys_get_temp_dir() . '/phpstan-fork-phar-lock-' . getmypid() . '-' . uniqid(); touch($lockPath); - foreach (ClassLoader::getRegisteredLoaders() as $loader) { - $loader->unregister(); - spl_autoload_register(static function (string $class) use ($loader, $lockPath): void { + // Wrap every registered autoloader callable. Using spl_autoload_* + // rather than Composer\Autoload\ClassLoader::getRegisteredLoaders() + // keeps this file free of any reference to Composer's namespace — + // php-scoper would otherwise rewrite that reference to the prefixed + // form, which does not exist at runtime inside the built phar. + foreach (spl_autoload_functions() as $callback) { + spl_autoload_unregister($callback); + spl_autoload_register(static function (string $class) use ($callback, $lockPath): void { $fh = fopen($lockPath, 'r'); if ($fh === false) { - $loader->loadClass($class); + $callback($class); return; } flock($fh, LOCK_EX); try { - $loader->loadClass($class); + $callback($class); } finally { flock($fh, LOCK_UN); fclose($fh); From b039eba458cdd3cbafd6f498a1f84780c912de5f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 15 May 2026 12:27:38 +0200 Subject: [PATCH 3/3] Track re-entrancy in PharAutoloaderLock to avoid self-deadlock on nested autoload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP class declaration can trigger a nested autoload inside the declaration itself — `class Foo extends Bar` autoloads Bar mid-bind. With each call doing its own `fopen` + `flock(LOCK_EX)`, the nested call opens a separate OFD against the same lockfile and tries to acquire LOCK_EX while the outer call holds it; flock is per-OFD on BSD/macOS, so the same process self-deadlocks. Symptom: 0/N files progress and every worker stuck inside `zif_flock` according to sample(1). The race that actually matters is cross-process — within one process, libphar reads are sequential by construction, so the nested call doesn't need a second lock. Track depth per-process: outermost call takes the flock, nested ones (depth > 0) skip straight to the delegate. Cross-process exclusion is preserved because the static counter is per-process (fork COWs its own copy). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Parallel/PharAutoloaderLock.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/Parallel/PharAutoloaderLock.php b/src/Parallel/PharAutoloaderLock.php index b7cc6737fa..2ddaf5d1d8 100644 --- a/src/Parallel/PharAutoloaderLock.php +++ b/src/Parallel/PharAutoloaderLock.php @@ -57,6 +57,19 @@ final class PharAutoloaderLock private bool $installed = false; + /** + * Re-entrancy depth for the lock-protected autoload section, per process. + * + * PHP class declarations can trigger nested autoloads (declaring `class + * Foo extends Bar` autoloads Bar mid-declaration), so the wrapper can be + * called recursively in a single process. `flock` is per-OFD on BSD/macOS, + * so two `fopen` + `LOCK_EX` calls from the same process on the same file + * self-deadlock. The race we actually care about is cross-process — only + * the outermost call needs to take the lock; nested ones are already + * inside the critical section. + */ + private static int $depth = 0; + public function install(): void { if ($this->installed) { @@ -79,15 +92,25 @@ public function install(): void foreach (spl_autoload_functions() as $callback) { spl_autoload_unregister($callback); spl_autoload_register(static function (string $class) use ($callback, $lockPath): void { + if (self::$depth > 0) { + // Already inside this process's locked autoload — nested + // reads from libphar are sequential within a single + // process, so they don't need (and would deadlock on) a + // second flock. + $callback($class); + return; + } $fh = fopen($lockPath, 'r'); if ($fh === false) { $callback($class); return; } flock($fh, LOCK_EX); + self::$depth++; try { $callback($class); } finally { + self::$depth--; flock($fh, LOCK_UN); fclose($fh); }