From f8f3ca3a82a81945c4845bc6eba83224427e97cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:10:15 +0000 Subject: [PATCH] fix(watchdog): correct bandwidth totals, rps, producers and fatals SystemInfo::getStats() feeds the watchdog heartbeat (servers.watchdog_data), servers_stats and the LB /api stats action, and had four defects: - bytes_sent_total/bytes_received_total were assigned per interface, so only the last selected interface was kept, and the received total read tx_bytes. aggregateNetwork() now sums both totals over the same interfaces as the per-second rates and reads rx_bytes for received. network_speed keeps its first-positive rule and the interface selection is unchanged, so routing capacity is not affected. - total_running_streams was `ps ax | grep -c ffmpeg`, which counted ffprobe and shell wrappers and missed the fanout daemon's native remuxer. ProcessManager::countStreamProducers() now reads /proc and counts processes whose program is ffmpeg or `xc_fanout remux`. - The memory percentage divided by total_mem, which getMemory() reports as 0 without MemAvailable (DivisionByZeroError), and nvidia-smi XML with a single GPU process decodes process_info as one associative array (TypeError). Either aborted the watchdog pass, so the node wrote no heartbeat. memUsedPercent() and gpuProcesses() guard both. The watchdog runs one pass per process and re-execs itself, so the previous nginx request count held in a variable was always null and requests_per_second was 0 on every node. CounterRateSampler keeps the previous reading in a tmp state file (atomic replace, 120 s max age, 0 after a counter reset). The watchdog_data key set and order are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a --- src/Cli/Commands/WatchdogCommand.php | 13 ++-- src/Core/Process/ProcessManager.php | 37 ++++++++++ src/Core/Util/CounterRateSampler.php | 69 ++++++++++++++++++ src/Core/Util/SystemInfo.php | 95 +++++++++++++++++++----- tests/Unit/CounterRateSamplerTest.php | 98 +++++++++++++++++++++++++ tests/Unit/StreamProducerCountTest.php | 82 +++++++++++++++++++++ tests/Unit/SystemInfoGuardsTest.php | 71 ++++++++++++++++++ tests/Unit/SystemInfoNetworkTest.php | 99 ++++++++++++++++++++++++++ 8 files changed, 541 insertions(+), 23 deletions(-) create mode 100644 src/Core/Util/CounterRateSampler.php create mode 100644 tests/Unit/CounterRateSamplerTest.php create mode 100644 tests/Unit/StreamProducerCountTest.php create mode 100644 tests/Unit/SystemInfoGuardsTest.php create mode 100644 tests/Unit/SystemInfoNetworkTest.php diff --git a/src/Cli/Commands/WatchdogCommand.php b/src/Cli/Commands/WatchdogCommand.php index 15fbf040..3350c3a7 100644 --- a/src/Cli/Commands/WatchdogCommand.php +++ b/src/Cli/Commands/WatchdogCommand.php @@ -7,6 +7,7 @@ use XcVm\Cli\DaemonTrait; use XcVm\Core\Config\SettingsManager; use XcVm\Core\Config\SettingsRepository; use XcVm\Core\Process\ProcessManager; +use XcVm\Core\Util\CounterRateSampler; use XcVm\Core\Util\SystemInfo; use XcVm\Domain\Server\ServerRepository; use XcVm\Domain\Stream\ConnectionTracker; @@ -54,7 +55,7 @@ class WatchdogCommand implements CommandInterface { $this->initRedisIfEnabled(); $this->rRefreshInterval = (intval(SettingsManager::get('online_capacity_interval')) ?: 10); - $rLastRequests = $rLastRequestsTime = $rPrevStat = null; + $rPrevStat = null; $this->rLastCheck = null; $rServers = ServerRepository::getAll(); @@ -97,11 +98,11 @@ class WatchdogCommand implements CommandInterface { } // ── Nginx stats ────────────────────────────────────── - $rNginx = explode("\n", file_get_contents('http://127.0.0.1:' . $rServers[SERVER_ID]['http_broadcast_port'] . '/nginx_status')); - list($rAccepted, $rHandled, $rRequests) = explode(' ', trim($rNginx[2])); - $rRequestsPerSecond = ($rLastRequests ? intval((floatval($rRequests) - floatval($rLastRequests)) / (time() - $rLastRequestsTime)) : 0); - $rLastRequests = $rRequests; - $rLastRequestsTime = time(); + // Each pass is a fresh process (restartDaemon re-execs), so the + // previous request count is kept in a state file, not a variable. + $rNginx = explode("\n", (string) @file_get_contents('http://127.0.0.1:' . $rServers[SERVER_ID]['http_broadcast_port'] . '/nginx_status')); + $rRequests = explode(' ', trim($rNginx[2] ?? ''))[2] ?? null; + $rRequestsPerSecond = is_numeric($rRequests) ? (new CounterRateSampler(TMP_PATH . 'watchdog_nginx_requests.json'))->sample((float) $rRequests, time()) : 0; // ── CPU stats ──────────────────────────────────────── $rStats = SystemInfo::getStats(); diff --git a/src/Core/Process/ProcessManager.php b/src/Core/Process/ProcessManager.php index e4e3927d..71390bdf 100644 --- a/src/Core/Process/ProcessManager.php +++ b/src/Core/Process/ProcessManager.php @@ -646,6 +646,43 @@ class ProcessManager { return count(self::findProcessPIDs($rTerms, 1)) > 0; } + /** + * Whether a raw /proc/PID/cmdline is a stream producer: ffmpeg itself, or + * the fanout daemon's native remuxer (`xc_fanout remux`). Only the program + * name is matched, so ffprobe (under bin/ffmpeg_bin/ as well), a shell + * wrapper naming ffmpeg and the fanout daemon are not producers. + * + * @param string $rRawCmdline NUL-separated argv, as read from /proc + * @return bool + */ + public static function isStreamProducerCmdline(string $rRawCmdline) { + $rArgv = explode("\0", $rRawCmdline); + $rProgram = basename($rArgv[0]); + if ($rProgram === 'ffmpeg') { + return true; + } + return $rProgram === 'xc_fanout' && ($rArgv[1] ?? '') === 'remux'; + } + + /** + * Count the running stream producers (see isStreamProducerCmdline()) — + * total_running_streams in the watchdog heartbeat. Reads /proc directly + * instead of `ps ax | grep -c ffmpeg`. + * + * @param string $rProcRoot procfs mount point (overridable for tests) + * @return int + */ + public static function countStreamProducers(string $rProcRoot = '/proc') { + $rCount = 0; + foreach (glob($rProcRoot . '/[0-9]*/cmdline') ?: [] as $rCmdFile) { + $rRaw = @file_get_contents($rCmdFile); + if ($rRaw && self::isStreamProducerCmdline($rRaw)) { + $rCount++; + } + } + return $rCount; + } + /** * PIDs of the live PHP-FPM workers of the xc_vm pool — the pids that * connection rows record (getmypid() in the stream endpoints). The watchdog diff --git a/src/Core/Util/CounterRateSampler.php b/src/Core/Util/CounterRateSampler.php new file mode 100644 index 00000000..be826bed --- /dev/null +++ b/src/Core/Util/CounterRateSampler.php @@ -0,0 +1,69 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ + +final class CounterRateSampler { + /** + * @param string $rStateFile Where the previous reading is kept. + * @param int $rMaxAge Oldest previous reading (seconds) still used; + * an older one averages over a gap and says nothing. + */ + public function __construct(private string $rStateFile, private int $rMaxAge = 120) { + } + + /** + * Record a reading and return the rate since the previous one. + * + * The new reading is always stored, so the next call measures from it even + * when this one reports nothing. + * + * @param float $rValue Current counter value. + * @param int $rNow Current unix time. + * @return int Units per second; 0 with no usable previous reading (none, a + * corrupt state file, the same or an earlier second, one older + * than the max age) or when the counter went down (a restart). + */ + public function sample(float $rValue, int $rNow): int { + $rPrev = json_decode((string) @file_get_contents($this->rStateFile), true); + $this->store($rValue, $rNow); + + if (!is_array($rPrev) || !isset($rPrev['v'], $rPrev['t']) || !is_numeric($rPrev['v']) || !is_numeric($rPrev['t'])) { + return 0; + } + $rSeconds = $rNow - (int) $rPrev['t']; + $rDelta = $rValue - (float) $rPrev['v']; + if ($rSeconds <= 0 || $this->rMaxAge < $rSeconds || $rDelta < 0) { + return 0; + } + + return intval($rDelta / $rSeconds); + } + + /** + * Replace the state file atomically (write a temporary file, then rename), + * so a reader never sees a half-written reading. Failures are silent: the + * next sample just reports 0. + */ + private function store(float $rValue, int $rNow): void { + $rTmp = $this->rStateFile . '.' . getmypid() . '.tmp'; + if (@file_put_contents($rTmp, json_encode(['v' => $rValue, 't' => $rNow])) === false || !@rename($rTmp, $this->rStateFile)) { + @unlink($rTmp); + } + } +} diff --git a/src/Core/Util/SystemInfo.php b/src/Core/Util/SystemInfo.php index 4f876510..9e08a3c3 100644 --- a/src/Core/Util/SystemInfo.php +++ b/src/Core/Util/SystemInfo.php @@ -2,6 +2,7 @@ namespace XcVm\Core\Util; +use XcVm\Core\Process\ProcessManager; use XcVm\Domain\Server\ServerRepository; /** @@ -39,12 +40,12 @@ class SystemInfo { $rJSON['total_mem'] = $rMemInfo['total']; $rJSON['total_mem_free'] = $rMemInfo['free']; $rJSON['total_mem_used'] = $rMemInfo['used']; - $rJSON['total_mem_used_percent'] = round(($rJSON['total_mem_used'] / $rJSON['total_mem']) * 100, 2); + $rJSON['total_mem_used_percent'] = self::memUsedPercent($rJSON['total_mem_used'], $rJSON['total_mem']); $rJSON['total_disk_space'] = disk_total_space(MAIN_HOME); $rJSON['free_disk_space'] = disk_free_space(MAIN_HOME); $rJSON['kernel'] = trim(@shell_exec('uname -r') ?? ''); $rJSON['uptime'] = self::getUptime(); - $rJSON['total_running_streams'] = (int) trim(@shell_exec('ps ax | grep -v grep | grep -c ffmpeg') ?? '0'); + $rJSON['total_running_streams'] = ProcessManager::countStreamProducers(); $rJSON['bytes_sent'] = 0; $rJSON['bytes_sent_total'] = 0; $rJSON['bytes_received'] = 0; @@ -71,18 +72,8 @@ class SystemInfo { } $rJSON['network_info'] = self::getNetwork($rNetworkInterface); - foreach ($rJSON['network_info'] as $rInterface => $rData) { - if (file_exists('/sys/class/net/' . $rInterface . '/speed')) { - $NetSpeed = intval(file_get_contents('/sys/class/net/' . $rInterface . '/speed')); - if (0 < $NetSpeed && $rJSON['network_speed'] == 0) { - $rJSON['network_speed'] = $NetSpeed; - } - } - $rJSON['bytes_sent_total'] = (intval(trim(file_get_contents('/sys/class/net/' . $rInterface . '/statistics/tx_bytes')))); - $rJSON['bytes_received_total'] = (intval(trim(file_get_contents('/sys/class/net/' . $rInterface . '/statistics/tx_bytes')))); - $rJSON['bytes_sent'] += $rData['out_bytes']; - $rJSON['bytes_received'] += $rData['in_bytes']; - } + // The bandwidth keys are initialised above, so replacing them keeps their order. + $rJSON = array_replace($rJSON, self::aggregateNetwork($rJSON['network_info'])); $rJSON['audio_devices'] = []; $rJSON['video_devices'] = $rJSON['audio_devices']; $rJSON['gpu_info'] = $rJSON['video_devices']; @@ -165,6 +156,24 @@ class SystemInfo { } } + /** + * Used memory as a percentage of the total. + * + * getMemory() reports a total of 0 when /proc/meminfo cannot be read or has + * no MemAvailable (kernels before 3.14, some containers); dividing by it + * threw DivisionByZeroError and cost the node its heartbeat. + * + * @param int $rUsed Used memory (kB) + * @param int $rTotal Total memory (kB) + * @return float Percentage rounded to 2 places; 0 with no total + */ + public static function memUsedPercent(int $rUsed, int $rTotal) { + if ($rTotal <= 0) { + return 0.0; + } + return round(($rUsed / $rTotal) * 100, 2); + } + /** * Get system uptime as human-readable string. * @@ -215,6 +224,33 @@ class SystemInfo { return $rReturn; } + /** + * Bandwidth figures over the interfaces getNetwork() selected. + * + * The per-second rates come from the cached network readings; the lifetime + * totals from sysfs (tx_bytes sent, rx_bytes received). Both are summed over + * every interface. network_speed is the first positive link speed (Mbit/s). + * An unreadable sysfs file counts as 0. + * + * @param array $rNetworkInfo getNetwork() result + * @param string $rSysNet sysfs network class directory (overridable for tests) + * @return array{bytes_sent: int, bytes_sent_total: int, bytes_received: int, bytes_received_total: int, network_speed: int} + */ + public static function aggregateNetwork(array $rNetworkInfo, string $rSysNet = '/sys/class/net/') { + $rReturn = ['bytes_sent' => 0, 'bytes_sent_total' => 0, 'bytes_received' => 0, 'bytes_received_total' => 0, 'network_speed' => 0]; + foreach ($rNetworkInfo as $rInterface => $rData) { + $rSpeed = intval(trim((string) @file_get_contents($rSysNet . $rInterface . '/speed'))); + if (0 < $rSpeed && $rReturn['network_speed'] == 0) { + $rReturn['network_speed'] = $rSpeed; + } + $rReturn['bytes_sent_total'] += intval(trim((string) @file_get_contents($rSysNet . $rInterface . '/statistics/tx_bytes'))); + $rReturn['bytes_received_total'] += intval(trim((string) @file_get_contents($rSysNet . $rInterface . '/statistics/rx_bytes'))); + $rReturn['bytes_sent'] += $rData['out_bytes']; + $rReturn['bytes_received'] += $rData['in_bytes']; + } + return $rReturn; + } + /** * List V4L2 video capture devices. * @@ -287,9 +323,7 @@ class SystemInfo { } foreach ($rJSON['gpu'] as $rInstance) { $rArray = ['name' => $rInstance['product_name'], 'power_readings' => $rInstance['power_readings'], 'utilisation' => $rInstance['utilization'], 'memory_usage' => $rInstance['fb_memory_usage'], 'fan_speed' => $rInstance['fan_speed'], 'temperature' => $rInstance['temperature'], 'clocks' => $rInstance['clocks'], 'uuid' => $rInstance['uuid'], 'id' => intval($rInstance['pci']['pci_device']), 'processes' => []]; - foreach ($rInstance['processes']['process_info'] as $rProcess) { - $rArray['processes'][] = ['pid' => intval($rProcess['pid']), 'memory' => $rProcess['used_memory']]; - } + $rArray['processes'] = self::gpuProcesses($rInstance); $rGPU['gpus'][] = $rArray; } return $rGPU; @@ -297,4 +331,31 @@ class SystemInfo { } return []; } + + /** + * The processes of one decoded nvidia-smi GPU entry. + * + * The XML decodes a single as one associative array rather + * than a list of one, the same way a single decodes (see getGPUInfo()), + * and a GPU with no processes has no process_info at all. + * + * @param array $rInstance One decoded element + * @return array + */ + public static function gpuProcesses(array $rInstance) { + $rProcesses = $rInstance['processes']['process_info'] ?? []; + if (!is_array($rProcesses)) { + return []; + } + if (isset($rProcesses['pid'])) { + $rProcesses = [$rProcesses]; + } + $rReturn = []; + foreach ($rProcesses as $rProcess) { + if (is_array($rProcess)) { + $rReturn[] = ['pid' => intval($rProcess['pid'] ?? 0), 'memory' => $rProcess['used_memory'] ?? null]; + } + } + return $rReturn; + } } diff --git a/tests/Unit/CounterRateSamplerTest.php b/tests/Unit/CounterRateSamplerTest.php new file mode 100644 index 00000000..3b1d8509 --- /dev/null +++ b/tests/Unit/CounterRateSamplerTest.php @@ -0,0 +1,98 @@ +rFile = sys_get_temp_dir() . '/xcvm_rate_' . uniqid('', true) . '.json'; + } + + protected function tearDown(): void { + foreach (glob($this->rFile . '*') ?: [] as $rPath) { + @unlink($rPath); + } + } + + public function testTheFirstSampleIsZeroAndStoresTheReading(): void { + $this->assertSame(0, (new CounterRateSampler($this->rFile))->sample(1000, 100)); + $this->assertFileExists($this->rFile); + } + + public function testRateIsTheCounterDeltaPerSecondAcrossInstances(): void { + (new CounterRateSampler($this->rFile))->sample(1000, 100); + + // A fresh instance, as a re-exec'd watchdog pass would build. + $this->assertSame(100, (new CounterRateSampler($this->rFile))->sample(1500, 105)); + $this->assertSame(33, (new CounterRateSampler($this->rFile))->sample(1600, 108)); + } + + public function testACounterThatWentDownIsZeroAndStillStored(): void { + $rSampler = new CounterRateSampler($this->rFile); + $rSampler->sample(5000, 100); + + // nginx restarted: its counter starts over. + $this->assertSame(0, $rSampler->sample(40, 104)); + // The next reading is measured from the stored post-restart value. + $this->assertSame(10, $rSampler->sample(80, 108)); + } + + public function testTheSameTimestampIsZero(): void { + $rSampler = new CounterRateSampler($this->rFile); + $rSampler->sample(1000, 100); + + $this->assertSame(0, $rSampler->sample(1500, 100)); + $this->assertSame(0, $rSampler->sample(2000, 99), 'a clock that stepped back'); + } + + public function testAReadingOlderThanMaxAgeIsZero(): void { + $rSampler = new CounterRateSampler($this->rFile, 120); + $rSampler->sample(1000, 100); + + $this->assertSame(0, $rSampler->sample(100000, 221)); + $this->assertSame(10, $rSampler->sample(101200, 341), '120 s is still in range'); + } + + public function testTheDefaultMaxAgeIs120Seconds(): void { + $rSampler = new CounterRateSampler($this->rFile); + $rSampler->sample(1000, 100); + $this->assertSame(0, $rSampler->sample(2210, 221)); + } + + public function testACorruptStateFileIsZeroAndOverwritten(): void { + file_put_contents($this->rFile, 'not json {'); + $rSampler = new CounterRateSampler($this->rFile); + + $this->assertSame(0, $rSampler->sample(1000, 100)); + $this->assertSame(100, $rSampler->sample(1500, 105)); + + file_put_contents($this->rFile, '{"v":"x","t":null}'); + $this->assertSame(0, $rSampler->sample(2000, 110)); + } + + public function testAnUnwritableStateFileIsZeroWithoutAWarning(): void { + $rSampler = new CounterRateSampler(sys_get_temp_dir() . '/xcvm_missing_' . uniqid('', true) . '/state.json'); + + $this->assertSame(0, $rSampler->sample(1000, 100)); + $this->assertSame(0, $rSampler->sample(1500, 105)); + } + + public function testWatchdogSamplesNginxRequestsThroughTheStateFile(): void { + $rPath = MAIN_HOME . 'Cli/Commands/WatchdogCommand.php'; + $this->assertFileExists($rPath); + $rSource = (string) file_get_contents($rPath); + + $this->assertStringContainsString('new CounterRateSampler(TMP_PATH . ', $rSource); + $this->assertStringNotContainsString('$rLastRequests', $rSource, 'a per-process previous reading is always null'); + } +} diff --git a/tests/Unit/StreamProducerCountTest.php b/tests/Unit/StreamProducerCountTest.php new file mode 100644 index 00000000..9027028c --- /dev/null +++ b/tests/Unit/StreamProducerCountTest.php @@ -0,0 +1,82 @@ +rRoot = sys_get_temp_dir() . '/xcvm_proc_' . uniqid('', true); + mkdir($this->rRoot, 0755, true); + } + + protected function tearDown(): void { + foreach (glob($this->rRoot . '/*/cmdline') ?: [] as $rFile) { + unlink($rFile); + } + foreach (glob($this->rRoot . '/*') ?: [] as $rDir) { + rmdir($rDir); + } + @rmdir($this->rRoot); + } + + private function process(int $rPID, string $rCmdline): void { + mkdir($this->rRoot . '/' . $rPID); + file_put_contents($this->rRoot . '/' . $rPID . '/cmdline', $rCmdline); + } + + public function testCountsFfmpegAndNativeRemuxProducersOnly(): void { + $this->process(4300001, self::FFMPEG); + $this->process(4300002, self::FFPROBE); + $this->process(4300003, self::SHELL); + $this->process(4300004, self::REMUX); + $this->process(4300005, self::DAEMON); + $this->process(4300006, self::FPM); + // A kernel thread or a zombie has an empty cmdline. + $this->process(4300007, ''); + + $this->assertSame(2, ProcessManager::countStreamProducers($this->rRoot)); + } + + public function testAnEmptyProcRootCountsNothing(): void { + $this->assertSame(0, ProcessManager::countStreamProducers($this->rRoot)); + $this->assertSame(0, ProcessManager::countStreamProducers($this->rRoot . '/missing')); + } + + public function testTheProducerPredicate(): void { + $this->assertTrue(ProcessManager::isStreamProducerCmdline(self::FFMPEG), 'ffmpeg'); + $this->assertTrue(ProcessManager::isStreamProducerCmdline(self::REMUX), 'xc_fanout remux'); + $this->assertTrue(ProcessManager::isStreamProducerCmdline("ffmpeg\0-i\0x\0"), 'ffmpeg found on PATH'); + + $this->assertFalse(ProcessManager::isStreamProducerCmdline(self::FFPROBE), 'ffprobe'); + $this->assertFalse(ProcessManager::isStreamProducerCmdline(self::SHELL), 'a shell wrapper'); + $this->assertFalse(ProcessManager::isStreamProducerCmdline(self::DAEMON), 'the fanout daemon'); + $this->assertFalse(ProcessManager::isStreamProducerCmdline(self::FPM), 'a PHP-FPM worker'); + $this->assertFalse(ProcessManager::isStreamProducerCmdline(''), 'an empty cmdline'); + $this->assertFalse(ProcessManager::isStreamProducerCmdline("/usr/bin/xc_fanout_remux\0"), 'a lookalike name'); + } + + public function testGetStatsCountsProducersInsteadOfGreppingPs(): void { + $rSource = (string) file_get_contents(MAIN_HOME . 'Core/Util/SystemInfo.php'); + + $this->assertStringContainsString("\$rJSON['total_running_streams'] = ProcessManager::countStreamProducers();", $rSource); + $this->assertStringNotContainsString('grep -c ffmpeg', $rSource); + } +} diff --git a/tests/Unit/SystemInfoGuardsTest.php b/tests/Unit/SystemInfoGuardsTest.php new file mode 100644 index 00000000..186be6e2 --- /dev/null +++ b/tests/Unit/SystemInfoGuardsTest.php @@ -0,0 +1,71 @@ +assertSame(0.0, SystemInfo::memUsedPercent(0, 0)); + $this->assertSame(0.0, SystemInfo::memUsedPercent(512, 0)); + } + + public function testMemoryPercentIsRoundedToTwoPlaces(): void { + $this->assertSame(25.0, SystemInfo::memUsedPercent(1024, 4096)); + $this->assertSame(33.33, SystemInfo::memUsedPercent(1, 3)); + } + + public function testASingleGpuProcessIsWrappedInAList(): void { + $rInstance = ['processes' => ['process_info' => ['pid' => '4242', 'type' => 'C', 'process_name' => 'ffmpeg', 'used_memory' => '312 MiB']]]; + + $this->assertSame([['pid' => 4242, 'memory' => '312 MiB']], SystemInfo::gpuProcesses($rInstance)); + } + + public function testAListOfGpuProcessesIsMappedInOrder(): void { + $rInstance = ['processes' => ['process_info' => [ + ['pid' => '11', 'used_memory' => '100 MiB'], + ['pid' => '12', 'used_memory' => '200 MiB'], + ]]]; + + $this->assertSame( + [['pid' => 11, 'memory' => '100 MiB'], ['pid' => 12, 'memory' => '200 MiB']], + SystemInfo::gpuProcesses($rInstance) + ); + } + + public function testNoGpuProcessesIsAnEmptyList(): void { + $this->assertSame([], SystemInfo::gpuProcesses([])); + // An empty element decodes to an empty array. + $this->assertSame([], SystemInfo::gpuProcesses(['processes' => []])); + $this->assertSame([], SystemInfo::gpuProcesses(['processes' => 'N/A'])); + $this->assertSame([], SystemInfo::gpuProcesses(['processes' => ['process_info' => []]])); + } + + public function testGpuProcessesDecodedFromNvidiaSmiXml(): void { + $rXML = '' + . '777Cffmpeg150 MiB' + . ''; + $rGPU = json_decode(json_encode(simplexml_load_string($rXML)), true)['gpu']; + + $this->assertSame([['pid' => 777, 'memory' => '150 MiB']], SystemInfo::gpuProcesses($rGPU)); + } + + public function testGetStatsUsesTheGuardedHelpers(): void { + $rSource = (string) file_get_contents(MAIN_HOME . 'Core/Util/SystemInfo.php'); + + $this->assertStringContainsString("self::memUsedPercent(\$rJSON['total_mem_used'], \$rJSON['total_mem'])", $rSource); + $this->assertStringContainsString("\$rArray['processes'] = self::gpuProcesses(\$rInstance);", $rSource); + $this->assertStringNotContainsString("\$rInstance['processes']['process_info'] as", $rSource); + } +} diff --git a/tests/Unit/SystemInfoNetworkTest.php b/tests/Unit/SystemInfoNetworkTest.php new file mode 100644 index 00000000..ecc85f93 --- /dev/null +++ b/tests/Unit/SystemInfoNetworkTest.php @@ -0,0 +1,99 @@ +rRoot = sys_get_temp_dir() . '/xcvm_sysnet_' . uniqid('', true) . '/'; + $this->iface('eth0', 1000, 10, '1000'); + $this->iface('eth1', 2000, 20, '10000'); + } + + protected function tearDown(): void { + foreach (glob($this->rRoot . '*/statistics/*') ?: [] as $rFile) { + unlink($rFile); + } + foreach (glob($this->rRoot . '*/speed') ?: [] as $rFile) { + unlink($rFile); + } + foreach (glob($this->rRoot . '*/statistics') ?: [] as $rDir) { + rmdir($rDir); + } + foreach (glob($this->rRoot . '*') ?: [] as $rDir) { + rmdir($rDir); + } + @rmdir($this->rRoot); + } + + private function iface(string $rName, ?int $rTx, ?int $rRx, ?string $rSpeed): void { + mkdir($this->rRoot . $rName . '/statistics', 0755, true); + if ($rTx !== null) { + file_put_contents($this->rRoot . $rName . '/statistics/tx_bytes', $rTx . "\n"); + } + if ($rRx !== null) { + file_put_contents($this->rRoot . $rName . '/statistics/rx_bytes', $rRx . "\n"); + } + if ($rSpeed !== null) { + file_put_contents($this->rRoot . $rName . '/speed', $rSpeed . "\n"); + } + } + + private static function rates(int $rIn, int $rOut): array { + return ['in_bytes' => $rIn, 'in_packets' => 0, 'in_errors' => 0, 'out_bytes' => $rOut, 'out_packets' => 0, 'out_errors' => 0]; + } + + public function testTotalsAreSummedOverTheSelectedInterfaces(): void { + $rNetwork = SystemInfo::aggregateNetwork(['eth0' => self::rates(1, 2), 'eth1' => self::rates(3, 4)], $this->rRoot); + + $this->assertSame(6, $rNetwork['bytes_sent']); + $this->assertSame(4, $rNetwork['bytes_received']); + $this->assertSame(3000, $rNetwork['bytes_sent_total']); + $this->assertSame(30, $rNetwork['bytes_received_total'], 'received is rx_bytes, not tx_bytes'); + } + + public function testNetworkSpeedIsTheFirstPositiveSpeed(): void { + $rNetwork = SystemInfo::aggregateNetwork(['eth0' => self::rates(1, 2), 'eth1' => self::rates(3, 4)], $this->rRoot); + $this->assertSame(1000, $rNetwork['network_speed']); + + // A down or virtual interface reports -1 (or nothing); the next one counts. + $this->iface('veth0', 5, 5, '-1'); + $rNetwork = SystemInfo::aggregateNetwork(['veth0' => self::rates(0, 0), 'eth1' => self::rates(0, 0)], $this->rRoot); + $this->assertSame(10000, $rNetwork['network_speed']); + } + + public function testReturnsTheHeartbeatKeysInOrder(): void { + $this->assertSame( + ['bytes_sent', 'bytes_sent_total', 'bytes_received', 'bytes_received_total', 'network_speed'], + array_keys(SystemInfo::aggregateNetwork([], $this->rRoot)) + ); + $this->assertSame([0, 0, 0, 0, 0], array_values(SystemInfo::aggregateNetwork([], $this->rRoot))); + } + + public function testAMissingStatisticsFileCountsAsZero(): void { + $this->iface('eth2', null, 7, null); + $rNetwork = SystemInfo::aggregateNetwork(['eth2' => self::rates(1, 1), 'gone0' => self::rates(1, 1)], $this->rRoot); + + $this->assertSame(0, $rNetwork['bytes_sent_total']); + $this->assertSame(7, $rNetwork['bytes_received_total']); + $this->assertSame(0, $rNetwork['network_speed']); + $this->assertSame(2, $rNetwork['bytes_sent']); + } + + public function testGetStatsTakesItsBandwidthFromAggregateNetwork(): void { + $rSource = (string) file_get_contents(MAIN_HOME . 'Core/Util/SystemInfo.php'); + + $this->assertStringContainsString('self::aggregateNetwork($rJSON[\'network_info\'])', $rSource); + $this->assertStringNotContainsString("\$rJSON['bytes_sent_total'] = (", $rSource, 'the total must not be assigned per interface'); + } +}