Skip to content

Commit ffa806b

Browse files
committed
Disable PCov if installed
1 parent 847148b commit ffa806b

6 files changed

Lines changed: 276 additions & 0 deletions

File tree

‎src/Command/AnalyseCommand.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use PHPStan\Internal\ComposerHelper;
2929
use PHPStan\Internal\DirectoryCreator;
3030
use PHPStan\Internal\DirectoryCreatorException;
31+
use PHPStan\Process\PcovHelper;
3132
use PHPStan\ShouldNotHappenException;
3233
use PHPStan\Turbo\TurboExtensionEnabler;
3334
use Symfony\Component\Console\Command\Command;
@@ -86,6 +87,13 @@ final class AnalyseCommand extends Command
8687
*/
8788
private const RESULT_CACHE_CI_NOTIFICATION_ELAPSED_LIMIT = 60.0;
8889

90+
/**
91+
* The pcov notification is only shown when the analysis took longer than this
92+
* many seconds. Shorter runs do not waste enough time on pcov's call hook to
93+
* be worth nagging about.
94+
*/
95+
private const PCOV_NOTIFICATION_ELAPSED_LIMIT = 10.0;
96+
8997
/**
9098
* @param string[] $composerAutoloaderProjectPaths
9199
*/
@@ -676,6 +684,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
676684
}
677685

678686
$this->reportMissingResultCacheInCi($errorOutput, $analysisResult, $onlyFiles);
687+
$this->reportPcovOverhead($errorOutput);
679688

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

@@ -711,6 +720,23 @@ private function reportMissingResultCacheInCi(Output $errorOutput, AnalysisResul
711720
$errorOutput->writeLineFormatted('');
712721
}
713722

723+
private function reportPcovOverhead(Output $errorOutput): void
724+
{
725+
if (!PcovHelper::isActive() || PcovHelper::isAllowed()) {
726+
return;
727+
}
728+
729+
if (microtime(true) - $this->analysisStartTime < self::PCOV_NOTIFICATION_ELAPSED_LIMIT) {
730+
return;
731+
}
732+
733+
$errorOutput->writeLineFormatted('<comment>Tip: The pcov extension is active, which makes this run slower than it needs to be.</comment>');
734+
$errorOutput->writeLineFormatted('pcov hooks into every function call in the process, even though PHPStan never collects code coverage.');
735+
$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));
736+
$errorOutput->writeLineFormatted(sprintf('Set %s=1 if you need pcov to stay enabled.', PcovHelper::ALLOW_ENV_VARIABLE));
737+
$errorOutput->writeLineFormatted('');
738+
}
739+
714740
private function createStreamOutput(): StreamOutput
715741
{
716742
$resource = fopen('php://memory', 'w', false);

‎src/Diagnose/PHPStanDiagnoseExtension.php‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use PHPStan\Internal\ComposerHelper;
1313
use PHPStan\Php\ComposerPhpVersionFactory;
1414
use PHPStan\Php\PhpVersion;
15+
use PHPStan\Process\PcovHelper;
1516
use ReflectionClass;
1617
use function array_count_values;
1718
use function array_key_exists;
@@ -68,6 +69,13 @@ public function print(Output $output, array $processedFiles): void
6869
$phpRuntimeVersion->getVersionString(),
6970
));
7071

72+
if (PcovHelper::isLoaded()) {
73+
$output->writeLineFormatted(sprintf(
74+
'<info>pcov extension:</info> %s',
75+
$this->describePcovStatus(),
76+
));
77+
}
78+
7179
if (
7280
$this->phpVersion->getSource() === PhpVersion::SOURCE_CONFIG
7381
&& is_array($this->configPhpVersion)
@@ -234,6 +242,26 @@ public function print(Output $output, array $processedFiles): void
234242
$output->writeLineFormatted('');
235243
}
236244

