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..c6bf05f9fb --- /dev/null +++ b/src/Parallel/PharForkPreparation.php @@ -0,0 +1,104 @@ +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); + $alias = $phar->getAlias(); + $phar->extractTo($extractDir, null, true); + + PharRedirectStreamWrapper::configure($pharPath, $alias, $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..4ff5442f76 --- /dev/null +++ b/src/Parallel/PharRedirectStreamWrapper.php @@ -0,0 +1,251 @@ + + */ + private static array $prefixes = []; + + private static ?string $extractDir = null; + + /** @var resource|null */ + private $fp = null; + + private ?FilesystemIterator $dirIterator = null; + + public static function configure(string $pharPath, string $alias, string $extractDir): void + { + $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::$extractDir === null || self::$prefixes === []) { + return null; + } + if (strncmp($pharUrl, 'phar://', 7) !== 0) { + return null; + } + $afterScheme = substr($pharUrl, 7); + 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; + } + + return null; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool + { + $real = $this->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; + } + +}