diff --git a/composer.json b/composer.json
index 4aa026deb9e..968b150260e 100644
--- a/composer.json
+++ b/composer.json
@@ -20,7 +20,7 @@
"composer-runtime-api": "^2.0",
"clue/ndjson-react": "^1.0",
"composer/semver": "^3.4",
- "fidry/cpu-core-counter": "^1.2",
+ "fidry/cpu-core-counter": "^1.4",
"guzzlehttp/guzzle": "^7.9",
"hoa/compiler": "3.17.08.08",
"hoa/exception": "^1.0",
diff --git a/composer.lock b/composer.lock
index 25efda9df84..632cc0d1d7e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "ecb4c72bbfe50089cd3e8ee6fdb0c711",
+ "content-hash": "cc17f905fdadcb3534521763a14f9f3b",
"packages": [
{
"name": "clue/ndjson-react",
@@ -196,23 +196,23 @@
},
{
"name": "fidry/cpu-core-counter",
- "version": "1.3.0",
+ "version": "1.4.1",
"source": {
"type": "git",
"url": "https://github.com/theofidry/cpu-core-counter.git",
- "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+ "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
- "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032",
+ "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
- "fidry/makefile": "^0.2.0",
+ "fidry/makefile": "^0.2.0 || ^1.1.2",
"fidry/php-cs-fixer-config": "^1.1.2",
"phpstan/extension-installer": "^1.2.0",
"phpstan/phpstan": "^2.0",
@@ -220,6 +220,7 @@
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "symfony/polyfill-mbstring": "^1.33",
"webmozarts/strict-phpunit": "^7.5"
},
"type": "library",
@@ -245,7 +246,7 @@
],
"support": {
"issues": "https://github.com/theofidry/cpu-core-counter/issues",
- "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1"
},
"funding": [
{
@@ -253,7 +254,7 @@
"type": "github"
}
],
- "time": "2025-08-14T07:29:31+00:00"
+ "time": "2026-09-29T22:11:39+00:00"
},
{
"name": "guzzlehttp/guzzle",
diff --git a/src/Diagnose/SystemResourcesDiagnoseExtension.php b/src/Diagnose/SystemResourcesDiagnoseExtension.php
index cc27cfcd9bc..b8d69646e89 100644
--- a/src/Diagnose/SystemResourcesDiagnoseExtension.php
+++ b/src/Diagnose/SystemResourcesDiagnoseExtension.php
@@ -2,11 +2,11 @@
namespace PHPStan\Diagnose;
+use Fidry\CpuCoreCounter\Diagnoser;
use PHPStan\Command\Output;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Process\CpuCoreCounter;
-use PHPStan\Process\SystemResources;
use function sprintf;
/**
@@ -19,7 +19,6 @@ final class SystemResourcesDiagnoseExtension implements DiagnoseExtension
public function __construct(
private CpuCoreCounter $cpuCoreCounter,
- private SystemResources $systemResources,
#[AutowiredParameter(ref: '%parallel.loadLimit%')]
private ?float $loadLimit,
)
@@ -32,21 +31,13 @@ public function print(Output $output): void
$output->writeLineFormatted(sprintf('Detected CPU cores: %d', $this->cpuCoreCounter->getDetectedNumberOfCpuCores()));
$output->writeLineFormatted(sprintf('Load limit: %s', $this->loadLimit === null ? 'none' : (string) $this->loadLimit));
- $kubernetesLimit = $this->cpuCoreCounter->getKubernetesCpuLimit();
- $output->writeLineFormatted(sprintf(
- 'KUBERNETES_CPU_LIMIT: %s',
- $kubernetesLimit === null ? 'none' : sprintf('%d cores', $kubernetesLimit),
- ));
- $output->writeLineFormatted(sprintf('Available after limits: %d', $this->cpuCoreCounter->getNumberOfCpuCoresAfterLimits()));
-
- $quota = $this->systemResources->getCpuQuota();
- $output->writeLineFormatted(sprintf(
- 'cgroup CPU quota: %s',
- $quota === null ? 'none' : sprintf('%d cores', $quota),
- ));
-
$output->writeLineFormatted(sprintf('Usable CPU cores: %d', $this->cpuCoreCounter->getNumberOfCpuCores()));
$output->writeLineFormatted('');
+
+ // lists every limit the counter looks at, also one that is overridden by the limit found before it
+ $output->writeLineFormatted('CPU core limit:');
+ $output->writeRaw(Diagnoser::diagnose([$this->cpuCoreCounter->getCountLimitFinder()]));
+ $output->writeLineFormatted('');
}
}
diff --git a/src/Process/CpuCoreCounter.php b/src/Process/CpuCoreCounter.php
index 6951a61bf99..23a68a49f0d 100644
--- a/src/Process/CpuCoreCounter.php
+++ b/src/Process/CpuCoreCounter.php
@@ -3,88 +3,51 @@
namespace PHPStan\Process;
use Fidry\CpuCoreCounter\CpuCoreCounter as FidryCpuCoreCounter;
-use Fidry\CpuCoreCounter\NumberOfCpuCoreNotFound;
+use Fidry\CpuCoreCounter\Finder\CpuCoreFinder;
+use Fidry\CpuCoreCounter\Finder\FinderRegistry;
+use Fidry\CpuCoreCounter\ParallelisationResult;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
-use function min;
#[AutowiredService]
final class CpuCoreCounter
{
- private ?int $count = null;
+ private ?ParallelisationResult $result = null;
- /**
- * What fidry/cpu-core-counter found: the raw count, the count left after the
- * load limit and KUBERNETES_CPU_LIMIT were applied, and that environment limit.
- *
- * @var array{int, int, int|null}|null
- */
- private ?array $detected = null;
+ private CpuCoreFinder $countLimitFinder;
public function __construct(
#[AutowiredParameter(ref: '%parallel.loadLimit%')]
private ?float $loadLimit,
- private SystemResources $systemResources,
)
{
+ $this->countLimitFinder = FinderRegistry::getDefaultCountLimitFinder();
}
/**
* Cores PHPStan may actually use: what the machine reports, reduced by the load
- * limit and capped by the CPU quota of the cgroup it runs in.
+ * limit and capped by the cgroup CPU quota or, failing that, KUBERNETES_CPU_LIMIT.
*/
public function getNumberOfCpuCores(): int
{
- if ($this->count !== null) {
- return $this->count;
- }
-
- $count = $this->getNumberOfCpuCoresAfterLimits();
-
- // fidry/cpu-core-counter has no cgroup finder, and its nproc-based default
- // honours a cpuset affinity mask but not a CFS bandwidth quota, so inside a
- // `docker run --cpus=2` container it reports the host's core count
- $quota = $this->systemResources->getCpuQuota();
- if ($quota !== null) {
- $count = min($count, $quota);
- }
-
- return $this->count = $count;
+ return $this->detect()->availableCpus;
}
/** What the machine reports, before any limit is applied. */
public function getDetectedNumberOfCpuCores(): int
{
- return $this->detect()[0];
+ return $this->detect()->totalCoresCount;
}
- /** The detected count after the load limit and KUBERNETES_CPU_LIMIT, before any cgroup quota. */
- public function getNumberOfCpuCoresAfterLimits(): int
+ public function getCountLimitFinder(): CpuCoreFinder
{
- return $this->detect()[1];
+ return $this->countLimitFinder;
}
- public function getKubernetesCpuLimit(): ?int
+ private function detect(): ParallelisationResult
{
- return $this->detect()[2];
- }
-
- /** @return array{int, int, int|null} */
- private function detect(): array
- {
- if ($this->detected !== null) {
- return $this->detected;
- }
-
- try {
- $result = (new FidryCpuCoreCounter())->getAvailableForParallelisation(0, null, $this->loadLimit);
- $this->detected = [$result->totalCoresCount, $result->availableCpus, $result->correctedCountLimit];
- } catch (NumberOfCpuCoreNotFound) {
- $this->detected = [1, 1, null];
- }
-
- return $this->detected;
+ return $this->result ??= (new FidryCpuCoreCounter(countLimitFinder: $this->countLimitFinder))->getAvailableForParallelisation(0, null, $this->loadLimit);
}
}
diff --git a/src/Process/SystemResources.php b/src/Process/SystemResources.php
deleted file mode 100644
index aa571a9f19e..00000000000
--- a/src/Process/SystemResources.php
+++ /dev/null
@@ -1,245 +0,0 @@
-
- */
- private array $cgroupPaths = [];
-
- /**
- * @param string $filesystemRoot Prefix for every /proc and /sys path read, so tests
- * can run against a fixture tree. Empty means the real
- * filesystem.
- */
- public function __construct(private string $filesystemRoot = '')
- {
- }
-
- /**
- * Number of CPU cores the current cgroup's CFS quota allows, or null when no
- * cgroup limits CPU bandwidth.
- *
- * @return positive-int|null
- */
- public function getCpuQuota(): ?int
- {
- $quotas = [];
- foreach ([$this->getCgroupV2CpuQuota(), $this->getCgroupV1CpuQuota()] as $quota) {
- if ($quota === null) {
- continue;
- }
-
- $quotas[] = $quota;
- }
-
- if (count($quotas) === 0) {
- return null;
- }
-
- // a sub-core quota still lets a single worker run, just throttled
- return max(1, min($quotas));
- }
-
- /** @return positive-int|null */
- private function getCgroupV2CpuQuota(): ?int
- {
- $cgroupPath = $this->getCgroupPath('');
- if ($cgroupPath === null) {
- return null;
- }
-
- $quotas = [];
- foreach ($this->getAncestorPaths($cgroupPath) as $path) {
- $cpuMax = $this->readFile('/sys/fs/cgroup' . $path . '/cpu.max');
- if ($cpuMax === null) {
- // the cpu controller is not enabled at this depth - a host's root
- // cgroup has no cpu.max and a leaf often does not either - which says
- // nothing about the ancestors that may still carry a quota. Inside a
- // cgroup namespace the root is the container's own cgroup and does
- // carry it, which is why the walk starts there.
- continue;
- }
-
- $parts = explode(' ', trim($cpuMax));
- if (count($parts) !== 2 || !ctype_digit($parts[0]) || !ctype_digit($parts[1])) {
- // "max " is how an unlimited cgroup states it
- continue;
- }
-
- $period = (int) $parts[1];
- if ($period <= 0) {
- continue;
- }
-
- $quotas[] = (int) ceil((int) $parts[0] / $period);
- }
-
- return count($quotas) === 0 ? null : max(1, min($quotas));
- }
-
- /** @return positive-int|null */
- private function getCgroupV1CpuQuota(): ?int
- {
- $cgroupPath = $this->getCgroupPath('cpu');
- if ($cgroupPath === null) {
- return null;
- }
-
- $quotas = [];
- foreach ($this->getAncestorPaths($cgroupPath) as $path) {
- foreach (['cpu', 'cpu,cpuacct'] as $controllerDir) {
- $base = '/sys/fs/cgroup/' . $controllerDir . $path;
- $quota = $this->readIntFile($base . '/cpu.cfs_quota_us');
- $period = $this->readIntFile($base . '/cpu.cfs_period_us');
- if ($quota === null || $period === null || $quota <= 0 || $period <= 0) {
- // -1 means unlimited
- continue;
- }
-
- $quotas[] = (int) ceil($quota / $period);
- }
- }
-
- return count($quotas) === 0 ? null : max(1, min($quotas));
- }
-
- /**
- * The current process' path within a cgroup hierarchy, or null when it is not in
- * one. An empty controller asks for the v2 unified hierarchy.
- */
- private function getCgroupPath(string $controller): ?string
- {
- if (array_key_exists($controller, $this->cgroupPaths)) {
- return $this->cgroupPaths[$controller];
- }
-
- return $this->cgroupPaths[$controller] = $this->findCgroupPath($controller);
- }
-
- private function findCgroupPath(string $controller): ?string
- {
- $contents = $this->readFile('/proc/self/cgroup');
- if ($contents === null) {
- return null;
- }
-
- foreach (explode("\n", $contents) as $line) {
- // hierarchy-ID:controller-list:path
- $parts = explode(':', trim($line), 3);
- if (count($parts) !== 3) {
- continue;
- }
-
- [, $controllers, $path] = $parts;
- if ($controller === '') {
- if ($controllers !== '') {
- continue;
- }
- } elseif (!in_array($controller, explode(',', $controllers), true)) {
- // a substring match would take cpuset or cpuacct for cpu
- continue;
- }
-
- return $path === '/' ? '' : $path;
- }
-
- return null;
- }
-
- /**
- * The cgroup's own path and every ancestor up to the root, because a limit set on
- * an ancestor binds the leaf just as tightly - Kubernetes puts the pod's quota on
- * the pod slice, not on the container's own cgroup.
- *
- * @return list
- */
- private function getAncestorPaths(string $path): array
- {
- $segments = [];
- foreach (explode('/', $path) as $segment) {
- if ($segment === '') {
- continue;
- }
-
- $segments[] = $segment;
- }
-
- $paths = [''];
- for ($i = 1; $i <= count($segments); $i++) {
- $paths[] = '/' . implode('/', array_slice($segments, 0, $i));
- }
-
- return $paths;
- }
-
- private function readIntFile(string $path): ?int
- {
- $contents = $this->readFile($path);
- if ($contents === null) {
- return null;
- }
-
- $contents = trim($contents);
- $negative = str_starts_with($contents, '-');
- $digits = $negative ? substr($contents, 1) : $contents;
- if ($digits === '' || !ctype_digit($digits)) {
- return null;
- }
-
- return $negative ? -(int) $digits : (int) $digits;
- }
-
- private function readFile(string $path): ?string
- {
- $path = $this->filesystemRoot . $path;
-
- // container filesystems routinely have these present but unreadable, and a
- // warning from a probe would be worse than not knowing
- if (!@is_file($path)) {
- return null;
- }
-
- $contents = @file_get_contents($path);
-
- return $contents === false ? null : $contents;
- }
-
-}
diff --git a/tests/PHPStan/Process/SystemResourcesTest.php b/tests/PHPStan/Process/SystemResourcesTest.php
deleted file mode 100644
index b1c05ce3144..00000000000
--- a/tests/PHPStan/Process/SystemResourcesTest.php
+++ /dev/null
@@ -1,234 +0,0 @@
- */
- private array $roots = [];
-
- #[Override]
- protected function tearDown(): void
- {
- foreach ($this->roots as $root) {
- self::removeDirectory($root);
- }
-
- $this->roots = [];
- }
-
- /**
- * @return iterable, int|null}>
- */
- public static function dataCpuQuota(): iterable
- {
- yield 'v2, quota on the leaf' => [
- [
- '/proc/self/cgroup' => "0::/foo\n",
- '/sys/fs/cgroup/foo/cpu.max' => "200000 100000\n",
- ],
- 2,
- ];
-
- yield 'v2, quota only on an ancestor' => [
- [
- '/proc/self/cgroup' => "0::/foo/bar\n",
- '/sys/fs/cgroup/foo/cpu.max' => "200000 100000\n",
- ],
- 2,
- ];
-
- yield 'v2, nested with a tighter ancestor' => [
- [
- '/proc/self/cgroup' => "0::/foo/bar\n",
- '/sys/fs/cgroup/foo/cpu.max' => "100000 100000\n",
- '/sys/fs/cgroup/foo/bar/cpu.max' => "400000 100000\n",
- ],
- 1,
- ];
-
- yield 'v2, unlimited' => [
- [
- '/proc/self/cgroup' => "0::/foo\n",
- '/sys/fs/cgroup/foo/cpu.max' => "max 100000\n",
- ],
- null,
- ];
-
- yield 'v2, cpu controller not enabled at any level' => [
- [
- '/proc/self/cgroup' => "0::/foo\n",
- '/sys/fs/cgroup/foo/memory.max' => "4294967296\n",
- ],
- null,
- ];
-
- yield 'v2, quota is not a whole number of cores' => [
- [
- '/proc/self/cgroup' => "0::/foo\n",
- '/sys/fs/cgroup/foo/cpu.max' => "250000 100000\n",
- ],
- 3,
- ];
-
- yield 'v2, quota below a single core' => [
- [
- '/proc/self/cgroup' => "0::/foo\n",
- '/sys/fs/cgroup/foo/cpu.max' => "50000 100000\n",
- ],
- 1,
- ];
-
- yield 'v2, malformed' => [
- [
- '/proc/self/cgroup' => "0::/foo\n",
- '/sys/fs/cgroup/foo/cpu.max' => "garbage\n",
- ],
- null,
- ];
-
- yield 'v1, quota' => [
- [
- '/proc/self/cgroup' => "4:cpu,cpuacct:/foo\n",
- '/sys/fs/cgroup/cpu/foo/cpu.cfs_quota_us' => "200000\n",
- '/sys/fs/cgroup/cpu/foo/cpu.cfs_period_us' => "100000\n",
- ],
- 2,
- ];
-
- yield 'v1, unlimited' => [
- [
- '/proc/self/cgroup' => "4:cpu,cpuacct:/foo\n",
- '/sys/fs/cgroup/cpu/foo/cpu.cfs_quota_us' => "-1\n",
- '/sys/fs/cgroup/cpu/foo/cpu.cfs_period_us' => "100000\n",
- ],
- null,
- ];
-
- yield 'v1, controller mounted as cpu,cpuacct' => [
- [
- '/proc/self/cgroup' => "4:cpu,cpuacct:/foo\n",
- '/sys/fs/cgroup/cpu,cpuacct/foo/cpu.cfs_quota_us' => "400000\n",
- '/sys/fs/cgroup/cpu,cpuacct/foo/cpu.cfs_period_us' => "100000\n",
- ],
- 4,
- ];
-
- yield 'v1, a hierarchy whose controller name merely starts with cpu is listed first' => [
- [
- '/proc/self/cgroup' => "7:net_cls,cpuset:/other\n4:cpu,cpuacct:/foo\n",
- '/sys/fs/cgroup/cpu/foo/cpu.cfs_quota_us' => "200000\n",
- '/sys/fs/cgroup/cpu/foo/cpu.cfs_period_us' => "100000\n",
- ],
- 2,
- ];
-
- yield 'v2, docker container in its own cgroup namespace: the root is the container cgroup' => [
- [
- '/proc/self/cgroup' => "0::/\n",
- '/sys/fs/cgroup/cpu.max' => "200000 100000\n",
- ],
- 2,
- ];
-
- yield 'v1, docker container in the host cgroup namespace: the mount is rooted at the container cgroup' => [
- [
- '/proc/self/cgroup' => "4:cpu,cpuacct:/docker/0123abcd\n",
- '/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us' => "400000\n",
- '/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us' => "100000\n",
- ],
- 4,
- ];
-
- yield 'no cgroup filesystem at all' => [[], null];
-
- yield 'in the root cgroup, which has no cpu.max' => [
- ['/proc/self/cgroup' => "0::/\n"],
- null,
- ];
- }
-
- /**
- * @param array $files
- */
- #[DataProvider('dataCpuQuota')]
- public function testGetCpuQuota(array $files, ?int $expectedQuota): void
- {
- $resources = new SystemResources($this->createFixtureRoot($files));
-
- $this->assertSame($expectedQuota, $resources->getCpuQuota());
- }
-
- public function testUsableCoresNeverExceedDetectedCoresOnThisMachine(): void
- {
- // the important regression: whatever this machine turns out to be, applying
- // its quota must narrow the core count rather than invent capacity or
- // collapse to zero - a probe that guesses low would throttle every run
- $counter = new CpuCoreCounter(null, new SystemResources());
-
- $this->assertGreaterThanOrEqual(1, $counter->getNumberOfCpuCores());
- $this->assertLessThanOrEqual($counter->getDetectedNumberOfCpuCores(), $counter->getNumberOfCpuCores());
- }
-
- /**
- * @param array $files
- */
- private function createFixtureRoot(array $files): string
- {
- $root = sys_get_temp_dir() . '/phpstan-system-resources-' . uniqid();
- $this->roots[] = $root;
-
- foreach ($files as $path => $contents) {
- $fullPath = $root . $path;
- $directory = dirname($fullPath);
- if (!is_dir($directory)) {
- mkdir($directory, 0777, true);
- }
-
- FileWriter::write($fullPath, $contents);
- }
-
- if (!is_dir($root)) {
- mkdir($root, 0777, true);
- }
-
- return $root;
- }
-
- private static function removeDirectory(string $directory): void
- {
- if (!is_dir($directory)) {
- return;
- }
-
- foreach (scandir($directory) ?: [] as $entry) {
- if ($entry === '.' || $entry === '..') {
- continue;
- }
-
- $path = $directory . '/' . $entry;
- if (is_dir($path)) {
- self::removeDirectory($path);
- } else {
- unlink($path);
- }
- }
-
- rmdir($directory);
- }
-
-}