245+
private function describePcovStatus(): string
246+
{
247+
$version = PcovHelper::getVersion() ?? 'unknown version';
248+
249+
if (PcovHelper::isAllowed()) {
250+
return sprintf(
251+
'%s, %s - kept everywhere because %s=1',
252+
$version,
253+
PcovHelper::isActive() ? 'active' : 'not active (pcov.enabled=0)',
254+
PcovHelper::ALLOW_ENV_VARIABLE,
255+
);
256+
}
257+
258+
if (!PcovHelper::isActive()) {
259+
return sprintf('%s, not active in this process (pcov.enabled=0), disabled in worker processes', $version);
260+
}
261+
262+
return sprintf('%s, active - it slows down every function call, disabled in worker processes', $version);
263+
}
264+
237265
/**
238266
* @param list<string> $processedFiles
239267
* @return array<string, int<2, max>>

‎src/Process/PcovHelper.php‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Process;
4+
5+
use function extension_loaded;
6+
use function getenv;
7+
use function ini_get;
8+
use function phpversion;
9+
10+
/**
11+
* pcov hooks into every userland function call as soon as the extension starts up,
12+
* whether or not any coverage is being collected. Nothing in PHPStan collects
13+
* coverage, so on a machine that has pcov installed - typically a CI image built
14+
* for a test suite - the hook is pure overhead. It makes an analysis dramatically
15+
* slower: function calls in the analysed process become several times more
16+
* expensive, which measures as roughly 40 % of PHPStan's wall clock time.
17+
*
18+
* The hook is installed before any PHP code of the process runs and pcov.enabled is
19+
* PHP_INI_SYSTEM, so a process cannot get rid of it with ini_set(). The only way is
20+
* to start the process with pcov.enabled=0, which is what PHPStan does for its
21+
* worker processes (see ProcessHelper).
22+
*/
23+
final class PcovHelper
24+
{
25+
26+
public const DISABLED_INI_SETTING = 'pcov.enabled=0';
27+
28+
public const ALLOW_ENV_VARIABLE = 'PHPSTAN_ALLOW_PCOV';
29+
30+
public static function isLoaded(): bool
31+
{
32+
return extension_loaded('pcov');
33+
}
34+
35+
/** Whether pcov's call hook is installed in the current process. */
36+
public static function isActive(): bool
37+
{
38+
if (!self::isLoaded()) {
39+
return false;
40+
}
41+
42+
$enabled = ini_get('pcov.enabled');
43+
44+
return $enabled !== false && $enabled !== '' && $enabled !== '0';
45+
}
46+
47+
/** Whether the user asked PHPStan to leave pcov alone. */
48+
public static function isAllowed(): bool
49+
{
50+
return getenv(self::ALLOW_ENV_VARIABLE) === '1';
51+
}
52+
53+
public static function shouldDisableInSubProcesses(): bool
54+
{
55+
return self::isLoaded() && !self::isAllowed();
56+
}
57+
58+
public static function getVersion(): ?string
59+
{
60+
$version = phpversion('pcov');
61+
62+
return $version === false ? null : $version;
63+
}
64+
65+
}

‎src/Process/ProcessHelper.php‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ public static function getWorkerCommand(
4444
$processCommandArray[] = 'memory_limit=' . ini_get('memory_limit');
4545
}
4646

