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