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
26 changes: 26 additions & 0 deletions src/Command/AnalyseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use PHPStan\Internal\ComposerHelper;
use PHPStan\Internal\DirectoryCreator;
use PHPStan\Internal\DirectoryCreatorException;
use PHPStan\Process\PcovHelper;
use PHPStan\ShouldNotHappenException;
use PHPStan\Turbo\TurboExtensionEnabler;
use Symfony\Component\Console\Command\Command;
Expand Down Expand Up @@ -86,6 +87,13 @@ final class AnalyseCommand extends Command
*/
private const RESULT_CACHE_CI_NOTIFICATION_ELAPSED_LIMIT = 60.0;

/**
* The pcov notification is only shown when the analysis took longer than this
* many seconds. Shorter runs do not waste enough time on pcov's call hook to
* be worth nagging about.
*/
private const PCOV_NOTIFICATION_ELAPSED_LIMIT = 10.0;

/**
* @param string[] $composerAutoloaderProjectPaths
*/
Expand Down Expand Up @@ -676,6 +684,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

$this->reportMissingResultCacheInCi($errorOutput, $analysisResult, $onlyFiles);
$this->reportPcovOverhead($errorOutput);

$this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput(), $analysisResult->getProcessedFiles());

Expand Down Expand Up @@ -711,6 +720,23 @@ private function reportMissingResultCacheInCi(Output $errorOutput, AnalysisResul
$errorOutput->writeLineFormatted('');
}

private function reportPcovOverhead(Output $errorOutput): void
{
if (!PcovHelper::isActive() || PcovHelper::isAllowed()) {
return;
}

if (microtime(true) - $this->analysisStartTime < self::PCOV_NOTIFICATION_ELAPSED_LIMIT) {
return;
}

$errorOutput->writeLineFormatted('<comment>Tip: The pcov extension is active, which makes this run slower than it needs to be.</comment>');
$errorOutput->writeLineFormatted('pcov hooks into every function call in the process, even though PHPStan never collects code coverage.');
$errorOutput->writeLineFormatted(sprintf('PHPStan disables it in its worker processes - run PHPStan with "php -d %s" to disable it in the main process too.', PcovHelper::DISABLED_INI_SETTING));
$errorOutput->writeLineFormatted(sprintf('Set %s=1 if you need pcov to stay enabled.', PcovHelper::ALLOW_ENV_VARIABLE));
$errorOutput->writeLineFormatted('');
}

private function createStreamOutput(): StreamOutput
{
$resource = fopen('php://memory', 'w', false);
Expand Down
28 changes: 28 additions & 0 deletions src/Diagnose/PHPStanDiagnoseExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use PHPStan\Internal\ComposerHelper;
use PHPStan\Php\ComposerPhpVersionFactory;
use PHPStan\Php\PhpVersion;
use PHPStan\Process\PcovHelper;
use ReflectionClass;
use function array_count_values;
use function array_key_exists;
Expand Down Expand Up @@ -68,6 +69,13 @@ public function print(Output $output, array $processedFiles): void
$phpRuntimeVersion->getVersionString(),
));

if (PcovHelper::isLoaded()) {
$output->writeLineFormatted(sprintf(
'<info>pcov extension:</info> %s',
$this->describePcovStatus(),
));
}

if (
$this->phpVersion->getSource() === PhpVersion::SOURCE_CONFIG
&& is_array($this->configPhpVersion)
Expand Down Expand Up @@ -234,6 +242,26 @@ public function print(Output $output, array $processedFiles): void
$output->writeLineFormatted('');
}

private function describePcovStatus(): string
{
$version = PcovHelper::getVersion() ?? 'unknown version';

if (PcovHelper::isAllowed()) {
return sprintf(
'%s, %s - kept everywhere because %s=1',
$version,
PcovHelper::isActive() ? 'active' : 'not active (pcov.enabled=0)',
PcovHelper::ALLOW_ENV_VARIABLE,
);
}

if (!PcovHelper::isActive()) {
return sprintf('%s, not active in this process (pcov.enabled=0), disabled in worker processes', $version);
}

return sprintf('%s, active - it slows down every function call, disabled in worker processes', $version);
}

/**
* @param list<string> $processedFiles
* @return array<string, int<2, max>>
Expand Down
65 changes: 65 additions & 0 deletions src/Process/PcovHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php declare(strict_types = 1);

namespace PHPStan\Process;

use function extension_loaded;
use function getenv;
use function ini_get;
use function phpversion;

/**
* pcov hooks into every userland function call as soon as the extension starts up,
* whether or not any coverage is being collected. Nothing in PHPStan collects
* coverage, so on a machine that has pcov installed - typically a CI image built
* for a test suite - the hook is pure overhead. It makes an analysis dramatically
* slower: function calls in the analysed process become several times more
* expensive, which measures as roughly 40 % of PHPStan's wall clock time.
*
* The hook is installed before any PHP code of the process runs and pcov.enabled is
* PHP_INI_SYSTEM, so a process cannot get rid of it with ini_set(). The only way is
* to start the process with pcov.enabled=0, which is what PHPStan does for its
* worker processes (see ProcessHelper).
*/
final class PcovHelper
{

public const DISABLED_INI_SETTING = 'pcov.enabled=0';

public const ALLOW_ENV_VARIABLE = 'PHPSTAN_ALLOW_PCOV';

public static function isLoaded(): bool
{
return extension_loaded('pcov');
}

/** Whether pcov's call hook is installed in the current process. */
public static function isActive(): bool
{
if (!self::isLoaded()) {
return false;
}

$enabled = ini_get('pcov.enabled');

return $enabled !== false && $enabled !== '' && $enabled !== '0';
}

/** Whether the user asked PHPStan to leave pcov alone. */
public static function isAllowed(): bool
{
return getenv(self::ALLOW_ENV_VARIABLE) === '1';
}

public static function shouldDisableInSubProcesses(): bool
{
return self::isLoaded() && !self::isAllowed();
}

public static function getVersion(): ?string
{
$version = phpversion('pcov');

return $version === false ? null : $version;
}

}
7 changes: 7 additions & 0 deletions src/Process/ProcessHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ public static function getWorkerCommand(
$processCommandArray[] = 'memory_limit=' . ini_get('memory_limit');
}

