Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.88% covered (warning)
87.88%
87 / 99
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessManager
87.88% covered (warning)
87.88%
87 / 99
72.22% covered (warning)
72.22%
13 / 18
52.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 unregister
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 listRunning
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 countRunning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findRunningPid
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 stopAll
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 stopPid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSourceHint
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 findListeningPids
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRunning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hydrateFromFallback
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
11.30
 getPortFromHint
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 getSourceHint
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRegistry
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 saveRegistry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHints
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 saveHints
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Process;
10
11use OCA\Libresign\AppInfo\Application;
12use OCP\IAppConfig;
13use Psr\Log\LoggerInterface;
14
15class ProcessManager {
16    private const string APP_CONFIG_KEY = 'process_registry';
17    private const string APP_CONFIG_HINTS_KEY = 'process_registry_hints';
18
19    private ProcessSignaler $signaler;
20    private ListeningPidResolver $pidResolver;
21
22    public function __construct(
23        private IAppConfig $appConfig,
24        private LoggerInterface $logger,
25        ?ProcessSignaler $signaler = null,
26        ?ListeningPidResolver $pidResolver = null,
27    ) {
28        $this->signaler = $signaler ?? new ProcessSignaler($logger);
29        $this->pidResolver = $pidResolver ?? new ListeningPidResolver();
30    }
31
32    /**
33     * @param array<string, scalar> $context
34     */
35    public function register(string $source, int $pid, array $context = []): void {
36        if ($pid <= 0) {
37            return;
38        }
39
40        $registry = $this->getRegistry();
41        $registry[$source][(string)$pid] = [
42            'pid' => $pid,
43            'context' => $context,
44            'createdAt' => time(),
45        ];
46        $this->saveRegistry($registry);
47        $this->setSourceHint($source, $context);
48    }
49
50    public function unregister(string $source, int $pid): void {
51        $registry = $this->getRegistry();
52        unset($registry[$source][(string)$pid]);
53        $this->saveRegistry($registry);
54    }
55
56    /**
57     * @return array<int, array{pid: int, context: array<string, scalar>, createdAt: int}>
58     */
59    public function listRunning(string $source): array {
60        $registry = $this->getRegistry();
61        $entries = $registry[$source] ?? [];
62        $running = [];
63        $changed = false;
64
65        foreach ($entries as $pidKey => $entry) {
66            $pid = (int)($entry['pid'] ?? 0);
67            if ($pid <= 0 || !$this->isRunning($pid)) {
68                unset($registry[$source][$pidKey]);
69                $changed = true;
70                continue;
71            }
72
73            $running[] = [
74                'pid' => $pid,
75                'context' => is_array($entry['context'] ?? null) ? $entry['context'] : [],
76                'createdAt' => (int)($entry['createdAt'] ?? 0),
77            ];
78        }
79
80        if ($changed) {
81            $this->saveRegistry($registry);
82        }
83
84        return $running;
85    }
86
87    public function countRunning(string $source): int {
88        return count($this->listRunning($source));
89    }
90
91    /**
92     * @param null|callable(array{pid: int, context: array<string, scalar>, createdAt: int}): bool $filter
93     */
94    public function findRunningPid(string $source, ?callable $filter = null): int {
95        $entries = $this->listRunning($source);
96        foreach ($entries as $entry) {
97            if ($filter !== null && !$filter($entry)) {
98                continue;
99            }
100            return $entry['pid'];
101        }
102
103        $this->hydrateFromFallback($source);
104
105        foreach ($this->listRunning($source) as $entry) {
106            if ($filter !== null && !$filter($entry)) {
107                continue;
108            }
109            return $entry['pid'];
110        }
111
112        return 0;
113    }
114
115    public function stopAll(string $source, int $signal = SIGTERM): int {
116        $stopped = 0;
117        foreach ($this->listRunning($source) as $entry) {
118            if ($this->stopPid($entry['pid'], $signal)) {
119                $stopped++;
120            }
121            $this->unregister($source, $entry['pid']);
122        }
123
124        return $stopped;
125    }
126
127    public function stopPid(int $pid, int $signal = SIGTERM): bool {
128        return $this->signaler->stopPid($pid, $signal);
129    }
130
131    /**
132     * @param array<string, scalar> $context
133     */
134    public function setSourceHint(string $source, array $context): void {
135        if ($source === '' || $context === []) {
136            return;
137        }
138
139        $hints = $this->getHints();
140        $hints[$source] = $context;
141        $this->saveHints($hints);
142    }
143
144    /**
145     * @return int[]
146     */
147    public function findListeningPids(int $port): array {
148        return $this->pidResolver->findListeningPids($port);
149    }
150
151    public function isRunning(int $pid): bool {
152        return $this->signaler->isRunning($pid);
153    }
154
155    private function hydrateFromFallback(string $source): void {
156        $hint = $this->getSourceHint($source);
157        if (!is_array($hint) || $hint === []) {
158            return;
159        }
160
161        $port = $this->getPortFromHint($hint);
162        if ($port <= 0) {
163            return;
164        }
165
166        try {
167            $pids = $this->findListeningPids($port);
168        } catch (\RuntimeException $e) {
169            $this->logger->debug('Unable to hydrate process registry from fallback', [
170                'source' => $source,
171                'port' => $port,
172                'error' => $e->getMessage(),
173            ]);
174            return;
175        }
176
177        foreach ($pids as $pid) {
178            if ($pid <= 0) {
179                continue;
180            }
181            $this->register($source, $pid, $hint);
182        }
183    }
184
185    /**
186     * @param array<string, scalar> $hint
187     */
188    private function getPortFromHint(array $hint): int {
189        if (isset($hint['port']) && is_numeric((string)$hint['port'])) {
190            $port = (int)$hint['port'];
191            if ($port > 0) {
192                return $port;
193            }
194        }
195
196        if (isset($hint['uri']) && is_string($hint['uri'])) {
197            return (int)(parse_url($hint['uri'], PHP_URL_PORT) ?? 0);
198        }
199
200        return 0;
201    }
202
203    /**
204     * @return array<string, scalar>
205     */
206    private function getSourceHint(string $source): array {
207        $hints = $this->getHints();
208        $hint = $hints[$source] ?? [];
209        return is_array($hint) ? $hint : [];
210    }
211
212    /**
213     * @return array<string, array<string, array{pid: int, context: array<string, scalar>, createdAt: int}>>
214     */
215    private function getRegistry(): array {
216        $raw = $this->appConfig->getValueString(Application::APP_ID, self::APP_CONFIG_KEY, '{}');
217        $decoded = json_decode($raw, true);
218        if (!is_array($decoded)) {
219            return [];
220        }
221
222        return $decoded;
223    }
224
225    /**
226     * @param array<string, array<string, array{pid: int, context: array<string, scalar>, createdAt: int}>> $registry
227     */
228    private function saveRegistry(array $registry): void {
229        $this->appConfig->setValueString(Application::APP_ID, self::APP_CONFIG_KEY, json_encode($registry));
230    }
231
232    /**
233     * @return array<string, array<string, scalar>>
234     */
235    private function getHints(): array {
236        $raw = $this->appConfig->getValueString(Application::APP_ID, self::APP_CONFIG_HINTS_KEY, '{}');
237        $decoded = json_decode($raw, true);
238        if (!is_array($decoded)) {
239            return [];
240        }
241
242        return $decoded;
243    }
244
245    /**
246     * @param array<string, array<string, scalar>> $hints
247     */
248    private function saveHints(array $hints): void {
249        $this->appConfig->setValueString(Application::APP_ID, self::APP_CONFIG_HINTS_KEY, json_encode($hints));
250    }
251
252}