From 99eef0207c68304b24f2689ff583db10267eb6ef Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 15 May 2026 10:40:21 +0200 Subject: [PATCH 1/2] Make fork-mode work when PHPStan runs from a .phar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP's built-in phar:// stream wrapper caches a single fd for the running .phar. After pcntl_fork() that fd's open file description is shared between parent and all forked children, so concurrent lazy class loads in different workers race on the shared seek cursor and read garbage offsets — surfacing as spurious parse errors at "almost valid" positions in phpstan.phar-internal files. This adds PharForkPreparation: before forking, when running inside a phar, it extracts the phar to a fresh tmp directory and registers PharRedirectStreamWrapper as the phar:// handler. Every phar://… read in the children is then transparently rerouted to the on-disk copy — each open is an ordinary fopen() with its own fd, so there is no shared cursor and no race. Autoload, file_get_contents, stat, directory iteration all keep working through the wrapper. ParallelAnalyser and FixerApplication call the (idempotent) prepare() once they have decided to take the fork path. The extracted directory is cleaned up on parent shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Command/FixerApplication.php | 9 +- src/Parallel/ParallelAnalyser.php | 4 + src/Parallel/PharForkPreparation.php | 103 ++++++++++ src/Parallel/PharRedirectStreamWrapper.php | 226 +++++++++++++++++++++ 4 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 src/Parallel/PharForkPreparation.php create mode 100644 src/Parallel/PharRedirectStreamWrapper.php diff --git a/src/Command/FixerApplication.php b/src/Command/FixerApplication.php index d0deb1607a..3993cf4318 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\PharForkPreparation; 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 PharForkPreparation $pharForkPreparation, ) { } @@ -457,8 +459,13 @@ private function analyse( }); }); + $useFork = $this->forkParallelChecker->isSupported(); + if ($useFork) { + $this->pharForkPreparation->prepare(); + } + $process = $this->createProcessPromise( - $this->forkParallelChecker->isSupported(), + $useFork, $loop, $server, $mainScript, diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index c5be7a4e54..af19d7c57f 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 PharForkPreparation $pharForkPreparation, ) { $this->processTimeout = max($processTimeout, self::DEFAULT_TIMEOUT); @@ -175,6 +176,9 @@ public function analyse( }; $useFork = $this->forkParallelChecker->isSupported(); + if ($useFork) { + $this->pharForkPreparation->prepare(); + } for ($i = 0; $i < $numberOfProcesses; $i++) { if (count($jobs) === 0) { diff --git a/src/Parallel/PharForkPreparation.php b/src/Parallel/PharForkPreparation.php new file mode 100644 index 0000000000..0b46996408 --- /dev/null +++ b/src/Parallel/PharForkPreparation.php @@ -0,0 +1,103 @@ +prepared) { + return; + } + $this->prepared = true; + + $pharPath = Phar::running(false); + if ($pharPath === '') { + return; + } + + $extractDir = sys_get_temp_dir() . '/phpstan-fork-phar-' . getmypid() . '-' . uniqid(); + if (!mkdir($extractDir, 0700, true) && !is_dir($extractDir)) { + throw new ShouldNotHappenException(sprintf('Failed creating phar-extract directory %s.', $extractDir)); + } + + $phar = new Phar($pharPath); + $phar->extractTo($extractDir, null, true); + + PharRedirectStreamWrapper::configure($pharPath, $extractDir); + stream_wrapper_unregister('phar'); + stream_wrapper_register('phar', PharRedirectStreamWrapper::class); + + $parentPid = getmypid(); + register_shutdown_function(static function () use ($parentPid, $extractDir): void { + if (getmypid() !== $parentPid) { + // Forked children must not nuke the directory the parent still needs. + return; + } + self::removeDirectory($extractDir); + }); + } + + private static function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + if ($entry->isDir()) { + rmdir($entry->getPathname()); + } else { + unlink($entry->getPathname()); + } + } + rmdir($dir); + } + +} diff --git a/src/Parallel/PharRedirectStreamWrapper.php b/src/Parallel/PharRedirectStreamWrapper.php new file mode 100644 index 0000000000..0d64eb325e --- /dev/null +++ b/src/Parallel/PharRedirectStreamWrapper.php @@ -0,0 +1,226 @@ +translate($path); + if ($real === null) { + return false; + } + $useIncludePath = ($options & STREAM_USE_PATH) !== 0; + $report = ($options & STREAM_REPORT_ERRORS) !== 0; + $fp = $report ? fopen($real, $mode, $useIncludePath) : @fopen($real, $mode, $useIncludePath); + if ($fp === false) { + return false; + } + $this->fp = $fp; + $opened_path = $real; + + return true; + } + + public function stream_read(int $count): string|false + { + if ($this->fp === null || $count < 1) { + return false; + } + + return fread($this->fp, $count); + } + + public function stream_close(): void + { + if ($this->fp === null) { + return; + } + fclose($this->fp); + $this->fp = null; + } + + public function stream_eof(): bool + { + if ($this->fp === null) { + return true; + } + + return feof($this->fp); + } + + public function stream_seek(int $offset, int $whence = SEEK_SET): bool + { + if ($this->fp === null) { + return false; + } + + return fseek($this->fp, $offset, $whence) === 0; + } + + public function stream_tell(): int|false + { + if ($this->fp === null) { + return false; + } + + return ftell($this->fp); + } + + /** + * @return array|false + */ + public function stream_stat(): array|false + { + if ($this->fp === null) { + return false; + } + + return fstat($this->fp); + } + + public function stream_flush(): bool + { + return true; + } + + public function stream_set_option(int $option, int $arg1, int $arg2): bool + { + return false; + } + + /** + * @return array|false + */ + public function url_stat(string $path, int $flags): array|false + { + $real = $this->translate($path); + if ($real === null) { + return false; + } + $quiet = ($flags & STREAM_URL_STAT_QUIET) !== 0; + + return $quiet ? @stat($real) : stat($real); + } + + public function dir_opendir(string $path, int $options): bool + { + $real = $this->translate($path); + if ($real === null || !is_dir($real)) { + return false; + } + try { + $this->dirIterator = new FilesystemIterator( + $real, + FilesystemIterator::SKIP_DOTS | FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_PATHNAME, + ); + } catch (Throwable) { + return false; + } + + return true; + } + + public function dir_readdir(): string|false + { + if ($this->dirIterator === null || !$this->dirIterator->valid()) { + return false; + } + $name = $this->dirIterator->key(); + $this->dirIterator->next(); + + return $name; + } + + public function dir_rewinddir(): bool + { + if ($this->dirIterator === null) { + return false; + } + $this->dirIterator->rewind(); + + return true; + } + + public function dir_closedir(): bool + { + $this->dirIterator = null; + + return true; + } + +} From 08447306fe34445e7e9c705b61cdba76a27683ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 15 May 2026 10:50:10 +0200 Subject: [PATCH 2/2] Recognise alias and basename forms of phar:// URLs in the redirect wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phars can be addressed three equivalent ways: by absolute path (phar:///abs/path/to/phpstan.phar/x), by their explicit alias (phar://phpstan.phar/x — what Phar::setAlias() / the build-time alias declares), or by basename (also typically the implicit alias). The redirect wrapper only knew about the absolute-path form, so anything constructing URLs via the alias — Nette's config loader does exactly this for "phar://phpstan.phar/conf/bleedingEdge.neon" — couldn't be translated and ended up "file not found". Wrapper now accepts any of the three prefixes (deduplicated; the in-flight cascade where the boundary-after-prefix-check rejects a false positive like "/foo.phar.bak" matching "/foo.phar" is kept). PharForkPreparation reads Phar::getAlias() at extraction time and passes it to ::configure(). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Parallel/PharForkPreparation.php | 3 +- src/Parallel/PharRedirectStreamWrapper.php | 43 +++++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/Parallel/PharForkPreparation.php b/src/Parallel/PharForkPreparation.php index 0b46996408..c6bf05f9fb 100644 --- a/src/Parallel/PharForkPreparation.php +++ b/src/Parallel/PharForkPreparation.php @@ -65,9 +65,10 @@ public function prepare(): void } $phar = new Phar($pharPath); + $alias = $phar->getAlias(); $phar->extractTo($extractDir, null, true); - PharRedirectStreamWrapper::configure($pharPath, $extractDir); + PharRedirectStreamWrapper::configure($pharPath, $alias, $extractDir); stream_wrapper_unregister('phar'); stream_wrapper_register('phar', PharRedirectStreamWrapper::class); diff --git a/src/Parallel/PharRedirectStreamWrapper.php b/src/Parallel/PharRedirectStreamWrapper.php index 0d64eb325e..4ff5442f76 100644 --- a/src/Parallel/PharRedirectStreamWrapper.php +++ b/src/Parallel/PharRedirectStreamWrapper.php @@ -5,7 +5,9 @@ use AllowDynamicProperties; use FilesystemIterator; use Throwable; +use function basename; use function fclose; +use function in_array; use function feof; use function fopen; use function fread; @@ -45,7 +47,14 @@ final class PharRedirectStreamWrapper { - private static ?string $pharPath = null; + /** + * Path prefixes (right after "phar://") that identify our running phar: + * its absolute path, its explicit alias and its basename — all three are + * valid ways to address the same archive. + * + * @var list + */ + private static array $prefixes = []; private static ?string $extractDir = null; @@ -54,28 +63,44 @@ final class PharRedirectStreamWrapper private ?FilesystemIterator $dirIterator = null; - public static function configure(string $pharPath, string $extractDir): void + public static function configure(string $pharPath, string $alias, string $extractDir): void { - self::$pharPath = $pharPath; + $candidates = [$pharPath, $alias, basename($pharPath)]; + $prefixes = []; + foreach ($candidates as $candidate) { + if ($candidate === '' || in_array($candidate, $prefixes, true)) { + continue; + } + $prefixes[] = $candidate; + } + self::$prefixes = $prefixes; self::$extractDir = $extractDir; } private function translate(string $pharUrl): ?string { - if (self::$pharPath === null || self::$extractDir === null) { + if (self::$extractDir === null || self::$prefixes === []) { return null; } if (strncmp($pharUrl, 'phar://', 7) !== 0) { return null; } $afterScheme = substr($pharUrl, 7); - $pharPathLen = strlen(self::$pharPath); - if (strncmp($afterScheme, self::$pharPath, $pharPathLen) !== 0) { - return null; + foreach (self::$prefixes as $prefix) { + $prefixLen = strlen($prefix); + if (strncmp($afterScheme, $prefix, $prefixLen) !== 0) { + continue; + } + $internal = substr($afterScheme, $prefixLen); + if ($internal !== '' && $internal[0] !== '/') { + // "/abs/foo.phar" must not match "phar:///abs/foo.phar.bak/x" + continue; + } + + return self::$extractDir . $internal; } - $internal = substr($afterScheme, $pharPathLen); - return self::$extractDir . $internal; + return null; } public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool