Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Improved
- extracted system memory probing from TableOutput into a dedicated SystemMemoryReader


### Fixed
- table output memory reporting on BusyBox environments without `ps -p`

## [1.1.0] - 2026-02-12
### Added
Expand Down
64 changes: 64 additions & 0 deletions src/Orchestrator/Output/SystemMemoryReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Multitron\Orchestrator\Output;

final class SystemMemoryReader
{
public function getCurrentProcessUsage(): int
{
$pid = getmypid();
if ($pid !== false) {
$rss = $this->procMemoryUsage($pid) ?? $this->psMemoryUsage($pid);
if ($rss !== null) {
return $rss;
}
}

return memory_get_usage(true);
}
Comment thread
riki137 marked this conversation as resolved.

public function getFreeMemory(): ?int
{
if (!is_readable('/proc/meminfo')) {
return null;
}

$data = file_get_contents('/proc/meminfo');
if (preg_match('/MemAvailable:\s+(\d+)/', (string)$data, $m)) {
return (int)$m[1] * 1024;
}

return null;
}

private function procMemoryUsage(int $pid): ?int
{
$statusFile = '/proc/' . $pid . '/status';
if (!is_readable($statusFile)) {
return null;
}

$data = file_get_contents($statusFile);
if (!is_string($data) || $data === '') {
return null;
}

if (preg_match('/^VmRSS:\s+(\d+)\s+kB$/mi', $data, $m)) {
return (int)$m[1] * 1024;
}

return null;
}

private function psMemoryUsage(int $pid): ?int
{
$out = @shell_exec('ps -o rss= -p ' . $pid);
if (preg_match('/^\s*(\d+)\s*$/', (string)$out, $m)) {
return (int)$m[1] * 1024;
}

return null;
}
}
31 changes: 6 additions & 25 deletions src/Orchestrator/Output/TableOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ final class TableOutput implements ProgressOutput

private readonly TableRenderer $renderer;

private readonly SystemMemoryReader $memoryReader;

/** @var array<string, TaskState> */
private array $states = [];

Expand All @@ -35,13 +37,15 @@ public function __construct(
TaskList $taskList,
private readonly bool $interactive,
private readonly int $lowMemoryWarning,
?SystemMemoryReader $memoryReader = null,
) {
if ($interactive && $output instanceof ConsoleOutputInterface) {
$this->section = $output->section();
} else {
$this->section = $output;
}
$this->renderer = new TableRenderer($taskList);
$this->memoryReader = $memoryReader ?? new SystemMemoryReader();
}

public function onTaskStarted(TaskState $state): void
Expand Down Expand Up @@ -90,7 +94,7 @@ private function buildSectionBuffer(): array
$this->attachMemoryWarning($sectionBuffer);
$sectionBuffer[] = $this->renderer->getSummaryRow(
$partiallyDone,
self::memoryUsage(),
$this->memoryReader->getCurrentProcessUsage(),
$workersMem,
);

Expand Down Expand Up @@ -125,29 +129,6 @@ public function render(): void
}
}

private static function memoryUsage(): int
{
$pid = getmypid();
$out = @shell_exec('ps -o rss= -p ' . $pid);
if (is_string($out) && trim($out) !== '') {
return (int)trim($out) * 1024;
}

return memory_get_usage(true);
}

private static function freeMemory(): ?int
{
if (is_readable('/proc/meminfo')) {
$data = file_get_contents('/proc/meminfo');
if (preg_match('/MemAvailable:\s+(\d+)/', (string)$data, $m)) {
return (int)$m[1] * 1024;
}
}

return null;
}

