Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/Command/FixerApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,6 +101,7 @@ public function __construct(
private HttpClientFactory $httpClientFactory,
private ForkParallelChecker $forkParallelChecker,
private FixerWorkerRunner $fixerWorkerRunner,
private PharForkPreparation $pharForkPreparation,
)
{
}
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/Parallel/ParallelAnalyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
104 changes: 104 additions & 0 deletions src/Parallel/PharForkPreparation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php declare(strict_types = 1);

namespace PHPStan\Parallel;

use FilesystemIterator;
use Phar;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\ShouldNotHappenException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use function getmypid;
use function is_dir;
use function mkdir;
use function register_shutdown_function;
use function rmdir;
use function sprintf;
use function stream_wrapper_register;
use function stream_wrapper_unregister;
use function sys_get_temp_dir;
use function uniqid;
use function unlink;

/**
* Prepares a forked-worker run so that lazy phar:// reads in the children
* don't race on the shared phar file descriptor.
*
* When PHPStan runs from a .phar (the composer-distributed case), every
* phar://… access goes through libphar's internally-cached fd. After
* pcntl_fork() that fd is shared between parent and all forked children — the
* shared file-offset cursor means concurrent lazy class loads in different
* workers read garbage offsets and trigger spurious parse errors.
*
* The fix here: extract the phar to a fresh tmp directory in the parent
* **before** any forking, and swap PHP's built-in phar:// wrapper for
* {@see PharRedirectStreamWrapper}, which serves every subsequent phar://
* request from that on-disk extraction. Children then open ordinary files
* with their own fds — no shared OFD, no race — while autoload (and any
* stat/file_get_contents that uses phar://) keeps working transparently.
*
* No-op when not running inside a phar; called by ParallelAnalyser /
* FixerApplication right before they fork their workers. Idempotent — only
* the first call actually extracts.
*/
#[AutowiredService]
final class PharForkPreparation
{

private bool $prepared = false;

public function prepare(): void
{
if ($this->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);
}

}
Loading
Loading