1<?php
2
3/**
4 * This file is part of the FreeDSx LDAP package.
5 *
6 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace FreeDSx\Ldap\Server\ServerRunner;
13
14use FreeDSx\Asn1\Exception\EncoderException;
15use FreeDSx\Ldap\Exception\RuntimeException;
16use FreeDSx\Ldap\LoggerTrait;
17use FreeDSx\Ldap\Protocol\Queue\ServerQueue;
18use FreeDSx\Ldap\Protocol\ServerProtocolHandler;
19use FreeDSx\Ldap\Server\ChildProcess;
20use FreeDSx\Ldap\Server\RequestHandler\HandlerFactory;
21use FreeDSx\Ldap\Server\RequestHandler\RequestHandlerInterface;
22use FreeDSx\Socket\Socket;
23use FreeDSx\Socket\SocketServer;
24
25/**
26 * Uses PNCTL to fork incoming requests and send them to the server protocol handler.
27 *
28 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
29 */
30class PcntlServerRunner implements ServerRunnerInterface
31{
32    use LoggerTrait;
33
34    /**
35     * The time to wait, in seconds, before we run some clean-up tasks to then wait again.
36     */
37    private const SOCKET_ACCEPT_TIMEOUT = 5;
38
39    /**
40     * The max time to wait (in seconds) for any child processes before we force kill them.
41     */
42    private const MAX_SHUTDOWN_WAIT_TIME = 15;
43
44    /**
45     * @var SocketServer
46     */
47    protected $server;
48
49    /**
50     * @var array
51     */
52    protected $options;
53
54    /**
55     * @var ChildProcess[]
56     */
57    protected $childProcesses = [];
58
59    /**
60     * @var bool
61     */
62    protected $isMainProcess = true;
63
64    /**
65     * @var int[] These are the POSIX signals we handle for shutdown purposes.
66     */
67    protected $handledSignals = [];
68
69    /**
70     * @var bool
71     */
72    protected $isPosixExtLoaded;
73
74    /**
75     * @var bool
76     */
77    protected $isServerSignalsInstalled = false;
78
79    /**
80     * @var bool
81     */
82    protected $isShuttingDown = false;
83
84    /**
85     * @var array<string, mixed>
86     */
87    protected $defaultContext = [];
88
89    /**
90     * @param array $options
91     * @psalm-param array{request_handler?: class-string<RequestHandlerInterface>} $options
92     * @throws RuntimeException
93     */
94    public function __construct(array $options = [])
95    {
96        if (!extension_loaded('pcntl')) {
97            throw new RuntimeException('The PCNTL extension is needed to fork incoming requests, which is only available on Linux.');
98        }
99        $this->options = $options;
100
101        // posix_kill needs this...we cannot clean up child processes without it on shutdown...
102        $this->isPosixExtLoaded = extension_loaded('posix');
103        // We need to be able to handle signals as they come in, regardless of what is going on...
104        pcntl_async_signals(true);
105
106        $this->handledSignals = [
107            SIGHUP,
108            SIGINT,
109            SIGTERM,
110            SIGQUIT,
111        ];
112        $this->defaultContext = [
113            'pid' => posix_getpid(),
114        ];
115    }
116
117    /**
118     * @throws EncoderException
119     */
120    public function run(SocketServer $server): void
121    {
122        $this->server = $server;
123
124        try {
125            $this->acceptClients();
126        } finally {
127            if ($this->isMainProcess) {
128                $this->handleServerShutdown();
129            }
130        }
131    }
132
133    /**
134     * Check each child process we have and see if it is stopped. This will clean up zombie processes.
135     */
136    private function cleanUpChildProcesses(): void
137    {
138        foreach ($this->childProcesses as $index => $childProcess) {
139            // No use for this at the moment, but define it anyway.
140            $status = null;
141
142            $result = pcntl_waitpid(
143                $childProcess->getPid(),
144                $status,
145                WNOHANG
146            );
147
148            if ($result === -1 || $result > 0) {
149                unset($this->childProcesses[$index]);
150                $socket = $childProcess->getSocket();
151                $this->server->removeClient($socket);
152                $socket->close();
153                $this->logInfo(
154                    'The child process has ended.',
155                    array_merge(
156                        $this->defaultContext,
157                        ['child_pid' => $childProcess->getPid()]
158                    )
159                );
160            }
161        }
162    }
163
164    /**
165     * Accept clients from the socket server in a loop with a timeout. This lets us to periodically check existing
166     * children processes as we listen for new ones.
167     */
168    private function acceptClients(): void
169    {
170        $this->logInfo(
171            'The server process has started and is now accepting clients.',
172            $this->defaultContext
173        );
174
175        do {
176            $socket = $this->server->accept(self::SOCKET_ACCEPT_TIMEOUT);
177
178            if ($this->isShuttingDown) {
179                if ($socket) {
180                    $this->logInfo(
181                        'A client was accepted, but the server is shutting down. Closing connection.',
182                        $this->defaultContext
183                    );
184                    $socket->close();
185                }
186
187                break;
188            }
189
190            // If there was no client received, we still want to clean up any children that have stopped.
191            if ($socket === null) {
192                $this->cleanUpChildProcesses();
193
194                continue;
195            }
196
197            $pid = pcntl_fork();
198            if ($pid == -1) {
199                // In parent process, but could not fork...
200                $this->logAndThrow(
201                    'Unable to fork process.',
202                    $this->defaultContext
203                );
204            } elseif ($pid === 0) {
205                // This is the child's thread of execution...
206                $this->runChildProcessThenExit(
207                    $socket,
208                    posix_getpid()
209                );
210            } else {
211                // We are in the parent; the PID is the child process.
212                $this->runAfterChildStarted(
213                    $pid,
214                    $socket
215                );
216            }
217        } while ($this->server->isConnected());
218    }
219
220    /**
221     * Install signal handlers responsible for sending a notice of disconnect to the client and stopping the queue.
222     */
223    private function installChildSignalHandlers(
224        ServerProtocolHandler $protocolHandler,
225        array $context
226    ): void {
227        foreach ($this->handledSignals as $signal) {
228            $context = array_merge(
229                $context,
230                ['signal' => $signal]
231            );
232            pcntl_signal(
233                $signal,
234                function () use ($protocolHandler, $context) {
235                    // Ignore it if a signal was already acknowledged...
236                    if ($this->isShuttingDown) {
237                        return;
238                    }
239                    $this->isShuttingDown = true;
240                    $this->logInfo(
241                        'The child process has received a signal to stop.',
242                        $context
243                    );
244                    $protocolHandler->shutdown($context);
245                }
246            );
247        }
248    }
249
250    /**
251     * Install signal handlers responsible for ending all child processes gracefully, sending a SIG_KILL if necessary.
252     */
253    private function installServerSignalHandlers(): void
254    {
255        foreach ($this->handledSignals as $signal) {
256            $this->isServerSignalsInstalled = pcntl_signal(
257                $signal,
258                function () {
259                    $this->handleServerShutdown();
260                }
261            );
262        }
263    }
264
265    /**
266     * Attempts to shut down the server end all child processes in a graceful way...
267     *
268     *     1. Set a marker on the class signaling we are shutting down. This will reject incoming clients.
269     *     2. First sends a SIG_TERM to all child processes asking them to shut down and send a notice to the client.
270     *     3. Waits for child processes to stop / clean them up.
271     *     4. Force ends any remaining child process after a max time by sending a SIG_KILL.
272     *     5. Cleans up any child socket resources.
273     *     6. Stops the main socket server process.
274     */
275    private function handleServerShutdown(): void
276    {
277        // Want to make sure we are only handling this once...
278        if ($this->isShuttingDown) {
279            return;
280        }
281        $this->isShuttingDown = true;
282        $this->logInfo(
283            'The server shutdown process has started.',
284            $this->defaultContext
285        );
286
287        // We can't do anything else without the posix ext ... :(
288        if (!$this->isPosixExtLoaded) {
289            $this->cleanUpChildProcesses();
290
291            return;
292        }
293        // Ask nicely first...
294        $this->endChildProcesses(SIGTERM);
295
296        $waitTime = 0;
297        while (!empty($this->childProcesses)) {
298            // If we reach max wait time, attempt to force end them and then stop.
299            if ($waitTime >= self::MAX_SHUTDOWN_WAIT_TIME) {
300                $this->forceEndChildProcesses();
301
302                break;
303            }
304            $this->cleanUpChildProcesses();
305
306            // We are still waiting for some children to shut down, wait on them.
307            if (!empty($this->childProcesses)) {
308                sleep(1);
309                $waitTime += 1;
310            }
311        }
312
313        $this->server->close();
314        $this->logInfo(
315            'The server shutdown process has completed.',
316            $this->defaultContext
317        );
318    }
319
320    /**
321     * Iterates through each child process and sends the specified signal.
322     */
323    private function endChildProcesses(
324        int $signal,
325        bool $closeSocket = false
326    ): void {
327        foreach ($this->childProcesses as $childProcess) {
328            $context = array_merge(
329                $this->defaultContext,
330                ['child_pid' => $childProcess->getPid()]
331            );
332
333            $message = ($signal === SIGKILL)
334                ? 'Force ending child process.'
335                : 'Sending graceful signal to end child process.';
336            $this->logInfo(
337                $message,
338                $context
339            );
340
341            posix_kill(
342                $childProcess->getPid(),
343                $signal
344            );
345            if ($closeSocket) {
346                $childProcess->closeSocket();
347            }
348        }
349    }
350
351    /**
352     * In the child process we install a different set of signal handlers. Then we run the protocol handler and exit
353     * with a zero error code.
354     *
355     * @throws EncoderException
356     */
357    private function runChildProcessThenExit(
358        Socket $socket,
359        int $pid
360    ): void {
361        $context = ['pid' => $pid];
362        $this->isMainProcess = false;
363        $serverProtocolHandler = new ServerProtocolHandler(
364            new ServerQueue($socket),
365            new HandlerFactory($this->options),
366            $this->options
367        );
368
369        $this->installChildSignalHandlers(
370            $serverProtocolHandler,
371            $context
372        );
373
374        $this->logInfo(
375            'Handling LDAP connection in new child process.',
376            $context
377        );
378        $serverProtocolHandler->handle();
379        $this->logInfo(
380            'The child process is ending.',
381            $context
382        );
383
384        exit(0);
385    }
386
387    /**
388     * When a new Socket is received, we do the following:
389     *
390     *     1. If the server has not installed its signal handlers, do that first.
391     *     2. Add the ChildProcess to the list of running child processes.
392     *     3. Clean-up any currently running child processes.
393     */
394    private function runAfterChildStarted(
395        int $pid,
396        Socket $socket
397    ): void {
398        if (!$this->isServerSignalsInstalled) {
399            $this->installServerSignalHandlers();
400        }
401        $this->childProcesses[] = new ChildProcess(
402            $pid,
403            $socket
404        );
405        $this->logInfo(
406            'A new client has connected.',
407            array_merge(
408                ['child_pid' => $pid],
409                $this->defaultContext
410            )
411        );
412        $this->cleanUpChildProcesses();
413    }
414
415    /**
416     * After try to stop processes nicely, we instead:
417     *
418     *      1. Clean up and existing processes.
419     *      2. Send a SIG_KILL to each child.
420     *      3. Clean up the list of child processes.
421     */
422    private function forceEndChildProcesses(): void
423    {
424        // One last check before we force end them all.
425        $this->cleanUpChildProcesses();
426        if (empty($this->childProcesses)) {
427            return;
428        }
429
430        $this->endChildProcesses(
431            SIGKILL,
432            true
433        );
434        $this->cleanUpChildProcesses();
435    }
436}
437