Lines 95.65% 22 / 23
Functions and Methods 80.00% 4 / 5
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
DoctorCommand 95.65% 22 / 23 80.00% 4 / 5 18 0.00% 0 / 1
 execute 100.00% 12 / 12 100.00% 1 / 1 11
 reportCheck 100.00% 2 / 2 100.00% 1 / 1 3
 reportInfo 100.00% 1 / 1 100.00% 1 / 1 1
 writeLine 100.00% 1 / 1 100.00% 1 / 1 1
 canBindUnixSocket 85.71% 6 / 7 0.00% 0 / 1 2.01
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Console;
6
7use Symfony\Component\Console\Attribute\AsCommand;
8use Symfony\Component\Console\Command\Command;
9use Symfony\Component\Console\Input\InputInterface;
10use Symfony\Component\Console\Output\OutputInterface;
11
12#[AsCommand(name: self::NAME, description: 'Check that this PHP can run Stewart')]
13final class DoctorCommand extends Command
14{
15    public const string NAME = 'doctor';
16
17    private const array REQUIRED_EXTENSIONS = [
18        'pcntl' => 'signal handling; without it graceful shutdown is impossible',
19        'json' => 'Home Assistant wire format',
20        'ctype' => 'config and cpu-count parsing',
21    ];
22
23    private const string PROBE_SOCKET_PREFIX = '/stewart-doctor-';
24
25    protected function execute(InputInterface $input, OutputInterface $output): int
26    {
27        // Composer's platform check already refused an older PHP before this command could load.
28        $output->writeln(\sprintf('PHP %s (%s)', \PHP_VERSION, \PHP_BINARY));
29        $healthy = true;
30
31        foreach (self::REQUIRED_EXTENSIONS as $extension => $purpose) {
32            $healthy = $this->reportCheck($output, $extension, \extension_loaded($extension), '', $purpose) && $healthy;
33        }
34
35        $this->reportInfo($output, 'zlib', \extension_loaded('zlib') ? 'websocket compression on' : 'absent; websocket compression off');
36        $this->reportInfo($output, 'xdebug', \extension_loaded('xdebug') ? 'loaded, mode=' . (\ini_get('xdebug.mode') ?: 'off') : 'absent (fine for production)');
37
38        $temporaryDirectory = sys_get_temp_dir();
39        $healthy = $this->reportCheck($output, 'tmpdir', is_dir($temporaryDirectory) && is_writable($temporaryDirectory), $temporaryDirectory, $temporaryDirectory . ' is not writable') && $healthy;
40        $healthy = $this->reportCheck($output, 'unix sock', $this->canBindUnixSocket($temporaryDirectory), 'can bind', 'cannot bind; workers will not spawn') && $healthy;
41
42        $this->reportInfo($output, 'memory', (string) \ini_get('memory_limit'));
43
44        $output->writeln($healthy ? 'All good.' : 'Problems found.');
45
46        return $healthy ? Command::SUCCESS : Command::FAILURE;
47    }
48
49    private function reportCheck(OutputInterface $output, string $label, bool $passed, string $passDetail, string $failDetail): bool
50    {
51        $this->writeLine($output, $label, $passed ? '✓' : '✗ FAIL', $passed ? $passDetail : $failDetail);
52
53        return $passed;
54    }
55
56    private function reportInfo(OutputInterface $output, string $label, string $detail): void
57    {
58        $this->writeLine($output, $label, '✓', $detail);
59    }
60
61    private function writeLine(OutputInterface $output, string $label, string $mark, string $detail): void
62    {
63        $output->writeln(rtrim(\sprintf('  %-10s %s  %s', $label, $mark, $detail)));
64    }
65
66    private function canBindUnixSocket(string $directory): bool
67    {
68        $path = $directory . self::PROBE_SOCKET_PREFIX . getmypid() . '.sock';
69        $server = @stream_socket_server('unix://' . $path);
70
71        if ($server === false) {
72            return false;
73        }
74
75        fclose($server);
76        @unlink($path);
77
78        return true;
79    }
80}