Lines 85.71% 30 / 35
Functions and Methods 71.42% 5 / 7
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
UnixSocketFile 85.71% 30 / 35 71.42% 5 / 7 20.05 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 prepareForListening 100.00% 8 / 8 100.00% 1 / 1 5
 identifyBoundSocket 100.00% 3 / 3 100.00% 1 / 1 2
 removeIfStillBound 66.66% 6 / 9 0.00% 0 / 1 4.59
 createDirectoryFor 66.66% 4 / 6 0.00% 0 / 1 3.33
 hasListener 100.00% 5 / 5 100.00% 1 / 1 2
 removeStaleSocket 100.00% 3 / 3 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Control\Server;
6
7use Psr\Log\LoggerInterface;
8use Stewart\Runtime\Exception\ControlError;
9use Stewart\Runtime\Exception\ControlException;
10use Symfony\Component\Filesystem\Exception\IOException;
11use Symfony\Component\Filesystem\Filesystem;
12
13final readonly class UnixSocketFile
14{
15    private const int SOCKET_DIRECTORY_MODE = 0o700;
16
17    private const float LISTENER_PROBE_TIMEOUT_SECONDS = 1.0;
18
19    private const array LINUX_ENOENT_AND_ECONNREFUSED = [2, 111];
20
21    public function __construct(
22        private Filesystem $filesystem,
23        private LoggerInterface $logger,
24    ) {}
25
26    /** @throws ControlException */
27    public function prepareForListening(string $path): void
28    {
29        $this->createDirectoryFor($path);
30
31        if (!file_exists($path) && !is_link($path)) {
32            return;
33        }
34
35        if (filetype($path) !== 'socket') {
36            throw ControlException::createForReason(ControlError::SocketPathNotASocket, path: $path);
37        }
38
39        if ($this->hasListener($path)) {
40            throw ControlException::createForReason(ControlError::SocketInUse, address: 'unix://' . $path);
41        }
42
43        $this->removeStaleSocket($path);
44    }
45
46    public function identifyBoundSocket(string $path): ?BoundSocketFile
47    {
48        clearstatcache(true, $path);
49        $stat = @stat($path);
50
51        return $stat === false ? null : new BoundSocketFile($path, $stat['dev'], $stat['ino']);
52    }
53
54    public function removeIfStillBound(BoundSocketFile $socket): void
55    {
56        $current = $this->identifyBoundSocket($socket->path);
57
58        if ($current === null) {
59            return;
60        }
61
62        if (!$current->isSameFileAs($socket)) {
63            $this->logger->warning('Left the control socket in place because another process replaced it', ['path' => $socket->path]);
64
65            return;
66        }
67
68        try {
69            $this->filesystem->remove($socket->path);
70        } catch (IOException $e) {
71            $this->logger->warning('Could not remove the control socket', ['path' => $socket->path, 'exception' => $e]);
72        }
73    }
74
75    /** @throws ControlException */
76    private function createDirectoryFor(string $path): void
77    {
78        $directory = \dirname($path);
79
80        if (is_dir($directory)) {
81            return;
82        }
83
84        try {
85            $this->filesystem->mkdir($directory, self::SOCKET_DIRECTORY_MODE);
86        } catch (IOException $e) {
87            throw ControlException::createForReason(ControlError::SocketDirectoryNotCreatable, $e, directory: $directory);
88        }
89    }
90
91    // A timeout or any unknown error counts as a listener: deleting a live daemon's socket is worse than refusing to start.
92    private function hasListener(string $path): bool
93    {
94        $probe = @stream_socket_client('unix://' . $path, $errno, $message, self::LISTENER_PROBE_TIMEOUT_SECONDS);
95
96        if ($probe === false) {
97            return !\in_array($errno, self::LINUX_ENOENT_AND_ECONNREFUSED, true);
98        }
99
100        fclose($probe);
101
102        return true;
103    }
104
105    /** @throws ControlException */
106    private function removeStaleSocket(string $path): void
107    {
108        try {
109            $this->filesystem->remove($path);
110        } catch (IOException $e) {
111            throw ControlException::createForReason(ControlError::SocketUnremovable, $e, path: $path);
112        }
113    }
114}