From e738805b91be7454d0cac94b47d04190d9d32043 Mon Sep 17 00:00:00 2001 From: k911 Date: Tue, 5 Jan 2021 23:58:52 +0100 Subject: [PATCH] feat(server): handle signals to enable graceful termination --- .php_cs.dist | 8 +- composer.json | 30 +++---- composer.lock | 2 +- phpstan.neon.dist | 3 + phpstan.tests.neon | 3 + .../Command/AbstractServerStartCommand.php | 38 ++++---- .../DependencyInjection/Configuration.php | 23 +++++ .../DependencyInjection/SwooleExtension.php | 38 ++++++++ .../Bundle/Resources/config/services.yaml | 33 ++++++- src/Client/HttpClient.php | 10 +++ src/Component/Clock/ClockInterface.php | 24 ++++++ .../Clock/CoroutineFriendlyClock.php | 72 ++++++++++++++++ src/Process/ProcessFactory.php | 15 ++++ src/Process/ProcessInterface.php | 12 +++ src/Process/ProcessManager.php | 52 +++++++++++ src/Process/ProcessManagerInterface.php | 19 ++++ .../Signal/Exception/SignalException.php | 20 +++++ src/Process/Signal/PcntlSignalHandler.php | 66 ++++++++++++++ src/Process/Signal/Signal.php | 86 +++++++++++++++++++ src/Process/Signal/SignalHandlerInterface.php | 18 ++++ .../Signal/SwooleProcessSignalHandler.php | 40 +++++++++ src/Server/Configurator/WithProcess.php | 23 +++++ .../Configurator/WithWorkerExitHandler.php | 26 ++++++ src/Server/HttpServer.php | 5 ++ src/Server/HttpServerConfiguration.php | 60 +++++++++++-- .../ServerStartHandlerInterface.php | 2 +- src/Server/LifecycleHandler/SigIntHandler.php | 33 ------- ...alServerShutdownHandlerAsServerProcess.php | 49 +++++++++++ ...gnalServerShutdownHandlerOnServerStart.php | 38 ++++++++ .../ClearAllTimersWorkerExitHandler.php | 30 +++++++ .../WorkerHandler/NoOpWorkerExitHandler.php | 18 ++++ .../WorkerExitHandlerInterface.php | 19 ++++ tests/Feature/SymfonyProfilerTest.php | 7 +- .../SwooleProcessSignalHandlerTest.php | 47 ++++++++++ .../WithWorkerExitHandlerTest.php | 36 ++++++++ .../ClearAllTimersWorkerExitHandlerTest.php | 51 +++++++++++ 36 files changed, 977 insertions(+), 79 deletions(-) create mode 100644 src/Component/Clock/ClockInterface.php create mode 100644 src/Component/Clock/CoroutineFriendlyClock.php create mode 100644 src/Process/ProcessFactory.php create mode 100644 src/Process/ProcessInterface.php create mode 100644 src/Process/ProcessManager.php create mode 100644 src/Process/ProcessManagerInterface.php create mode 100644 src/Process/Signal/Exception/SignalException.php create mode 100644 src/Process/Signal/PcntlSignalHandler.php create mode 100644 src/Process/Signal/Signal.php create mode 100644 src/Process/Signal/SignalHandlerInterface.php create mode 100644 src/Process/Signal/SwooleProcessSignalHandler.php create mode 100644 src/Server/Configurator/WithProcess.php create mode 100644 src/Server/Configurator/WithWorkerExitHandler.php delete mode 100644 src/Server/LifecycleHandler/SigIntHandler.php create mode 100644 src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerAsServerProcess.php create mode 100644 src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerOnServerStart.php create mode 100644 src/Server/WorkerHandler/ClearAllTimersWorkerExitHandler.php create mode 100644 src/Server/WorkerHandler/NoOpWorkerExitHandler.php create mode 100644 src/Server/WorkerHandler/WorkerExitHandlerInterface.php create mode 100644 tests/Unit/Process/SwooleProcessSignalHandlerTest.php create mode 100644 tests/Unit/Server/Configurator/WithWorkerExitHandlerTest.php create mode 100644 tests/Unit/Server/WorkerHandler/ClearAllTimersWorkerExitHandlerTest.php diff --git a/.php_cs.dist b/.php_cs.dist index 0644d71a..6a46ee2e 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -4,9 +4,10 @@ declare(strict_types=1); $finder = PhpCsFixer\Finder::create() ->in(['src', 'tests']) - ->exclude(['Fixtures/Symfony/app/var']); + ->exclude(['Fixtures/Symfony/app/var']) +; -/** +/* * @see https://github.com/FriendsOfPHP/PHP-CS-Fixer for rules */ return PhpCsFixer\Config::create() @@ -42,4 +43,5 @@ return PhpCsFixer\Config::create() 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], ]) ->setRiskyAllowed(true) - ->setFinder($finder); + ->setFinder($finder) +; diff --git a/composer.json b/composer.json index c00570b7..6f432c19 100644 --- a/composer.json +++ b/composer.json @@ -22,12 +22,12 @@ "ext-json": "*", "ext-swoole": "^4.5.10", "beberlei/assert": "^3.0", - "symfony/config": "^4.3.1|^5.0", - "symfony/console": "^4.3.1|^5.0", - "symfony/dependency-injection": "^4.3.1|^5.0", - "symfony/http-foundation": "^4.3.1|^5.0", - "symfony/http-kernel": "^4.3.1|^5.0", - "symfony/process": "^4.3.1|^5.0" + "symfony/config": "^4.4.0|^5.0", + "symfony/console": "^4.4.0|^5.0", + "symfony/dependency-injection": "^4.4.0|^5.0", + "symfony/http-foundation": "^4.4.0|^5.0", + "symfony/http-kernel": "^4.4.0|^5.0", + "symfony/process": "^4.4.0|^5.0" }, "require-dev": { "doctrine/annotations": "^1.6", @@ -45,15 +45,15 @@ "phpunit/phpunit": "^9.1.3", "swoole/ide-helper": "^4.5.10", "symfony/debug-pack": "^1.0", - "symfony/error-handler": "^4.3.1|^5.0", - "symfony/framework-bundle": "^4.3.1|^5.0", - "symfony/messenger": "^4.3.1|^5.0", - "symfony/monolog-bridge": "^4.3.1|^5.0", + "symfony/error-handler": "^4.4.0|^5.0", + "symfony/framework-bundle": "^4.4.0|^5.0", + "symfony/messenger": "^4.4.0|^5.0", + "symfony/monolog-bridge": "^4.4.0|^5.0", "symfony/monolog-bundle": "^3.3", "symfony/profiler-pack": "^1.0", - "symfony/twig-bundle": "^4.3.1|^5.0", - "symfony/var-dumper": "^4.3.1|^5.0", - "symfony/yaml": "^4.3.1|^5.0", + "symfony/twig-bundle": "^4.4.0|^5.0", + "symfony/var-dumper": "^4.4.0|^5.0", + "symfony/yaml": "^4.4.0|^5.0", "upscale/swoole-blackfire": "^3.0" }, "suggest": { @@ -82,10 +82,10 @@ "php tests/Fixtures/Symfony/app/console --ansi" ], "static-analyse-src": [ - "phpstan analyze src -l 7 -c phpstan.neon.dist --ansi --memory-limit=512M" + "phpstan analyze -c phpstan.neon.dist --ansi --memory-limit=512M" ], "static-analyse-tests": [ - "phpstan analyze tests -l 4 -c phpstan.tests.neon --ansi --memory-limit=512M" + "phpstan analyze -c phpstan.tests.neon --ansi --memory-limit=512M" ], "cs-analyse": [ "php-cs-fixer fix -v --dry-run --diff --stop-on-violation --ansi" diff --git a/composer.lock b/composer.lock index 299b8911..8bf522d3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "556792144793aa9da451552cfea19eb5", + "content-hash": "1a6190f6c8a8763a684ebb589f4597dd", "packages": [ { "name": "beberlei/assert", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 035cfbf5..171bb0ef 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,4 +1,5 @@ parameters: + level: 7 inferPrivatePropertyTypeFromConstructor: true checkMissingIterableValueType: false excludes_analyse: @@ -8,3 +9,5 @@ parameters: - src/Server/WorkerHandler/HMRWorkerStartHandler.php ignoreErrors: # Put false positives here + paths: + - src diff --git a/phpstan.tests.neon b/phpstan.tests.neon index dab5edcf..0e930242 100644 --- a/phpstan.tests.neon +++ b/phpstan.tests.neon @@ -1,4 +1,5 @@ parameters: + level: 4 inferPrivatePropertyTypeFromConstructor: true checkMissingIterableValueType: false excludes_analyse: @@ -13,3 +14,5 @@ parameters: # Symfony configuration files - '#Variable \$container might not be defined#' + paths: + - tests diff --git a/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php b/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php index ec08353c..5fa0bae6 100644 --- a/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php @@ -9,6 +9,8 @@ use function K911\Swoole\decode_string_as_set; use function K911\Swoole\format_bytes; use function K911\Swoole\get_max_memory; +use K911\Swoole\Process\Signal\PcntlSignalHandler; +use K911\Swoole\Process\Signal\Signal; use K911\Swoole\Server\Config\Socket; use K911\Swoole\Server\Configurator\ConfiguratorInterface; use K911\Swoole\Server\HttpServer; @@ -25,33 +27,28 @@ abstract class AbstractServerStartCommand extends Command { - /** - * @var ParameterBagInterface - */ - protected $parameterBag; - - private $server; - private $bootManager; - private $serverConfiguration; - private $serverConfigurator; - - /** - * @var bool - */ - private $testing = false; + protected ParameterBagInterface $parameterBag; + private HttpServer $server; + private BootableInterface $bootManager; + private HttpServerConfiguration $serverConfiguration; + private ConfiguratorInterface $serverConfigurator; + private ?PcntlSignalHandler $pcntlSignalHandler; + private bool $testing = false; public function __construct( HttpServer $server, HttpServerConfiguration $serverConfiguration, ConfiguratorInterface $serverConfigurator, ParameterBagInterface $parameterBag, - BootableInterface $bootManager + BootableInterface $bootManager, + ?PcntlSignalHandler $pcntlSignalHandler ) { $this->server = $server; $this->bootManager = $bootManager; $this->parameterBag = $parameterBag; $this->serverConfigurator = $serverConfigurator; $this->serverConfiguration = $serverConfiguration; + $this->pcntlSignalHandler = $pcntlSignalHandler; parent::__construct(); } @@ -201,7 +198,7 @@ protected function prepareConfigurationRowsToPrint(HttpServerConfiguration $serv ['worker_count', $serverConfiguration->getWorkerCount()], ['reactor_count', $serverConfiguration->getReactorCount()], ['worker_max_request', $serverConfiguration->getMaxRequest()], - ['worker_max_request_grace', $serverConfiguration->getMaxRequestGrace()], + ['worker_max_request_grace', $serverConfiguration->getMaxRequestGrace() ?? '~'], ['memory_limit', format_bytes(get_max_memory())], ['trusted_hosts', \implode(', ', $runtimeConfiguration['trustedHosts'])], ]; @@ -226,6 +223,15 @@ protected function startServer(HttpServerConfiguration $serverConfiguration, Htt { $io->comment('Quit the server with CONTROL-C.'); + if ($this->pcntlSignalHandler instanceof PcntlSignalHandler && $serverConfiguration->isReactorRunningMode()) { + // Register dummy SIGINT handler to make sure process doesn't exit to early when CONTROL-C is hit + $this->pcntlSignalHandler->register(function () use ($server): void { + if (\getmypid() !== $server->getMasterPid()) { + $server->shutdown(); + } + }, Signal::int()); + } + if ($server->start()) { $io->newLine(); $io->success('Swoole HTTP Server has been successfully shutdown.'); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 6a65a19c..83b6e5ed 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -186,6 +186,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('debug_handler') ->defaultNull() ->end() + ->booleanNode('reactor_mode_graceful_signal_handler') + ->defaultFalse() + ->treatNullLike(false) + ->end() ->booleanNode('trust_all_proxies_handler') ->defaultFalse() ->treatNullLike(false) @@ -206,6 +210,18 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() // drivers + ->arrayNode('coroutine') + ->addDefaultsIfNotSet() + ->canBeDisabled() + ->children() + ->arrayNode('hooks') + ->defaultValue(['all']) + ->prototype('enum') + ->values(['off', 'all']) + ->end() + ->end() + ->end() + ->end() ->arrayNode('settings') ->addDefaultsIfNotSet() ->children() @@ -240,6 +256,13 @@ public function getConfigTreeBuilder(): TreeBuilder ->min(0) ->defaultValue(0) ->end() + ->booleanNode('worker_reload_async') + ->defaultTrue() + ->end() + ->integerNode('worker_exit_timeout_seconds') + ->min(0) + ->defaultValue(5) + ->end() ->scalarNode('worker_max_request_grace') ->defaultNull() ->end() diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/SwooleExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/SwooleExtension.php index 4993db13..ee6ca94a 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/SwooleExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/SwooleExtension.php @@ -18,9 +18,11 @@ use K911\Swoole\Bridge\Symfony\Messenger\SwooleServerTaskTransportFactory; use K911\Swoole\Bridge\Symfony\Messenger\SwooleServerTaskTransportHandler; use K911\Swoole\Bridge\Upscale\Blackfire\WithProfiler; +use K911\Swoole\Process\Signal\PcntlSignalHandler; use K911\Swoole\Server\Config\Socket; use K911\Swoole\Server\Config\Sockets; use K911\Swoole\Server\Configurator\ConfiguratorInterface; +use K911\Swoole\Server\Configurator\WithProcess; use K911\Swoole\Server\HttpServer; use K911\Swoole\Server\HttpServerConfiguration; use K911\Swoole\Server\RequestHandler\AdvancedStaticFilesServer; @@ -32,10 +34,13 @@ use K911\Swoole\Server\Runtime\HMR\HotModuleReloaderInterface; use K911\Swoole\Server\Runtime\HMR\InotifyHMR; use K911\Swoole\Server\TaskHandler\TaskHandlerInterface; +use K911\Swoole\Server\WorkerHandler\ClearAllTimersWorkerExitHandler; use K911\Swoole\Server\WorkerHandler\HMRWorkerStartHandler; +use K911\Swoole\Server\WorkerHandler\WorkerExitHandlerInterface; use K911\Swoole\Server\WorkerHandler\WorkerStartHandlerInterface; use ReflectionMethod; use RuntimeException; +use Swoole\Process; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -192,6 +197,8 @@ private function registerHttpServerConfiguration(array $config, ContainerBuilder 'socket_type' => $socketType, 'ssl_enabled' => $sslEnabled, 'settings' => $settings, + 'coroutine' => $coroutine, + 'services' => $services, ] = $config; if ('auto' === $static['strategy']) { @@ -211,6 +218,8 @@ private function registerHttpServerConfiguration(array $config, ContainerBuilder $settings['serve_static'] = $static['strategy']; $settings['public_dir'] = $static['public_dir']; + $settings['coroutine_enabled'] = $coroutine['enabled']; + $settings['coroutine_hooks'] = $coroutine['hooks']; if ('auto' === $settings['log_level']) { $settings['log_level'] = $this->isDebug($container) ? 'debug' : 'notice'; @@ -234,9 +243,31 @@ private function registerHttpServerConfiguration(array $config, ContainerBuilder ->addArgument($settings) ; + if ('reactor ' === $runningMode && $services['reactor_mode_graceful_signal_handler']) { + $this->registerReactorGracefulSignalHandler($container); + } + $this->registerHttpServerHMR($hmr, $container); } + private function registerReactorGracefulSignalHandler(ContainerBuilder $container): void + { + if (\extension_loaded('pcntl') && \extension_loaded('posix')) { + $container->register(PcntlSignalHandler::class); + } + + $container->register('swoole_bundle.server.http_server.configurator.with_process_signal_shutdown_handler') + ->setClass(WithProcess::class) + ->setAutowired(false) + ->setAutoconfigured(false) + ->setPublic(false) + ->setArguments([Process::class => new Reference('swoole_bundle.http_server.runtime.server_shutdown.signal_handler_swoole_process')]) + ; + + $def = $container->getDefinition('swoole_bundle.server.http_server.configurator.for_server_run_command'); + $def->addArgument(new Reference('swoole_bundle.server.http_server.configurator.with_process_signal_shutdown_handler')); + } + private function registerHttpServerHMR(string $hmr, ContainerBuilder $container): void { if ('off' === $hmr || !$this->isDebug($container)) { @@ -255,6 +286,13 @@ private function registerHttpServerHMR(string $hmr, ContainerBuilder $container) ->setArgument('$decorated', new Reference(HMRWorkerStartHandler::class.'.inner')) ->setDecoratedService(WorkerStartHandlerInterface::class) ; + + $container->autowire(ClearAllTimersWorkerExitHandler::class) + ->setPublic(false) + ->setAutoconfigured(true) + ->setArgument('$decorated', new Reference(ClearAllTimersWorkerExitHandler::class.'.inner')) + ->setDecoratedService(WorkerExitHandlerInterface::class) + ; } private function resolveAutoHMR(): string diff --git a/src/Bridge/Symfony/Bundle/Resources/config/services.yaml b/src/Bridge/Symfony/Bundle/Resources/config/services.yaml index a100f123..880dad83 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/services.yaml +++ b/src/Bridge/Symfony/Bundle/Resources/config/services.yaml @@ -53,7 +53,31 @@ services: K911\Swoole\Server\RequestHandler\LimitedRequestHandler: - K911\Swoole\Server\LifecycleHandler\SigIntHandler: + K911\Swoole\Server\Runtime\ServerShutdown\SignalServerShutdownHandlerOnServerStart: + + K911\Swoole\Component\Clock\ClockInterface: + class: K911\Swoole\Component\Clock\CoroutineFriendlyClock + + K911\Swoole\Process\Signal\SwooleProcessSignalHandler: + + K911\Swoole\Process\ProcessFactory: + + K911\Swoole\Process\ProcessManagerInterface: + class: K911\Swoole\Process\ProcessManager + + K911\Swoole\Process\Signal\SignalHandlerInterface: + alias: K911\Swoole\Process\Signal\SwooleProcessSignalHandler + + swoole_bundle.http_server.runtime.server_shutdown.signal_handler_process: + class: K911\Swoole\Server\Runtime\ServerShutdown\SignalServerShutdownHandlerAsServerProcess + + swoole_bundle.http_server.runtime.server_shutdown.signal_handler_swoole_process: + class: Swoole\Process + factory: ['@K911\Swoole\Process\ProcessFactory', make] + autowire: false + autoconfigure: false + arguments: + $process: '@swoole_bundle.http_server.runtime.server_shutdown.signal_handler_process' K911\Swoole\Server\Runtime\CallableBootManagerFactory: @@ -76,6 +100,9 @@ services: K911\Swoole\Server\WorkerHandler\WorkerStartHandlerInterface: class: K911\Swoole\Server\WorkerHandler\NoOpWorkerStartHandler + K911\Swoole\Server\WorkerHandler\WorkerExitHandlerInterface: + class: K911\Swoole\Server\WorkerHandler\NoOpWorkerExitHandler + K911\Swoole\Server\LifecycleHandler\ServerStartHandlerInterface: class: K911\Swoole\Server\LifecycleHandler\NoOpServerStartHandler @@ -123,6 +150,8 @@ services: K911\Swoole\Server\Configurator\WithWorkerStartHandler: + K911\Swoole\Server\Configurator\WithWorkerExitHandler: + K911\Swoole\Server\Configurator\WithTaskHandler: K911\Swoole\Server\Configurator\WithTaskFinishedHandler: @@ -164,4 +193,4 @@ services: class: K911\Swoole\Server\Configurator\WithServerStartHandler autoconfigure: false arguments: - $handler: '@K911\Swoole\Server\LifecycleHandler\SigIntHandler' + $handler: '@K911\Swoole\Server\Runtime\ServerShutdown\SignalServerShutdownHandlerOnServerStart' diff --git a/src/Client/HttpClient.php b/src/Client/HttpClient.php index 2d24702a..45fafa57 100644 --- a/src/Client/HttpClient.php +++ b/src/Client/HttpClient.php @@ -48,6 +48,16 @@ public function __construct(Client $client) $this->client = $client; } + public function __destruct() + { + $this->close(); + } + + public function close(): void + { + $this->client->close(); + } + public static function fromSocket(Socket $socket, array $options = []): self { return self::fromDomain( diff --git a/src/Component/Clock/ClockInterface.php b/src/Component/Clock/ClockInterface.php new file mode 100644 index 00000000..6e73e95b --- /dev/null +++ b/src/Component/Clock/ClockInterface.php @@ -0,0 +1,24 @@ +coroutineSleepHookEnabled = $coroutineSleepHookEnabled; + } + + public function timeout(callable $condition, float $timeoutSeconds = 10, int $stepMicroseconds = 1000): bool + { + $now = $this->currentTime(); + $start = $now; + $max = $start + $timeoutSeconds; + + do { + if ($condition()) { + return true; + } + + $now = $this->currentTime(); + $this->microSleep($stepMicroseconds); + } while ($now < $max); + + return false; + } + + public function currentTime(): float + { + return \microtime(true); + } + + public function microSleep(int $microseconds): void + { + $this->sleepCoroutineHookCheck(); + \usleep($microseconds); + } + + private function sleepCoroutineHookCheck(): void + { + if ($this->coroutineSleepHookEnabled) { + return; + } + + $this->coroutineSleepHookEnabled = (Runtime::getHookFlags() & \SWOOLE_HOOK_SLEEP) === \SWOOLE_HOOK_SLEEP; + Assertion::true($this->coroutineSleepHookEnabled, 'Swoole Coroutine hook "SWOOLE_HOOK_SLEEP" must be enabled'); + } +} diff --git a/src/Process/ProcessFactory.php b/src/Process/ProcessFactory.php new file mode 100644 index 00000000..3666bed6 --- /dev/null +++ b/src/Process/ProcessFactory.php @@ -0,0 +1,15 @@ +signalHandler = $signalHandler; + $this->clock = $clock; + } + + /** + * {@inheritDoc} + */ + public function gracefullyTerminate(int $processId, int $timeoutSeconds = 10): void + { + if (!$this->runningStatus($processId)) { + throw new SignalException(\sprintf('Process with id "%d" is not running.', $processId)); + } + + $this->signalHandler->kill($processId, Signal::term()); + + if (!$this->clock->timeout(fn () => $this->runningStatus($processId), $timeoutSeconds, 1000)) { + $this->signalHandler->kill($processId, Signal::kill()); + } + } + + /** + * {@inheritDoc} + */ + public function runningStatus(int $processId): bool + { + try { + $this->signalHandler->kill($processId, Signal::zero()); + } catch (SignalException $exception) { + return false; + } + + return true; + } +} diff --git a/src/Process/ProcessManagerInterface.php b/src/Process/ProcessManagerInterface.php new file mode 100644 index 00000000..6b314062 --- /dev/null +++ b/src/Process/ProcessManagerInterface.php @@ -0,0 +1,19 @@ +getMessage(), $exception->getCode(), $exception); + } + + public static function fromKillCommand(int $processId, Signal $signal): self + { + return new self(\sprintf('Unable to kill process having id "%d" using signal "%s (%d)"', $processId, $signal->name(), $signal->number())); + } +} diff --git a/src/Process/Signal/PcntlSignalHandler.php b/src/Process/Signal/PcntlSignalHandler.php new file mode 100644 index 00000000..6b1ee6ef --- /dev/null +++ b/src/Process/Signal/PcntlSignalHandler.php @@ -0,0 +1,66 @@ +restartSysCalls = $restartSysCalls; + + if ($asyncSignals) { + $this->enableAsyncSignals(); + } + } + + /** + * {@inheritDoc} + */ + public function register(callable $handler, Signal $signal, Signal ...$moreSignals): void + { + /** @var Signal $signalObj */ + foreach ([$signal, ...$moreSignals] as $signalObj) { + if (!\pcntl_signal($signalObj->number(), $handler, $this->restartSysCalls)) { + $errorNumber = \posix_get_last_error(); + $errorMessage = \pcntl_strerror($errorNumber); + + throw new SignalException(\sprintf('Unable to register PCNTL signal handler on signal "%s (%d)". Error (%d): %s', $signal->name(), $signal->number(), $errorNumber, $errorMessage)); + } + } + } + + /** + * {@inheritDoc} + */ + public function kill(int $processId, Signal $signal): void + { + if (!\posix_kill($processId, $signal->number())) { + $errorNumber = \posix_get_last_error(); + $errorMessage = \posix_strerror($errorNumber); + + throw new SignalException(\sprintf('Killing process id "%d" with signal "%s (%d)" failed. Error (%d): %s', $processId, $signal->name(), $signal->number(), $errorNumber, $errorMessage)); + } + } + + private function enableAsyncSignals(): void + { + if (!\pcntl_async_signals()) { + \pcntl_async_signals(true); + } + } +} diff --git a/src/Process/Signal/Signal.php b/src/Process/Signal/Signal.php new file mode 100644 index 00000000..94867784 --- /dev/null +++ b/src/Process/Signal/Signal.php @@ -0,0 +1,86 @@ + 0, + self::SIGINT => 2, + self::SIGKILL => 9, + self::SIGTERM => 15, + ]; + + private string $name; + private int $number; + + public function __construct(string $name) + { + $this->name = \mb_strtoupper($name); + $this->number = $this->resolveNumber($this->name); + } + + public function name(): string + { + return $this->name; + } + + public function number(): int + { + return $this->number; + } + + public static function kill(): self + { + return new self(self::SIGKILL); + } + + public static function term(): self + { + return new self(self::SIGTERM); + } + + public static function int(): self + { + return new self(self::SIGINT); + } + + public static function zero(): self + { + return new self(self::ZERO); + } + + private function resolveNumber(string $name): int + { + if (\array_key_exists($name, self::PORTABLE_SIGNALS)) { + return self::PORTABLE_SIGNALS[$name]; + } + + if (\defined($name)) { + $signalConstant = \constant($name); + Assertion::integer($signalConstant, 'Signal number must be an integer. Value "%s" is not an integer.'); + Assertion::greaterOrEqualThan($signalConstant, 0, 'Provided signal number "%s" is not greater or equal than "%s".'); + + return $signalConstant; + } + + throw new SignalException(\sprintf('Signal constant "%s" is not defined. Signal number unknown', $name)); + } +} diff --git a/src/Process/Signal/SignalHandlerInterface.php b/src/Process/Signal/SignalHandlerInterface.php new file mode 100644 index 00000000..999f557b --- /dev/null +++ b/src/Process/Signal/SignalHandlerInterface.php @@ -0,0 +1,18 @@ +number(), $handler); + } + } catch (\Throwable $exception) { + throw SignalException::fromThrowable($exception); + } + } + + /** + * {@inheritDoc} + */ + public function kill(int $processId, Signal $signal): void + { + try { + if (!Process::kill($processId, $signal->number())) { + throw SignalException::fromKillCommand($processId, $signal); + } + } catch (\Throwable $exception) { + throw SignalException::fromThrowable($exception); + } + } +} diff --git a/src/Server/Configurator/WithProcess.php b/src/Server/Configurator/WithProcess.php new file mode 100644 index 00000000..26f0fd01 --- /dev/null +++ b/src/Server/Configurator/WithProcess.php @@ -0,0 +1,23 @@ +process = $process; + } + + public function configure(Server $server): void + { + $server->addProcess($this->process); + } +} diff --git a/src/Server/Configurator/WithWorkerExitHandler.php b/src/Server/Configurator/WithWorkerExitHandler.php new file mode 100644 index 00000000..ffe386e1 --- /dev/null +++ b/src/Server/Configurator/WithWorkerExitHandler.php @@ -0,0 +1,26 @@ +handler = $handler; + } + + /** + * {@inheritdoc} + */ + public function configure(Server $server): void + { + $server->on('WorkerExit', [$this->handler, 'handle']); + } +} diff --git a/src/Server/HttpServer.php b/src/Server/HttpServer.php index f47c674e..b80ff0f7 100644 --- a/src/Server/HttpServer.php +++ b/src/Server/HttpServer.php @@ -136,6 +136,11 @@ public function getListeners(): array return $this->listeners; } + public function getMasterPid(): int + { + return $this->getServer()->master_pid; + } + private function isRunningInBackground(): bool { try { diff --git a/src/Server/HttpServerConfiguration.php b/src/Server/HttpServerConfiguration.php index 55513d7c..d9791094 100644 --- a/src/Server/HttpServerConfiguration.php +++ b/src/Server/HttpServerConfiguration.php @@ -27,12 +27,18 @@ class HttpServerConfiguration private const SWOOLE_HTTP_SERVER_CONFIG_PACKAGE_MAX_LENGTH = 'package_max_length'; private const SWOOLE_HTTP_SERVER_CONFIG_WORKER_MAX_REQUEST = 'worker_max_request'; private const SWOOLE_HTTP_SERVER_CONFIG_WORKER_MAX_REQUEST_GRACE = 'worker_max_request_grace'; + private const SWOOLE_HTTP_SERVER_CONFIG_WORKER_RELOAD_ASYNC = 'worker_reload_async'; + private const SWOOLE_HTTP_SERVER_CONFIG_WORKER_EXIT_TIMEOUT_SECONDS = 'worker_exit_timeout_seconds'; + private const SWOOLE_HTTP_SERVER_CONFIG_COROUTINE_ENABLED = 'coroutine_enabled'; + private const SWOOLE_HTTP_SERVER_CONFIG_COROUTINE_HOOKS = 'coroutine_hooks'; /** * @todo add more * * @see https://github.com/swoole/swoole-docs/blob/master/modules/swoole-server/configuration.md * @see https://github.com/swoole/swoole-docs/blob/master/modules/swoole-http-server/configuration.md + * @see https://wiki.swoole.com/#/server/setting + * @see https://wiki.swoole.com/#/http_server?id=%e9%85%8d%e7%bd%ae%e9%80%89%e9%a1%b9 */ private const SWOOLE_HTTP_SERVER_CONFIGURATION = [ self::SWOOLE_HTTP_SERVER_CONFIG_REACTOR_COUNT => 'reactor_num', @@ -48,6 +54,10 @@ class HttpServerConfiguration self::SWOOLE_HTTP_SERVER_CONFIG_TASK_WORKER_COUNT => 'task_worker_num', self::SWOOLE_HTTP_SERVER_CONFIG_WORKER_MAX_REQUEST => 'max_request', self::SWOOLE_HTTP_SERVER_CONFIG_WORKER_MAX_REQUEST_GRACE => 'max_request_grace', + self::SWOOLE_HTTP_SERVER_CONFIG_WORKER_RELOAD_ASYNC => 'reload_async', + self::SWOOLE_HTTP_SERVER_CONFIG_WORKER_EXIT_TIMEOUT_SECONDS => 'max_wait_time', + self::SWOOLE_HTTP_SERVER_CONFIG_COROUTINE_ENABLED => 'enable_coroutine', + self::SWOOLE_HTTP_SERVER_CONFIG_COROUTINE_HOOKS => 'hook_flags', ]; private const SWOOLE_SERVE_STATIC = [ @@ -65,17 +75,35 @@ class HttpServerConfiguration 'error' => \SWOOLE_LOG_ERROR, ]; - private $sockets; - /** - * @var string + * @see https://wiki.swoole.com/#/runtime?id=%e9%80%89%e9%a1%b9 */ - private $runningMode; + private const SWOOLE_COROUTINE_HOOKS = [ + 'off' => 0, + 'all' => \SWOOLE_HOOK_ALL, + 'tcp' => \SWOOLE_HOOK_TCP, + 'unix' => \SWOOLE_HOOK_UNIX, + 'udp' => \SWOOLE_HOOK_UDP, + 'udg' => \SWOOLE_HOOK_UDG, + 'ssl' => \SWOOLE_HOOK_SSL, + 'tls' => \SWOOLE_HOOK_TLS, + 'sleep' => \SWOOLE_HOOK_SLEEP, + 'file' => \SWOOLE_HOOK_FILE, + 'stream_function' => \SWOOLE_HOOK_STREAM_FUNCTION, + 'blocking_function' => \SWOOLE_HOOK_BLOCKING_FUNCTION, + 'proc' => \SWOOLE_HOOK_PROC, + 'curl' => \SWOOLE_HOOK_CURL, + // 'native_curl' => SWOOLE_HOOK_NATIVE_CURL, // TODO: Swoole >= 4.6.0 + // 'sockets' => SWOOLE_HOOK_SOCKETS, // TODO: Swoole >= 4.6.0 + ]; + + private Sockets $sockets; + private string $runningMode; /** * @var array */ - private $settings; + private array $settings; /** * @param array $settings settings available: @@ -280,6 +308,19 @@ public function getSwooleDocumentRoot(): ?string return 'default' === $this->settings[self::SWOOLE_HTTP_SERVER_CONFIG_SERVE_STATIC] ? $this->settings[self::SWOOLE_HTTP_SERVER_CONFIG_PUBLIC_DIR] : null; } + /** + * @see getSwooleSettings() + */ + public function getSwooleHookFlags(): int + { + $flags = 0; + foreach ($this->settings[self::SWOOLE_HTTP_SERVER_CONFIG_COROUTINE_HOOKS] as $hookName) { + $flags |= self::SWOOLE_COROUTINE_HOOKS[$hookName]; + } + + return $flags; + } + /** * @see getSwooleSettings() */ @@ -371,7 +412,9 @@ private function validateSetting(string $key, $value): void break; case self::SWOOLE_HTTP_SERVER_CONFIG_DAEMONIZE: - Assertion::boolean($value); + case self::SWOOLE_HTTP_SERVER_CONFIG_COROUTINE_ENABLED: + case self::SWOOLE_HTTP_SERVER_CONFIG_WORKER_RELOAD_ASYNC: + Assertion::boolean($value, \sprintf('Setting "%s" must be a boolean.', $key)); break; case self::SWOOLE_HTTP_SERVER_CONFIG_PUBLIC_DIR: @@ -395,6 +438,7 @@ private function validateSetting(string $key, $value): void case self::SWOOLE_HTTP_SERVER_CONFIG_TASK_WORKER_COUNT: case self::SWOOLE_HTTP_SERVER_CONFIG_REACTOR_COUNT: case self::SWOOLE_HTTP_SERVER_CONFIG_WORKER_COUNT: + case self::SWOOLE_HTTP_SERVER_CONFIG_WORKER_EXIT_TIMEOUT_SECONDS: Assertion::integer($value, \sprintf('Setting "%s" must be an integer.', $key)); Assertion::greaterThan($value, 0, 'Count value cannot be negative, "%s" provided.'); @@ -409,6 +453,10 @@ private function validateSetting(string $key, $value): void Assertion::nullOrGreaterOrEqualThan($value, 0, 'Value cannot be negative, "%s" provided.'); break; + case self::SWOOLE_HTTP_SERVER_CONFIG_COROUTINE_HOOKS: + Assertion::allChoice($value, \array_keys(self::SWOOLE_COROUTINE_HOOKS), \sprintf('Setting "%s" encountered value "%%s" which is not among expected values: %%s', $key)); + + // no break default: return; } diff --git a/src/Server/LifecycleHandler/ServerStartHandlerInterface.php b/src/Server/LifecycleHandler/ServerStartHandlerInterface.php index e92a33d3..340e3b0d 100644 --- a/src/Server/LifecycleHandler/ServerStartHandlerInterface.php +++ b/src/Server/LifecycleHandler/ServerStartHandlerInterface.php @@ -9,7 +9,7 @@ interface ServerStartHandlerInterface { /** - * Handle "OnStart" event. + * Handle "OnStart" event. (Process Mode only!). */ public function handle(Server $server): void; } diff --git a/src/Server/LifecycleHandler/SigIntHandler.php b/src/Server/LifecycleHandler/SigIntHandler.php deleted file mode 100644 index 9424f53d..00000000 --- a/src/Server/LifecycleHandler/SigIntHandler.php +++ /dev/null @@ -1,33 +0,0 @@ -decorated = $decorated; - $this->signalInterrupt = \defined('SIGINT') ? (int) \constant('SIGINT') : 2; - } - - /** - * {@inheritdoc} - */ - public function handle(Server $server): void - { - // 2 => SIGINT - Process::signal($this->signalInterrupt, [$server, 'shutdown']); - - if ($this->decorated instanceof ServerStartHandlerInterface) { - $this->decorated->handle($server); - } - } -} diff --git a/src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerAsServerProcess.php b/src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerAsServerProcess.php new file mode 100644 index 00000000..42142183 --- /dev/null +++ b/src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerAsServerProcess.php @@ -0,0 +1,49 @@ +server = $server; + $this->signalHandler = $signalHandler; + $this->processManager = $processManager; + } + + public function run(Process $self): void + { + $run = true; + $this->signalHandler->register(function () use (&$run): void { + $run = false; + $this->server->shutdown(); + }, Signal::int(), Signal::term()); + + $sleepTimes = 0; + while ($run) { + ++$sleepTimes; + \sleep(1); + if (0 === $sleepTimes % 5 && !$this->processManager->runningStatus($this->server->getMasterPid())) { + break; + } + } + } +} diff --git a/src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerOnServerStart.php b/src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerOnServerStart.php new file mode 100644 index 00000000..7bd4d7ed --- /dev/null +++ b/src/Server/Runtime/ServerShutdown/SignalServerShutdownHandlerOnServerStart.php @@ -0,0 +1,38 @@ +decorated = $decorated; + $this->signalHandler = $signalHandler; + } + + /** + * {@inheritdoc} + */ + public function handle(Server $server): void + { + // Note: SIGTERM is already registered by Swoole itself + $this->signalHandler->register([$server, 'shutdown'], Signal::int()); + + if ($this->decorated instanceof ServerStartHandlerInterface) { + $this->decorated->handle($server); + } + } +} diff --git a/src/Server/WorkerHandler/ClearAllTimersWorkerExitHandler.php b/src/Server/WorkerHandler/ClearAllTimersWorkerExitHandler.php new file mode 100644 index 00000000..52196c32 --- /dev/null +++ b/src/Server/WorkerHandler/ClearAllTimersWorkerExitHandler.php @@ -0,0 +1,30 @@ +decorated = $decorated; + } + + /** + * {@inheritDoc} + */ + public function handle(Server $worker, int $workerId): void + { + if ($this->decorated instanceof WorkerExitHandlerInterface) { + $this->decorated->handle($worker, $workerId); + } + + Timer::clearAll(); + } +} diff --git a/src/Server/WorkerHandler/NoOpWorkerExitHandler.php b/src/Server/WorkerHandler/NoOpWorkerExitHandler.php new file mode 100644 index 00000000..238977c8 --- /dev/null +++ b/src/Server/WorkerHandler/NoOpWorkerExitHandler.php @@ -0,0 +1,18 @@ +assertNotEmpty($response['headers']['x-debug-token']); $debugToken = $response['headers']['x-debug-token']; - $profilerResponse = $client->send('/_wdt/'.$debugToken)['response']; + $client2 = HttpClient::fromDomain('localhost', 9999, false); + $this->assertTrue($client2->connect()); - $this->assertStringContainsString('sf-toolbar-block-logger sf-toolbar-status-red', $profilerResponse['body']); + $profilerResponse = $client2->send('/_profiler/'.$debugToken)['response']; + $this->assertSame(200, $profilerResponse['statusCode']); + $this->assertStringContainsString('Profiler', $profilerResponse['body']); }); $serverRun->stop(); diff --git a/tests/Unit/Process/SwooleProcessSignalHandlerTest.php b/tests/Unit/Process/SwooleProcessSignalHandlerTest.php new file mode 100644 index 00000000..c9fe2d97 --- /dev/null +++ b/tests/Unit/Process/SwooleProcessSignalHandlerTest.php @@ -0,0 +1,47 @@ +swooleProcesSignalHandler = new SwooleProcessSignalHandler(); + } + + public function testSignalRegisteredAndExecuted(): void + { + Runtime::enableCoroutine(true, \SWOOLE_HOOK_ALL); + \go(function (): void { + $signal = new Signal('SIGUSR2'); + $signaled = false; + $this->swooleProcesSignalHandler->register(function () use (&$signaled): void { + $signaled = true; + }, $signal); + + self::assertFalse($signaled); + $this->swooleProcesSignalHandler->kill(\getmypid(), $signal); + \sleep(1); + self::assertTrue($signaled); + }); + Event::wait(); + } + + public function testKillNotExistingProcessExpectSignalException(): void + { + $this->expectException(SignalException::class); + $this->expectExceptionMessage('Unable to kill process having id "9999" using signal "ZERO (0)"'); + $this->swooleProcesSignalHandler->kill(9999, new Signal('ZERO')); + } +} diff --git a/tests/Unit/Server/Configurator/WithWorkerExitHandlerTest.php b/tests/Unit/Server/Configurator/WithWorkerExitHandlerTest.php new file mode 100644 index 00000000..cad1a2e2 --- /dev/null +++ b/tests/Unit/Server/Configurator/WithWorkerExitHandlerTest.php @@ -0,0 +1,36 @@ +noOpWorkerExitHandler = new NoOpWorkerExitHandler(); + + $this->configurator = new WithWorkerExitHandler($this->noOpWorkerExitHandler); + } + + public function testConfigure(): void + { + $swooleServerOnEventSpy = SwooleHttpServerMock::make(); + + $this->configurator->configure($swooleServerOnEventSpy); + + self::assertTrue($swooleServerOnEventSpy->registeredEvent); + self::assertSame(['WorkerExit', [$this->noOpWorkerExitHandler, 'handle']], $swooleServerOnEventSpy->registeredEventPair); + } +} diff --git a/tests/Unit/Server/WorkerHandler/ClearAllTimersWorkerExitHandlerTest.php b/tests/Unit/Server/WorkerHandler/ClearAllTimersWorkerExitHandlerTest.php new file mode 100644 index 00000000..9a410eb4 --- /dev/null +++ b/tests/Unit/Server/WorkerHandler/ClearAllTimersWorkerExitHandlerTest.php @@ -0,0 +1,51 @@ +workerExitHandlerProphecy = $this->prophesize(WorkerExitHandlerInterface::class); + + $this->clearAllTimersWorkerExitHandler = new ClearAllTimersWorkerExitHandler($this->workerExitHandlerProphecy->reveal()); + } + + public function testClearAllTimersAfterHandle(): void + { + $timerId = Timer::tick(1000, function (): void {}); + self::assertFalse(Timer::info($timerId)['removed']); + + $serverMock = SwooleServerMock::make(); + $workerId = IntMother::random(); + + $this->workerExitHandlerProphecy->handle($serverMock, $workerId) + ->shouldBeCalled() + ; + + $this->clearAllTimersWorkerExitHandler->handle($serverMock, $workerId); + + self::assertNull(Timer::info($timerId)); + } +}