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'); + } +}