From 8068f546ba3cc4b7f75e3167412183fe86608651 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 26 Oct 2026 18:15:51 +0100 Subject: [PATCH] Add signal and error handling --- etc/bootstrap/signals.php.template | 16 +++++++ etc/qa/composer-require-checker.json | 5 +- src/Runtime.php | 47 +++++++++++++++++- src/Signal.php | 40 ++++++++++++++++ src/SignalListener.php | 42 ++++++++++++++++ tests/RuntimeTest.php | 46 ++++++++++++++++++ tests/SignalListenerTest.php | 72 ++++++++++++++++++++++++++++ tests/SignalTest.php | 40 ++++++++++++++++ 8 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 etc/bootstrap/signals.php.template create mode 100644 src/Signal.php create mode 100644 src/SignalListener.php create mode 100644 tests/SignalListenerTest.php create mode 100644 tests/SignalTest.php diff --git a/etc/bootstrap/signals.php.template b/etc/bootstrap/signals.php.template new file mode 100644 index 0000000..bc279d5 --- /dev/null +++ b/etc/bootstrap/signals.php.template @@ -0,0 +1,16 @@ + $channel */ +$channel = Channel::open('{{CHANNEL}}'); + +SignalListener::listen($channel); + +unlink(__FILE__); diff --git a/etc/qa/composer-require-checker.json b/etc/qa/composer-require-checker.json index 040b1e2..b9d7347 100644 --- a/etc/qa/composer-require-checker.json +++ b/etc/qa/composer-require-checker.json @@ -2,14 +2,15 @@ "symbol-whitelist" : [ "null", "true", "false", "static", "self", "parent", - "array", "string", "int", "float", "bool", "iterable", "callable", "void", "object", "mixed", - "Safe\\json_encode", "futurePromise", "WyriHaximus\\Constants\\ComposerAutoloader\\LOCATION" + "array", "string", "int", "float", "bool", "iterable", "callable", "void", "object", "mixed" ], "php-core-extensions" : [ "Core", "date", + "pcntl", "pcre", "Phar", + "posix", "Reflection", "SPL", "standard" diff --git a/src/Runtime.php b/src/Runtime.php index d8eb053..90be807 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -5,15 +5,30 @@ namespace ReactParallel\Runtime; use Closure; +use parallel\Channel; use parallel\Runtime as ParallelRuntime; +use React\EventLoop\Loop; use ReactParallel\EventLoop\EventLoopBridge; +use function file_get_contents; +use function file_put_contents; +use function posix_getpid; +use function posix_kill; +use function React\Async\async; +use function str_replace; +use function sys_get_temp_dir; +use function tempnam; +use function uniqid; + use const WyriHaximus\Constants\ComposerAutoloader\LOCATION; final readonly class Runtime { private ParallelRuntime $runtime; + /** @var Channel */ + private Channel $signalChannel; + /** @api */ public static function create(EventLoopBridge $eventLoopBridge): self { @@ -22,7 +37,11 @@ public static function create(EventLoopBridge $eventLoopBridge): self public function __construct(private EventLoopBridge $eventLoopBridge, string $autoload) { - $this->runtime = new ParallelRuntime($autoload); + $channelName = '.well-known/react-parallel/runtime/signals/' . uniqid('', true); + /** @phpstan-ignore assign.propertyType */ + $this->signalChannel = Channel::make($channelName, Channel::Infinite); + $this->runtime = new ParallelRuntime($this->createBootstrapFile($autoload, $channelName)); + $this->forwardSignals(); } /** @@ -72,4 +91,30 @@ public function kill(): void { $this->runtime->kill(); } + + private function forwardSignals(): void + { + Loop::addTimer(0.0, async(function (): void { + foreach ($this->eventLoopBridge->observe($this->signalChannel) as $signal) { + posix_kill(posix_getpid(), $signal->number); + } + })); + } + + private function createBootstrapFile(string $autoload, string $channelName): string + { + $file = tempnam(sys_get_temp_dir(), 'react_parallel_'); + /** @phpstan-ignore wyrihaximus.reactphp.blocking.function.filePutContents */ + file_put_contents( + $file, + str_replace( + ['{{AUTOLOAD}}', '{{CHANNEL}}'], + [$autoload, $channelName], + /** @phpstan-ignore wyrihaximus.reactphp.blocking.function.fileGetContents, argument.type */ + file_get_contents(__DIR__ . '/../etc/bootstrap/signals.php.template'), + ), + ); + + return $file; + } } diff --git a/src/Signal.php b/src/Signal.php new file mode 100644 index 0000000..669a883 --- /dev/null +++ b/src/Signal.php @@ -0,0 +1,40 @@ + $channel + * + * @api + */ + public static function listen(Channel $channel): void + { + pcntl_async_signals(true); + + $channel = $channel; + pcntl_signal(SIGINT, static function () use ($channel): void { + $channel->send(Signal::sigint()); + }); + pcntl_signal(SIGTERM, static function () use ($channel): void { + $channel->send(Signal::sigterm()); + }); + pcntl_signal(SIGHUP, static function () use ($channel): void { + $channel->send(Signal::sighup()); + }); + pcntl_signal(SIGQUIT, static function () use ($channel): void { + $channel->send(Signal::sigquit()); + }); + } +} diff --git a/tests/RuntimeTest.php b/tests/RuntimeTest.php index 3f9a36b..8e4e07c 100644 --- a/tests/RuntimeTest.php +++ b/tests/RuntimeTest.php @@ -13,10 +13,16 @@ use TheOrville\Exceptions\LatchcombException; use WyriHaximus\AsyncTestUtilities\AsyncTestCase; +use function extension_loaded; +use function pcntl_async_signals; +use function pcntl_signal; +use function pcntl_signal_get_handler; use function React\Async\await; use function sleep; use function WyriHaximus\React\timedPromise; +use const SIGTERM; + final class RuntimeTest extends AsyncTestCase { #[Test] @@ -95,4 +101,44 @@ public function weKilledTheThread(): void await($promise); } + + #[Test] + public function signalChannelIsConfigured(): void + { + $runtime = Runtime::create(new EventLoopBridge()); + + try { + $result = $runtime->run(static fn (): bool => extension_loaded('pcntl')); + + self::assertTrue($result); + } finally { + $runtime->close(); + } + } + + #[Test] + public function signalsAreForwardedToMainProcess(): void + { + $signalReceived = false; + + pcntl_signal(SIGTERM, static function () use (&$signalReceived): void { + $signalReceived = true; + }); + pcntl_async_signals(true); + + $runtime = Runtime::create(new EventLoopBridge()); + + try { + $mainPid = \posix_getpid(); + $mainPid = $mainPid; + $runtime->run(static function (int $mainPid): void { + /** @phpstan-ignore wyrihaximus.reactphp.blocking.function.posixKill */ + \posix_kill($mainPid, \SIGTERM); + }, [$mainPid]); + } finally { + $runtime->close(); + } + + self::assertTrue($signalReceived); + } } diff --git a/tests/SignalListenerTest.php b/tests/SignalListenerTest.php new file mode 100644 index 0000000..1fd0dbb --- /dev/null +++ b/tests/SignalListenerTest.php @@ -0,0 +1,72 @@ + $channel */ + $channel = Channel::make('test-signals', Channel::Infinite); + + SignalListener::listen($channel); + + pcntl_async_signals(false); + + self::assertNotFalse(/** @phpstan-ignore-line */ pcntl_signal_get_handler(SIGINT)); + self::assertNotFalse(/** @phpstan-ignore-line */ pcntl_signal_get_handler(SIGTERM)); + self::assertNotFalse(/** @phpstan-ignore-line */ pcntl_signal_get_handler(SIGHUP)); + self::assertNotFalse(/** @phpstan-ignore-line */ pcntl_signal_get_handler(SIGQUIT)); + } + + /** @return array */ + public static function signals(): iterable + { + return [ + 'SIGINT' => [SIGINT, 'SIGINT'], + 'SIGTERM' => [SIGTERM, 'SIGTERM'], + 'SIGHUP' => [SIGHUP, 'SIGHUP'], + 'SIGQUIT' => [SIGQUIT, 'SIGQUIT'], + ]; + } + + #[Test] + #[DataProvider('signals')] + public function listenSendsSignalToChannel(int $signalNumber, string $signalName): void + { + /** @var Channel $channel */ + $channel = Channel::make('test-signals-' . $signalName, Channel::Infinite); + + SignalListener::listen($channel); + + pcntl_async_signals(true); + + posix_kill(posix_getpid(), $signalNumber); + + $signal = $channel->recv(); + /** @phpstan-ignore staticMethod.alreadyNarrowedType */ + self::assertInstanceOf(Signal::class, $signal); + self::assertSame($signalNumber, $signal->number); + self::assertSame($signalName, $signal->name); + } +} diff --git a/tests/SignalTest.php b/tests/SignalTest.php new file mode 100644 index 0000000..3362b87 --- /dev/null +++ b/tests/SignalTest.php @@ -0,0 +1,40 @@ + */ + public static function signalFactoryMethods(): iterable + { + return [ + 'sigint' => [Signal::class . '::sigint', SIGINT, 'SIGINT'], + 'sigterm' => [Signal::class . '::sigterm', SIGTERM, 'SIGTERM'], + 'sighup' => [Signal::class . '::sighup', SIGHUP, 'SIGHUP'], + 'sigquit' => [Signal::class . '::sigquit', SIGQUIT, 'SIGQUIT'], + ]; + } + + #[Test] + #[DataProvider('signalFactoryMethods')] + public function createsSignalWithCorrectNumberAndName(callable $factory, int $expectedNumber, string $expectedName): void + { + /** @var Signal $signal */ + $signal = $factory(); + + self::assertSame($expectedNumber, $signal->number); + self::assertSame($expectedName, $signal->name); + } +}