if (PcovHelper::shouldDisableInSubProcesses()) {
// pcov's call hook is installed before any PHP code of the worker runs,
// so the worker cannot turn it off itself.
$processCommandArray[] = '-d';
$processCommandArray[] = PcovHelper::DISABLED_INI_SETTING;
}

$turboExtension = TurboExtensionSelector::findExtensionForWorkers();
if ($turboExtension !== null) {
$processCommandArray[] = '-d';
Expand Down
67 changes: 67 additions & 0 deletions tests/PHPStan/Process/PcovHelperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php declare(strict_types = 1);

namespace PHPStan\Process;

use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\TestCase;
use function extension_loaded;
use function getenv;
use function putenv;
use function sprintf;

#[CoversNothing]
class PcovHelperTest extends TestCase
{

public function testIsActiveRequiresTheExtension(): void
{
if (extension_loaded('pcov')) {
$this->assertTrue(PcovHelper::isLoaded());
return;
}

$this->assertFalse(PcovHelper::isLoaded());
$this->assertFalse(PcovHelper::isActive());
$this->assertFalse(PcovHelper::shouldDisableInSubProcesses());
}

public function testShouldNotDisableInSubProcessesWhenAllowed(): void
{
$originalValue = getenv(PcovHelper::ALLOW_ENV_VARIABLE);
putenv(sprintf('%s=1', PcovHelper::ALLOW_ENV_VARIABLE));

try {
$this->assertTrue(PcovHelper::isAllowed());
$this->assertFalse(PcovHelper::shouldDisableInSubProcesses());
} finally {
self::restoreAllowEnvVariable($originalValue);
}
}

public function testShouldDisableInSubProcessesEvenWhenNotActiveInThisProcess(): void
{
if (!PcovHelper::isLoaded()) {
$this->markTestSkipped('pcov is not loaded in this process.');
}

$originalValue = getenv(PcovHelper::ALLOW_ENV_VARIABLE);
putenv(PcovHelper::ALLOW_ENV_VARIABLE);

try {
$this->assertFalse(PcovHelper::isAllowed());
// sub-processes read the php.ini, not this process' command line
$this->assertTrue(PcovHelper::shouldDisableInSubProcesses());
$this->assertNotNull(PcovHelper::getVersion());
} finally {
self::restoreAllowEnvVariable($originalValue);
}
}

private static function restoreAllowEnvVariable(string|false $originalValue): void
{
putenv($originalValue === false
? PcovHelper::ALLOW_ENV_VARIABLE
: sprintf('%s=%s', PcovHelper::ALLOW_ENV_VARIABLE, $originalValue));
}

}
83 changes: 83 additions & 0 deletions tests/PHPStan/Process/ProcessHelperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php declare(strict_types = 1);

namespace PHPStan\Process;

use PHPStan\Command\AnalyseCommand;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use function getenv;
use function putenv;
use function sprintf;

#[CoversNothing]
class ProcessHelperTest extends TestCase
{

public function testWorkerCommandDisablesPcov(): void
{
if (!PcovHelper::shouldDisableInSubProcesses()) {
$this->markTestSkipped('pcov is not loaded in this process.');
}

$this->assertStringContainsString(
sprintf('-d %s', PcovHelper::DISABLED_INI_SETTING),
self::getWorkerCommand(),
);
}

public function testWorkerCommandKeepsPcovWhenAllowed(): void
{
if (!PcovHelper::isLoaded()) {
$this->markTestSkipped('pcov is not loaded in this process.');
}

$originalValue = getenv(PcovHelper::ALLOW_ENV_VARIABLE);
putenv(sprintf('%s=1', PcovHelper::ALLOW_ENV_VARIABLE));

try {
$this->assertStringNotContainsString('pcov', self::getWorkerCommand());
} finally {
putenv($originalValue === false
? PcovHelper::ALLOW_ENV_VARIABLE
: sprintf('%s=%s', PcovHelper::ALLOW_ENV_VARIABLE, $originalValue));
}
}

public function testWorkerCommandDoesNotMentionPcovWhenItIsNotLoaded(): void
{
if (PcovHelper::isLoaded()) {
$this->markTestSkipped('pcov is loaded in this process.');
}

$this->assertStringNotContainsString('pcov', self::getWorkerCommand());
}

private static function getWorkerCommand(): string
{
return ProcessHelper::getWorkerCommand(
'bin/phpstan',
'worker',
null,
[],
self::createInput(),
);
}

private static function createInput(): InputInterface
{
return new ArrayInput(['paths' => ['src']], new InputDefinition([
new InputArgument('paths', InputArgument::IS_ARRAY),
new InputOption(AnalyseCommand::OPTION_LEVEL, mode: InputOption::VALUE_REQUIRED),
new InputOption('autoload-file', mode: InputOption::VALUE_REQUIRED),
new InputOption('memory-limit', mode: InputOption::VALUE_REQUIRED),
new InputOption('xdebug', mode: InputOption::VALUE_NONE),
new InputOption('verbose', mode: InputOption::VALUE_NONE),
]));
}

}
Loading