From 4a684a51344b72ecdbdb27fb2b6efd22c78b7bb8 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Fri, 9 Oct 2026 19:26:29 -0400 Subject: [PATCH 1/4] fix: cache that a stack has no compose profiles getProfiles() caches the profiles docker compose config --profiles reports in the stack's profiles file, but only when there are some. For a stack without profiles (most stacks) the command printed nothing, nothing was cached, and it ran again on every read: on every load of the stack list, once per stack, about 0.1 s each. An empty answer is now cached too. A command that fails (compose cannot read the stack, or docker is missing) is still not cached, so the next read asks again; the exit status now tells the two apart, where an empty output did not. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/Util.php | 18 ++++++--- tests/unit/StackInfoTest.php | 53 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index e4f9fe8d..a0c93908 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -3395,8 +3395,8 @@ private function isProfilesCacheStale(): bool * Extract available profiles via `docker compose config --profiles`. * * Mirrors the approach used by {@see getDefinedServices()}. On success - * the result is written back to the profiles metadata file so subsequent - * reads hit the fast-path cache. + * the result, even an empty one, is written back to the profiles metadata + * file so subsequent reads hit the fast-path cache. * * @return string[] */ @@ -3413,17 +3413,23 @@ private function extractProfilesFromCompose(): array } $cmd .= " config --profiles 2>/dev/null"; - $output = shell_exec($cmd); - if (!is_string($output) || trim($output) === '') { + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + if ($exitCode !== 0) { + // Compose could not read the stack (a broken compose file, say): nothing is + // cached, so the next read asks again once it is fixed. return []; } $profiles = array_values(array_filter( - array_map('trim', explode("\n", trim($output))), + array_map('trim', $output), fn(string $p): bool => $p !== '' )); - // Write-through: persist so future reads hit the cache. + // Write-through: persist so future reads hit the cache. An empty list is cached + // too: most stacks have no profiles, and asking compose again costs about 0.1 s + // per stack on every load of the stack list. $this->writeMetadata('profiles', json_encode($profiles)); return $profiles; diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index 2496f44e..b2deeaa7 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -1011,6 +1011,59 @@ public function testGetProfilesEmptyWhenNoFile(): void $this->assertSame([], $info->getProfiles()); } + public function testAStackWithoutProfilesAsksComposeOnlyOnce(): void + { + $stackDir = $this->tempRoot . '/no-profiles'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n"); + // docker compose config --profiles prints nothing for a stack without profiles. + $calls = $this->withDockerStandIn(0, function () use ($stackDir): void { + $this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'no-profiles')->getProfiles()); + \StackInfo::clearCache(); + $this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'no-profiles')->getProfiles()); + }); + + $this->assertSame(1, $calls); + $this->assertSame('[]', file_get_contents($stackDir . '/profiles')); + } + + public function testProfilesAreNotCachedWhenComposeCannotReadTheStack(): void + { + $stackDir = $this->tempRoot . '/broken'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n"); + $calls = $this->withDockerStandIn(1, function (): void { + $this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'broken')->getProfiles()); + \StackInfo::clearCache(); + $this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'broken')->getProfiles()); + }); + + // Asked again, since the failure might be fixed by the next read. + $this->assertSame(2, $calls); + $this->assertFileDoesNotExist($stackDir . '/profiles'); + } + + /** + * Run $work with a docker on PATH that prints nothing and exits with $exitCode, + * and return how many times it was run. + */ + private function withDockerStandIn(int $exitCode, callable $work): int + { + $binDir = $this->tempRoot . '/bin'; + $callsFile = $this->tempRoot . '/docker-calls'; + mkdir($binDir); + file_put_contents($binDir . '/docker', "#!/bin/sh\necho call >> " . escapeshellarg($callsFile) . "\nexit $exitCode\n"); + chmod($binDir . '/docker', 0755); + $originalPath = (string) getenv('PATH'); + putenv('PATH=' . $binDir . ':' . $originalPath); + try { + $work(); + } finally { + putenv('PATH=' . $originalPath); + } + return is_file($callsFile) ? count(file($callsFile)) : 0; + } + public function testGetProfilesEmptyForInvalidJson(): void { $stack = 'bad-json'; From aa5e019746c9a158703c9e8a7039b6655d91b90c Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Fri, 9 Oct 2026 19:38:04 -0400 Subject: [PATCH 2/4] fix: list a stack row's containers with docker ps, as the full list does The stack list loads its rows one by one (ComposeList.php mode=row), and each row asked docker compose ps --all for the stack's containers. Compose reads the stack's compose files first, so that took 0.1 to 0.15 s per stack on every load of the page. The full list (StackInfo::allFromRoot()) already uses one docker ps call filtered on the compose project label, which takes about 30 ms. A row now gets its containers the same way, through StackInfo::containersByProject(), which allFromRoot() uses too. The rows are the same docker ps rows the full list renders from. Co-Authored-By: Claude Opus 5.5 --- .../compose.manager/include/ComposeList.php | 7 ++++- source/compose.manager/include/Util.php | 31 ++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index a41c4c6d..75b8064e 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -176,11 +176,16 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str exit; } try { - $stackInfos = [StackInfo::fromProject($compose_root, $project)]; + $stackInfo = StackInfo::fromProject($compose_root, $project); } catch (\Throwable $e) { echo json_encode(composeRowBuildFailurePayload($compose_root, $project, $e->getMessage())); exit; } + // The stack's containers from one docker ps call, as the full list gets them + // (StackInfo::allFromRoot()), rather than docker compose ps, which reads the + // compose files first and takes several times as long for every row. + $stackInfo->setContainerList(StackInfo::containersByProject($stackInfo->projectName)[$stackInfo->projectName] ?? []); + $stackInfos = [$stackInfo]; } else { $stackInfos = StackInfo::allFromRoot($compose_root); } diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index a0c93908..ada7d717 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -4203,8 +4203,30 @@ public static function allFromRoot(string $composeRoot, bool $skipDocker = false // Batch-preload container data with a single docker ps call to avoid // O(n) docker invocations when callers iterate getContainerList(). + $containersByProject = self::containersByProject(); + foreach ($stacks as $stack) { + $key = $stack->projectName; + $stack->setContainerList($containersByProject[$key] ?? []); + } + + return $stacks; + } + + /** + * The containers of compose projects, from a single `docker ps` call, by project name. + * + * Rows are `docker ps --format json` rows, filtered on the compose project label. + * That is much quicker than `docker compose ps` for each stack, which has to read + * the stack's compose files first. Pass a project name for that project's only. + * + * @param string|null $projectName One project, or null for every compose project + * @return array Rows by project name + */ + public static function containersByProject(?string $projectName = null): array + { + $filter = 'label=com.docker.compose.project' . ($projectName === null ? '' : '=' . $projectName); $containersByProject = []; - $psOutput = shell_exec("docker ps -a --filter label=com.docker.compose.project --format json 2>/dev/null"); + $psOutput = shell_exec('docker ps -a --filter ' . escapeshellarg($filter) . ' --format json 2>/dev/null'); if ($psOutput) { foreach (explode("\n", trim($psOutput)) as $line) { if ($line === '') { @@ -4221,12 +4243,7 @@ public static function allFromRoot(string $composeRoot, bool $skipDocker = false } } } - foreach ($stacks as $stack) { - $key = $stack->projectName; - $stack->setContainerList($containersByProject[$key] ?? []); - } - - return $stacks; + return $containersByProject; } /** From 0ca9351796248469620d5bec57bcffcee928024d Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 12 Oct 2026 11:28:57 -0400 Subject: [PATCH 3/4] test: cover how containersByProject groups and filters docker ps rows The stack list's rows get their containers from StackInfo::containersByProject(), but the existing tests hand StackInfo its rows with setContainerList(), so nothing checked the docker ps call itself. A mistake in the label filter or in reading the project from Labels would show every affected stack as stopped. The docker stand-in on PATH now prints given output and records each call's arguments. Two tests use it: one with containers of two projects, where the project label is not always first and another compose label starts the same way, and one asking for a single project. Co-Authored-By: Claude Opus 5.5 --- tests/unit/StackInfoTest.php | 54 +++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index b2deeaa7..d7a9a436 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -1023,7 +1023,7 @@ public function testAStackWithoutProfilesAsksComposeOnlyOnce(): void $this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'no-profiles')->getProfiles()); }); - $this->assertSame(1, $calls); + $this->assertCount(1, $calls); $this->assertSame('[]', file_get_contents($stackDir . '/profiles')); } @@ -1039,20 +1039,60 @@ public function testProfilesAreNotCachedWhenComposeCannotReadTheStack(): void }); // Asked again, since the failure might be fixed by the next read. - $this->assertSame(2, $calls); + $this->assertCount(2, $calls); $this->assertFileDoesNotExist($stackDir . '/profiles'); } + public function testContainersByProjectGroupsEveryProjectsContainersByItsLabel(): void + { + // docker ps --format json prints one row per line; Labels is one comma-separated string. + $psOutput = implode("\n", [ + json_encode(['Names' => 'web-app-1', 'Labels' => 'com.docker.compose.project=web,com.docker.compose.service=app']), + // The project label is not always first, and other compose labels start the same way. + json_encode(['Names' => 'web-db-1', 'Labels' => 'com.docker.compose.project.working_dir=/mnt/web,com.docker.compose.project=web,com.docker.compose.service=db']), + json_encode(['Names' => 'media-plex-1', 'Labels' => 'com.docker.compose.service=plex,com.docker.compose.project=media']), + ]) . "\n"; + $byProject = []; + $calls = $this->withDockerStandIn(0, function () use (&$byProject): void { + $byProject = \StackInfo::containersByProject(); + }, $psOutput); + + $this->assertSame(['web', 'media'], array_keys($byProject)); + $this->assertSame(['web-app-1', 'web-db-1'], array_column($byProject['web'], 'Names')); + $this->assertSame(['media-plex-1'], array_column($byProject['media'], 'Names')); + // Every compose project: the label filter has no value. + $this->assertSame(['ps -a --filter label=com.docker.compose.project --format json'], $calls); + } + + public function testContainersByProjectAsksDockerForOneProjectOnly(): void + { + $psOutput = json_encode(['Names' => 'web-app-1', 'Labels' => 'com.docker.compose.project=web']) . "\n"; + $byProject = []; + $calls = $this->withDockerStandIn(0, function () use (&$byProject): void { + $byProject = \StackInfo::containersByProject('web'); + }, $psOutput); + + $this->assertSame(['web'], array_keys($byProject)); + $this->assertSame(['ps -a --filter label=com.docker.compose.project=web --format json'], $calls); + } + /** - * Run $work with a docker on PATH that prints nothing and exits with $exitCode, - * and return how many times it was run. + * Run $work with a docker on PATH that prints $output and exits with $exitCode, + * and return the arguments of every call made to it, one line per call. + * + * @return string[] */ - private function withDockerStandIn(int $exitCode, callable $work): int + private function withDockerStandIn(int $exitCode, callable $work, string $output = ''): array { $binDir = $this->tempRoot . '/bin'; $callsFile = $this->tempRoot . '/docker-calls'; + $outputFile = $this->tempRoot . '/docker-output'; mkdir($binDir); - file_put_contents($binDir . '/docker', "#!/bin/sh\necho call >> " . escapeshellarg($callsFile) . "\nexit $exitCode\n"); + file_put_contents($outputFile, $output); + file_put_contents( + $binDir . '/docker', + "#!/bin/sh\necho \"\$*\" >> " . escapeshellarg($callsFile) . "\ncat " . escapeshellarg($outputFile) . "\nexit $exitCode\n" + ); chmod($binDir . '/docker', 0755); $originalPath = (string) getenv('PATH'); putenv('PATH=' . $binDir . ':' . $originalPath); @@ -1061,7 +1101,7 @@ private function withDockerStandIn(int $exitCode, callable $work): int } finally { putenv('PATH=' . $originalPath); } - return is_file($callsFile) ? count(file($callsFile)) : 0; + return is_file($callsFile) ? file($callsFile, FILE_IGNORE_NEW_LINES) : []; } public function testGetProfilesEmptyForInvalidJson(): void From 229c847cc2dd5ead5df8fc5fa4073679f193598f Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 12 Oct 2026 11:28:58 -0400 Subject: [PATCH 4/4] docs: say plainly what containersByProject returns for one project Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/Util.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index ada7d717..3b9eb32a 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -4217,7 +4217,8 @@ public static function allFromRoot(string $composeRoot, bool $skipDocker = false * * Rows are `docker ps --format json` rows, filtered on the compose project label. * That is much quicker than `docker compose ps` for each stack, which has to read - * the stack's compose files first. Pass a project name for that project's only. + * the stack's compose files first. Pass a project name to get only that project's + * containers. * * @param string|null $projectName One project, or null for every compose project * @return array Rows by project name