47+
if (PcovHelper::shouldDisableInSubProcesses()) {
48+
// pcov's call hook is installed before any PHP code of the worker runs,
49+
// so the worker cannot turn it off itself.
50+
$processCommandArray[] = '-d';
51+
$processCommandArray[] = PcovHelper::DISABLED_INI_SETTING;
52+
}
53+
4754
$turboExtension = TurboExtensionSelector::findExtensionForWorkers();
4855
if ($turboExtension !== null) {
4956
$processCommandArray[] = '-d';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Process;
4+
5+
use PHPUnit\Framework\Attributes\CoversClass;
6+
use PHPUnit\Framework\TestCase;
7+
use function extension_loaded;
8+
use function getenv;
9+
use function putenv;
10+
use function sprintf;
11+
12+
#[CoversClass(PcovHelper::class)]
13+
class PcovHelperTest extends TestCase
14+
{
15+
16+
public function testIsActiveRequiresTheExtension(): void
17+
{
18+
if (extension_loaded('pcov')) {
19+
$this->assertTrue(PcovHelper::isLoaded());
20+
return;
21+
}
22+
23+
$this->assertFalse(PcovHelper::isLoaded());
24+
$this->assertFalse(PcovHelper::isActive());
25+
$this->assertFalse(PcovHelper::shouldDisableInSubProcesses());
26+
}
27+
28+
public function testShouldNotDisableInSubProcessesWhenAllowed(): void
29+
{
30+
$originalValue = getenv(PcovHelper::ALLOW_ENV_VARIABLE);
31+
putenv(sprintf('%s=1', PcovHelper::ALLOW_ENV_VARIABLE));
32+
33+
try {
34+
$this->assertTrue(PcovHelper::isAllowed());
35+
$this->assertFalse(PcovHelper::shouldDisableInSubProcesses());
36+
} finally {
37+
self::restoreAllowEnvVariable($originalValue);
38+
}
39+
}
40+
41+
public function testShouldDisableInSubProcessesEvenWhenNotActiveInThisProcess(): void
42+
{
43+
if (!PcovHelper::isLoaded()) {
44+
$this->markTestSkipped('pcov is not loaded in this process.');
45+
}
46+
47+
$originalValue = getenv(PcovHelper::ALLOW_ENV_VARIABLE);
48+
putenv(PcovHelper::ALLOW_ENV_VARIABLE);
49+
50+
try {
51+
$this->assertFalse(PcovHelper::isAllowed());
52+
// sub-processes read the php.ini, not this process' command line
53+
$this->assertTrue(PcovHelper::shouldDisableInSubProcesses());
54+
$this->assertNotNull(PcovHelper::getVersion());
55+
} finally {
56+
self::restoreAllowEnvVariable($originalValue);
57+
}
58+
}
59+
60+
private static function restoreAllowEnvVariable(string|false $originalValue): void
61+
{
62+
putenv($originalValue === false
63+
? PcovHelper::ALLOW_ENV_VARIABLE
64+
: sprintf('%s=%s', PcovHelper::ALLOW_ENV_VARIABLE, $originalValue));
65+
}
66+
67+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Process;
4+
5+
use PHPStan\Command\AnalyseCommand;
6+
use PHPUnit\Framework\Attributes\CoversClass;
7+
use PHPUnit\Framework\TestCase;
8+
use Symfony\Component\Console\Input\ArrayInput;
9+
use Symfony\Component\Console\Input\InputArgument;
10+
use Symfony\Component\Console\Input\InputDefinition;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Input\InputOption;
13+
use function getenv;
14+
use function putenv;
15+
use function sprintf;
16+
17+
#[CoversClass(ProcessHelper::class)]
18+
class ProcessHelperTest extends TestCase
19+
{
20+
21+
public function testWorkerCommandDisablesPcov(): void
22+
{
23+
if (!PcovHelper::shouldDisableInSubProcesses()) {
24+
$this->markTestSkipped('pcov is not loaded in this process.');
25+
}
26+
27+
$this->assertStringContainsString(
28+
sprintf('-d %s', PcovHelper::DISABLED_INI_SETTING),
29+
self::getWorkerCommand(),
30+
);
31+
}
32+
33+
public function testWorkerCommandKeepsPcovWhenAllowed(): void
34+
{
35+
if (!PcovHelper::isLoaded()) {
36+
$this->markTestSkipped('pcov is not loaded in this process.');
37+
}
38+
39+
$originalValue = getenv(PcovHelper::ALLOW_ENV_VARIABLE);
40+
putenv(sprintf('%s=1', PcovHelper::ALLOW_ENV_VARIABLE));
41+
42+
try {
43+
$this->assertStringNotContainsString('pcov', self::getWorkerCommand());
44+
} finally {
45+
putenv($originalValue === false
46+
? PcovHelper::ALLOW_ENV_VARIABLE
47+
: sprintf('%s=%s', PcovHelper::ALLOW_ENV_VARIABLE, $originalValue));
48+
}
49+
}
50+
51+
public function testWorkerCommandDoesNotMentionPcovWhenItIsNotLoaded(): void
52+
{
53+
if (PcovHelper::isLoaded()) {
54+
$this->markTestSkipped('pcov is loaded in this process.');
55+
}
56+
57+
$this->assertStringNotContainsString('pcov', self::getWorkerCommand());
58+
}
59+
60+
private static function getWorkerCommand(): string
61+
{
62+
return ProcessHelper::getWorkerCommand(
63+
'bin/phpstan',
64+
'worker',
65+
null,
66+
[],
67+
self::createInput(),
68+
);
69+
}
70+
71+
private static function createInput(): InputInterface
72+
{
73+
return new ArrayInput(['paths' => ['src']], new InputDefinition([
74+
new InputArgument('paths', InputArgument::IS_ARRAY),
75+
new InputOption(AnalyseCommand::OPTION_LEVEL, mode: InputOption::VALUE_REQUIRED),
76+
new InputOption('autoload-file', mode: InputOption::VALUE_REQUIRED),
77+
new InputOption('memory-limit', mode: InputOption::VALUE_REQUIRED),
78+
new InputOption('xdebug', mode: InputOption::VALUE_NONE),
79+
new InputOption('verbose', mode: InputOption::VALUE_NONE),
80+
]));
81+
}
82+
83+
}

0 commit comments

Comments
 (0)