From 34d7cbe852e1e010af32faf26d7cd957755249da Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:10:53 +0100 Subject: [PATCH 1/8] feat: add getCPU() with cgroup limit support Returns CPU capacity as a float, honouring cgroup v2/v1 cfs quota and cpuset pinning so Docker/Kubernetes CPU limits are reflected (e.g. a 500m limit returns 0.5). When both quota and cpuset are set, the more restrictive value wins. Falls back to the host core count when no limit is configured. Marks getCPUCores() as deprecated. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 121 ++++++++++++++++++++++++++++++++++++ tests/System/SystemTest.php | 7 +++ 2 files changed, 128 insertions(+) diff --git a/src/System/System.php b/src/System/System.php index d692103..15c14b3 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -125,6 +125,9 @@ public static function getHostname(): string * @return int * * @throws Exception + * + * @deprecated Use {@see self::getCPU()} instead, which returns a float and + * honours cgroup CPU limits inside Docker/Kubernetes containers. */ public static function getCPUCores(): int { @@ -147,6 +150,124 @@ public static function getCPUCores(): int } } + /** + * Gets the amount of CPU available to the current process as a float. + * + * On Linux, this respects cgroup v2 (`/sys/fs/cgroup/cpu.max`) and cgroup v1 + * (`cpu.cfs_quota_us` / `cpu.cfs_period_us`) so it returns the effective limit + * inside Docker or Kubernetes containers (e.g. `0.5` for a 500m CPU limit). + * Also honours cpuset pinning (`--cpuset-cpus`); if both a quota and a cpuset + * are set, the smaller of the two is returned. Falls back to the host's + * physical core count when no limit is configured. + * + * @return float + * + * @throws Exception + */ + public static function getCPU(): float + { + switch (self::getOS()) { + case 'Linux': + $cpuInfo = file_get_contents('/proc/cpuinfo'); + $matches = [[]]; + if ($cpuInfo) { + preg_match_all('/^processor/m', $cpuInfo, $matches); + } + $hostCores = (float) count($matches[0]); + + $limits = [ + self::getCgroupCPULimit(), + self::getCgroupCpusetCount(), + ]; + $limits = array_filter($limits, fn ($v) => $v !== null); + + return empty($limits) ? $hostCores : min($limits); + case 'Darwin': + return (float) intval(shell_exec('sysctl -n hw.ncpu')); + case 'Windows': + return (float) intval(shell_exec('wmic cpu get NumberOfCores')); + default: + throw new Exception(self::getOS().' not supported.'); + } + } + + /** + * Reads the cgroup CPU quota for the current process, supporting both + * cgroup v2 and v1. Returns null when no limit is set or the files are + * not readable. + * + * @return float|null + */ + private static function getCgroupCPULimit(): ?float + { + $v2 = '/sys/fs/cgroup/cpu.max'; + if (is_readable($v2)) { + $contents = trim((string) @file_get_contents($v2)); + if ($contents !== '') { + $parts = preg_split('/\s+/', $contents); + if ($parts !== false && count($parts) >= 2) { + [$quota, $period] = $parts; + if ($quota !== 'max' && is_numeric($quota) && is_numeric($period) && (float) $period > 0) { + return (float) $quota / (float) $period; + } + } + } + } + + $quotaFile = '/sys/fs/cgroup/cpu/cpu.cfs_quota_us'; + $periodFile = '/sys/fs/cgroup/cpu/cpu.cfs_period_us'; + if (is_readable($quotaFile) && is_readable($periodFile)) { + $quota = trim((string) @file_get_contents($quotaFile)); + $period = trim((string) @file_get_contents($periodFile)); + if (is_numeric($quota) && is_numeric($period) && (float) $quota > 0 && (float) $period > 0) { + return (float) $quota / (float) $period; + } + } + + return null; + } + + /** + * Counts the CPUs allowed by the current process's cpuset cgroup + * (e.g. `docker run --cpuset-cpus=0-1,3`). Supports cgroup v2 and v1. + * Returns null when no cpuset is configured or the files are not readable. + * + * @return float|null + */ + private static function getCgroupCpusetCount(): ?float + { + foreach (['/sys/fs/cgroup/cpuset.cpus.effective', '/sys/fs/cgroup/cpuset/cpuset.cpus'] as $file) { + if (! is_readable($file)) { + continue; + } + $contents = trim((string) @file_get_contents($file)); + if ($contents === '') { + continue; + } + + $count = 0; + foreach (explode(',', $contents) as $range) { + if ($range === '') { + continue; + } + if (str_contains($range, '-')) { + [$start, $end] = explode('-', $range, 2); + if (is_numeric($start) && is_numeric($end)) { + $count += ((int) $end - (int) $start) + 1; + } + } elseif (is_numeric($range)) { + $count += 1; + } + } + + if ($count > 0) { + return (float) $count; + } + } + + return null; + } + /** * Helper function to read a Linux System's /proc/stat data and convert it into an array. * diff --git a/tests/System/SystemTest.php b/tests/System/SystemTest.php index b254546..7424f88 100644 --- a/tests/System/SystemTest.php +++ b/tests/System/SystemTest.php @@ -52,6 +52,13 @@ public function testGetCPUCores(): void $this->assertIsInt(System::getCPUCores()); } + public function testGetCPU(): void + { + $cpu = System::getCPU(); + $this->assertIsFloat($cpu); + $this->assertGreaterThan(0, $cpu); + } + public function testGetDiskTotal(): void { $this->assertIsInt(System::getDiskTotal()); From d2392efacdd2cac76f0eb8d7785956db394a1994 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:15:59 +0100 Subject: [PATCH 2/8] fix: parse wmic NumberOfCores output correctly on Windows wmic prints a header line before the numeric value, so intval() on the raw output returns 0. Extract the first numeric token instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/System/System.php b/src/System/System.php index 15c14b3..cd0626c 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -185,7 +185,8 @@ public static function getCPU(): float case 'Darwin': return (float) intval(shell_exec('sysctl -n hw.ncpu')); case 'Windows': - return (float) intval(shell_exec('wmic cpu get NumberOfCores')); + $output = (string) shell_exec('wmic cpu get NumberOfCores'); + return preg_match('/\d+/', $output, $m) ? (float) $m[0] : 0.0; default: throw new Exception(self::getOS().' not supported.'); } From f573d01d4b293424a9de5fe6ea3997b4171d7268 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:16:15 +0100 Subject: [PATCH 3/8] fix: throw when CPU count cannot be determined on Linux Previously, if /proc/cpuinfo was unreadable and no cgroup limit applied, getCPU() would silently return 0.0. Throw a descriptive exception in that case so callers don't get a value that violates the contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/System/System.php b/src/System/System.php index cd0626c..62f71aa 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -168,20 +168,27 @@ public static function getCPU(): float { switch (self::getOS()) { case 'Linux': - $cpuInfo = file_get_contents('/proc/cpuinfo'); - $matches = [[]]; - if ($cpuInfo) { - preg_match_all('/^processor/m', $cpuInfo, $matches); - } - $hostCores = (float) count($matches[0]); - $limits = [ self::getCgroupCPULimit(), self::getCgroupCpusetCount(), ]; $limits = array_filter($limits, fn ($v) => $v !== null); - return empty($limits) ? $hostCores : min($limits); + if (! empty($limits)) { + return min($limits); + } + + $cpuInfo = file_get_contents('/proc/cpuinfo'); + if ($cpuInfo === false) { + throw new Exception('Unable to determine CPU count: /proc/cpuinfo is not readable and no cgroup limits are configured.'); + } + preg_match_all('/^processor/m', $cpuInfo, $matches); + $hostCores = count($matches[0]); + if ($hostCores === 0) { + throw new Exception('Unable to determine CPU count: /proc/cpuinfo contained no processor entries.'); + } + + return (float) $hostCores; case 'Darwin': return (float) intval(shell_exec('sysctl -n hw.ncpu')); case 'Windows': From 0a4dbf8752a499cde7845fa2689974954e301c27 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:16:59 +0100 Subject: [PATCH 4/8] fix: ignore cpuset when it matches all online CPUs cgroup v2 always exposes cpuset.cpus.effective listing every CPU even when no user-visible cpuset restriction is configured. Compare the set against /sys/devices/system/cpu/online and return null when it covers all of them, so the v1 fallback remains reachable and "no restriction" is no longer misrepresented as "cpuset = all CPUs". Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 51 ++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/src/System/System.php b/src/System/System.php index 62f71aa..461704e 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -253,29 +253,50 @@ private static function getCgroupCpusetCount(): ?float continue; } - $count = 0; - foreach (explode(',', $contents) as $range) { - if ($range === '') { - continue; - } - if (str_contains($range, '-')) { - [$start, $end] = explode('-', $range, 2); - if (is_numeric($start) && is_numeric($end)) { - $count += ((int) $end - (int) $start) + 1; - } - } elseif (is_numeric($range)) { - $count += 1; - } + $count = self::countCpuList($contents); + if ($count <= 0) { + continue; } - if ($count > 0) { - return (float) $count; + // If the cpuset matches every online CPU, no user-visible restriction + // is in effect (cgroup v2 always exposes the full set). Treat as null. + $online = @file_get_contents('/sys/devices/system/cpu/online'); + if ($online !== false) { + $onlineCount = self::countCpuList(trim($online)); + if ($onlineCount > 0 && $count >= $onlineCount) { + return null; + } } + + return (float) $count; } return null; } + /** + * Counts CPUs in a Linux cpu list string like "0-3,5,7-8". + */ + private static function countCpuList(string $list): int + { + $count = 0; + foreach (explode(',', $list) as $range) { + if ($range === '') { + continue; + } + if (str_contains($range, '-')) { + [$start, $end] = explode('-', $range, 2); + if (is_numeric($start) && is_numeric($end)) { + $count += ((int) $end - (int) $start) + 1; + } + } elseif (is_numeric($range)) { + $count += 1; + } + } + + return $count; + } + /** * Helper function to read a Linux System's /proc/stat data and convert it into an array. * From 29a1ae8d55fb65b4dce73a073059ec14534f5219 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:19:47 +0100 Subject: [PATCH 5/8] fix: throw on Darwin/Windows when CPU count cannot be parsed Both branches previously fell through to 0.0 when shell_exec was disabled or returned no numeric output, breaking the >0 contract that the Linux branch and tests rely on. Throw a descriptive exception instead, matching the Linux behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/System/System.php b/src/System/System.php index 461704e..9cc617a 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -190,10 +190,17 @@ public static function getCPU(): float return (float) $hostCores; case 'Darwin': - return (float) intval(shell_exec('sysctl -n hw.ncpu')); + $output = shell_exec('sysctl -n hw.ncpu'); + if ($output === null || ! preg_match('/\d+/', $output, $m) || (int) $m[0] <= 0) { + throw new Exception('Unable to determine CPU count via sysctl.'); + } + return (float) $m[0]; case 'Windows': - $output = (string) shell_exec('wmic cpu get NumberOfCores'); - return preg_match('/\d+/', $output, $m) ? (float) $m[0] : 0.0; + $output = shell_exec('wmic cpu get NumberOfCores'); + if ($output === null || ! preg_match('/\d+/', $output, $m) || (int) $m[0] <= 0) { + throw new Exception('Unable to determine CPU count via wmic.'); + } + return (float) $m[0]; default: throw new Exception(self::getOS().' not supported.'); } From 52bc777f41160a3a4afd290e4d08b06bb30a79cf Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:21:18 +0100 Subject: [PATCH 6/8] fix: narrow shell_exec result for phpstan shell_exec can return string|false|null; use is_string() before passing to preg_match to satisfy static analysis. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/System/System.php b/src/System/System.php index 9cc617a..5c69c0b 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -191,13 +191,13 @@ public static function getCPU(): float return (float) $hostCores; case 'Darwin': $output = shell_exec('sysctl -n hw.ncpu'); - if ($output === null || ! preg_match('/\d+/', $output, $m) || (int) $m[0] <= 0) { + if (! is_string($output) || ! preg_match('/\d+/', $output, $m) || (int) $m[0] <= 0) { throw new Exception('Unable to determine CPU count via sysctl.'); } return (float) $m[0]; case 'Windows': $output = shell_exec('wmic cpu get NumberOfCores'); - if ($output === null || ! preg_match('/\d+/', $output, $m) || (int) $m[0] <= 0) { + if (! is_string($output) || ! preg_match('/\d+/', $output, $m) || (int) $m[0] <= 0) { throw new Exception('Unable to determine CPU count via wmic.'); } return (float) $m[0]; From ff48f726805f2ba7cf7d38bd259664a217bc3e13 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:24:07 +0100 Subject: [PATCH 7/8] fix: sum core counts across CPU sockets on Windows wmic emits one NumberOfCores row per socket, so preg_match captured only the first and undercounted multi-socket systems. Use preg_match_all and sum the values. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/System/System.php b/src/System/System.php index 5c69c0b..05e161f 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -197,10 +197,14 @@ public static function getCPU(): float return (float) $m[0]; case 'Windows': $output = shell_exec('wmic cpu get NumberOfCores'); - if (! is_string($output) || ! preg_match('/\d+/', $output, $m) || (int) $m[0] <= 0) { + if (! is_string($output) || ! preg_match_all('/\d+/', $output, $m)) { throw new Exception('Unable to determine CPU count via wmic.'); } - return (float) $m[0]; + $total = array_sum(array_map('intval', $m[0])); + if ($total <= 0) { + throw new Exception('Unable to determine CPU count via wmic.'); + } + return (float) $total; default: throw new Exception(self::getOS().' not supported.'); } From c0833032eacda2907ce14954ec50fa8db061bad1 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Tue, 5 May 2026 13:31:20 +0100 Subject: [PATCH 8/8] fix: guard against inverted ranges in countCpuList An input like "3-1" previously produced a negative increment; require start <= end before counting. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/System/System.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/System/System.php b/src/System/System.php index 05e161f..8e4c487 100644 --- a/src/System/System.php +++ b/src/System/System.php @@ -297,7 +297,7 @@ private static function countCpuList(string $list): int } if (str_contains($range, '-')) { [$start, $end] = explode('-', $range, 2); - if (is_numeric($start) && is_numeric($end)) { + if (is_numeric($start) && is_numeric($end) && (int) $start <= (int) $end) { $count += ((int) $end - (int) $start) + 1; } } elseif (is_numeric($range)) {