public function __destruct()
{
if ($this->interactive) {
Expand All @@ -167,7 +148,7 @@ private function attachMemoryWarning(array &$buffer): void
if ($this->lowMemoryWarning < 1) {
return;
}
$freeMem = self::freeMemory();
$freeMem = $this->memoryReader->getFreeMemory();
if ($freeMem !== null && $freeMem < ($this->lowMemoryWarning * self::MB)) {
$buffer[] =
$this->renderer->getRowLabel('LOW MEMORY', TaskStatus::SKIP) .
Expand Down
2 changes: 1 addition & 1 deletion src/Orchestrator/Output/TableOutputFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function create(TaskList $taskList, OutputInterface $output, IpcHandlerRe
throw new InvalidArgumentException('Low memory warning must be a positive integer.');
}

$table = new TableOutput($output, $taskList, $interactive, $lowMemoryWarning);
$table = new TableOutput($output, $taskList, $interactive, $lowMemoryWarning, new SystemMemoryReader());
$registry->onMessage(function (Message $message, TaskState $state) use ($table) {
if ($message instanceof LogMessage) {
$table->log($state, $message->message);
Expand Down
150 changes: 150 additions & 0 deletions tests/Unit/SystemMemoryReaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

declare(strict_types=1);

namespace Multitron\Orchestrator\Output {
function getmypid(): int|false
{
return \Multitron\Tests\Unit\SystemMemoryReaderTest::$pid;
}

function is_readable(string $filename): bool
{
return \Multitron\Tests\Unit\SystemMemoryReaderTest::$readableFiles[$filename] ?? false;
}

function file_get_contents(string $filename): string|false
{
return \Multitron\Tests\Unit\SystemMemoryReaderTest::$fileContents[$filename] ?? false;
}

function shell_exec(string $command): string
{
\Multitron\Tests\Unit\SystemMemoryReaderTest::$shellCommands[] = $command;
return \Multitron\Tests\Unit\SystemMemoryReaderTest::$shellOutput;
}

function memory_get_usage(bool $realUsage = false): int
{
return \Multitron\Tests\Unit\SystemMemoryReaderTest::$memoryFallback;
}
}
Comment thread
riki137 marked this conversation as resolved.

namespace Multitron\Tests\Unit {

use Multitron\Orchestrator\Output\SystemMemoryReader;
use PHPUnit\Framework\TestCase;

final class SystemMemoryReaderTest extends TestCase
{
public static int|false $pid = 4321;

/** @var array<string, bool> */
public static array $readableFiles = [];

/** @var array<string, string> */
public static array $fileContents = [];

/** @var string[] */
public static array $shellCommands = [];

public static string $shellOutput = '';

public static int $memoryFallback = 0;

protected function setUp(): void
{
$this->resetDoubles();
}

protected function tearDown(): void
{
$this->resetDoubles();
}

public function testCurrentProcessUsagePrefersProcStatusOverPs(): void
{
$statusFile = '/proc/4321/status';
self::$readableFiles[$statusFile] = true;
self::$fileContents[$statusFile] = "Name:\tphp\nVmRSS:\t12345 kB\n";
self::$shellOutput = "99999\n";
self::$memoryFallback = 777;

$reader = new SystemMemoryReader();

$this->assertSame(12345 * 1024, $reader->getCurrentProcessUsage());
$this->assertSame([], self::$shellCommands);
}

public function testCurrentProcessUsageFallsBackToPsWhenProcIsUnavailable(): void
{
self::$shellOutput = "23456\n";
self::$memoryFallback = 777;

$reader = new SystemMemoryReader();

$this->assertSame(23456 * 1024, $reader->getCurrentProcessUsage());
$this->assertSame(['ps -o rss= -p 4321'], self::$shellCommands);
}

public function testCurrentProcessUsageFallsBackToPhpAllocatorWhenSystemLookupsFail(): void
{
self::$shellOutput = "ps: unrecognized option: p\n";
self::$memoryFallback = 345678;

$reader = new SystemMemoryReader();

$this->assertSame(345678, $reader->getCurrentProcessUsage());
$this->assertSame(['ps -o rss= -p 4321'], self::$shellCommands);
}

public function testCurrentProcessUsageFallsBackToPhpAllocatorWhenPidLookupFails(): void
{
self::$pid = false;
self::$memoryFallback = 456789;

$reader = new SystemMemoryReader();

$this->assertSame(456789, $reader->getCurrentProcessUsage());
$this->assertSame([], self::$shellCommands);
}

public function testFreeMemoryReadsMemAvailableFromProcMeminfo(): void
{
self::$readableFiles['/proc/meminfo'] = true;
self::$fileContents['/proc/meminfo'] = "MemTotal: 1 kB\nMemAvailable: 2048 kB\n";

$reader = new SystemMemoryReader();

$this->assertSame(2048 * 1024, $reader->getFreeMemory());
}

public function testFreeMemoryReturnsNullWhenProcMeminfoIsUnavailable(): void
{
$reader = new SystemMemoryReader();

$this->assertNull($reader->getFreeMemory());
}

public function testFreeMemoryReturnsNullWhenMemAvailableIsMissing(): void
{
self::$readableFiles['/proc/meminfo'] = true;
self::$fileContents['/proc/meminfo'] = "MemTotal: 1 kB\nMemFree: 512 kB\n";

$reader = new SystemMemoryReader();

$this->assertNull($reader->getFreeMemory());
}

private function resetDoubles(): void
{
self::$pid = 4321;
self::$readableFiles = [];
self::$fileContents = [];
self::$shellCommands = [];
self::$shellOutput = '';
self::$memoryFallback = 0;
}
}

}