From f8ad0169f652fedd6ad14908da1184fcd3a18685 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 24 Jul 2017 17:18:40 +0200 Subject: [PATCH] Support for signal handling --- README.md | 43 ++++++++++++++++ composer.json | 3 +- examples/04-signals.php | 12 +++++ src/ExtEventLoop.php | 39 +++++++++++++++ src/LibEvLoop.php | 40 +++++++++++++++ src/LibEventLoop.php | 43 ++++++++++++++++ src/LoopInterface.php | 35 +++++++++++++ src/SignalsHandler.php | 97 ++++++++++++++++++++++++++++++++++++ src/StreamSelectLoop.php | 61 +++++++++++++++++++++++ tests/AbstractLoopTest.php | 90 +++++++++++++++++++++++++++++++++ tests/SignalsHandlerTest.php | 92 ++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 8 +++ 12 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 examples/04-signals.php create mode 100644 src/SignalsHandler.php create mode 100644 tests/SignalsHandlerTest.php diff --git a/README.md b/README.md index 3aa0cc45..e3fa8d2c 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,49 @@ echo 'a'; See also [example #3](examples). +### addSignal() + +The `addSignal(int $signal, callable $listener): void` method can be used to +be notified about OS signals. This is useful to catch user interrupt signals or +shutdown signals from tools like `supervisor` or `systemd`. + +The listener callback function MUST be able to accept a single parameter, +the signal added by this method or you MAY use a function which +has no parameters at all. + +The listener callback function MUST NOT throw an `Exception`. +The return value of the listener callback function will be ignored and has +no effect, so for performance reasons you're recommended to not return +any excessive data structures. + +```php +$listener = function (int $signal) { + echo 'Caught user iterrupt signal', PHP_EOL; +}; +$loop->addSignal(SIGINT, $listener); +``` + +See also [example #4](examples). + +**Note: A listener can only be added once to the same signal, any attempts to add it +more then once will be ignored.** + +**Note: Signaling is only available on Unix-like platform, Windows isn't supported due +to limitations from underlying signal handlers.** + +### removeSignal() + +The `removeSignal(int $signal, callable $listener): void` removes a previously added +signal listener. + +Any attempts to remove listeners that aren't registerred will be ignored. + +```php +$loop->removeSignal(SIGINT, $listener); +``` + +See also [example #4](examples). + ### addReadStream() > Advanced! Note that this low-level API is considered advanced usage. diff --git a/composer.json b/composer.json index f48f6eaf..67060367 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "suggest": { "ext-libevent": ">=0.1.0 for LibEventLoop and PHP5 only", "ext-event": "~1.0 for ExtEventLoop", - "ext-libev": "for LibEvLoop" + "ext-libev": "for LibEvLoop", + "ext-pcntl": "For signals support when using the stream_select loop" }, "autoload": { "psr-4": { diff --git a/examples/04-signals.php b/examples/04-signals.php new file mode 100644 index 00000000..4e03e4b7 --- /dev/null +++ b/examples/04-signals.php @@ -0,0 +1,12 @@ +addSignal(SIGINT, $func = function ($signal) use ($loop, &$func) { + echo 'Signal: ', (string)$signal, PHP_EOL; + $loop->removeSignal(SIGINT, $func); +}); + +$loop->run(); diff --git a/src/ExtEventLoop.php b/src/ExtEventLoop.php index 0d088d50..25797678 100644 --- a/src/ExtEventLoop.php +++ b/src/ExtEventLoop.php @@ -25,6 +25,8 @@ class ExtEventLoop implements LoopInterface private $readListeners = []; private $writeListeners = []; private $running; + private $signals; + private $signalEvents = []; public function __construct(EventBaseConfig $config = null) { @@ -32,6 +34,27 @@ public function __construct(EventBaseConfig $config = null) $this->futureTickQueue = new FutureTickQueue(); $this->timerEvents = new SplObjectStorage(); + $this->signals = new SignalsHandler( + $this, + function ($signal) { + $this->signalEvents[$signal] = Event::signal($this->eventBase, $signal, $f = function () use ($signal, &$f) { + $this->signals->call($signal); + // Ensure there are two copies of the callable around until it has been executed. + // For more information see: https://bugs.php.net/bug.php?id=62452 + // Only an issue for PHP 5, this hack can be removed once PHP 5 suppose has been dropped. + $g = $f; + $f = $g; + }); + $this->signalEvents[$signal]->add(); + }, + function ($signal) { + if ($this->signals->count($signal) === 0) { + $this->signalEvents[$signal]->del(); + unset($this->signalEvents[$signal]); + } + } + ); + $this->createTimerCallback(); $this->createStreamCallback(); } @@ -158,6 +181,22 @@ public function futureTick(callable $listener) $this->futureTickQueue->add($listener); } + /** + * {@inheritdoc} + */ + public function addSignal($signal, callable $listener) + { + $this->signals->add($signal, $listener); + } + + /** + * {@inheritdoc} + */ + public function removeSignal($signal, callable $listener) + { + $this->signals->remove($signal, $listener); + } + /** * {@inheritdoc} */ diff --git a/src/LibEvLoop.php b/src/LibEvLoop.php index 3bbd8c4e..ad282820 100644 --- a/src/LibEvLoop.php +++ b/src/LibEvLoop.php @@ -4,6 +4,7 @@ use libev\EventLoop; use libev\IOEvent; +use libev\SignalEvent; use libev\TimerEvent; use React\EventLoop\Tick\FutureTickQueue; use React\EventLoop\Timer\Timer; @@ -22,12 +23,35 @@ class LibEvLoop implements LoopInterface private $readEvents = []; private $writeEvents = []; private $running; + private $signals; + private $signalEvents = []; public function __construct() { $this->loop = new EventLoop(); $this->futureTickQueue = new FutureTickQueue(); $this->timerEvents = new SplObjectStorage(); + + $this->signals = new SignalsHandler( + $this, + function ($signal) { + $this->signalEvents[$signal] = new SignalEvent($f = function () use ($signal, &$f) { + $this->signals->call($signal); + // Ensure there are two copies of the callable around until it has been executed. + // For more information see: https://bugs.php.net/bug.php?id=62452 + // Only an issue for PHP 5, this hack can be removed once PHP 5 suppose has been dropped. + $g = $f; + $f = $g; + }, $signal); + $this->loop->add($this->signalEvents[$signal]); + }, + function ($signal) { + if ($this->signals->count($signal) === 0) { + $this->loop->remove($this->signalEvents[$signal]); + unset($this->signalEvents[$signal]); + } + } + ); } /** @@ -170,6 +194,22 @@ public function futureTick(callable $listener) $this->futureTickQueue->add($listener); } + /** + * {@inheritdoc} + */ + public function addSignal($signal, callable $listener) + { + $this->signals->add($signal, $listener); + } + + /** + * {@inheritdoc} + */ + public function removeSignal($signal, callable $listener) + { + $this->signals->remove($signal, $listener); + } + /** * {@inheritdoc} */ diff --git a/src/LibEventLoop.php b/src/LibEventLoop.php index ee648e7f..299243c2 100644 --- a/src/LibEventLoop.php +++ b/src/LibEventLoop.php @@ -4,6 +4,7 @@ use Event; use EventBase; +use React\EventLoop\Signal\Pcntl; use React\EventLoop\Tick\FutureTickQueue; use React\EventLoop\Timer\Timer; use React\EventLoop\Timer\TimerInterface; @@ -26,6 +27,8 @@ class LibEventLoop implements LoopInterface private $readListeners = []; private $writeListeners = []; private $running; + private $signals; + private $signalEvents = []; public function __construct() { @@ -33,6 +36,30 @@ public function __construct() $this->futureTickQueue = new FutureTickQueue(); $this->timerEvents = new SplObjectStorage(); + $this->signals = new SignalsHandler( + $this, + function ($signal) { + $this->signalEvents[$signal] = event_new(); + event_set($this->signalEvents[$signal], $signal, EV_PERSIST | EV_SIGNAL, $f = function () use ($signal, &$f) { + $this->signals->call($signal); + // Ensure there are two copies of the callable around until it has been executed. + // For more information see: https://bugs.php.net/bug.php?id=62452 + // Only an issue for PHP 5, this hack can be removed once PHP 5 suppose has been dropped. + $g = $f; + $f = $g; + }); + event_base_set($this->signalEvents[$signal], $this->eventBase); + event_add($this->signalEvents[$signal]); + }, + function ($signal) { + if ($this->signals->count($signal) === 0) { + event_del($this->signalEvents[$signal]); + event_free($this->signalEvents[$signal]); + unset($this->signalEvents[$signal]); + } + } + ); + $this->createTimerCallback(); $this->createStreamCallback(); } @@ -166,6 +193,22 @@ public function futureTick(callable $listener) $this->futureTickQueue->add($listener); } + /** + * {@inheritdoc} + */ + public function addSignal($signal, callable $listener) + { + $this->signals->add($signal, $listener); + } + + /** + * {@inheritdoc} + */ + public function removeSignal($signal, callable $listener) + { + $this->signals->remove($signal, $listener); + } + /** * {@inheritdoc} */ diff --git a/src/LoopInterface.php b/src/LoopInterface.php index a4d394c0..595209de 100644 --- a/src/LoopInterface.php +++ b/src/LoopInterface.php @@ -326,6 +326,41 @@ public function isTimerActive(TimerInterface $timer); */ public function futureTick(callable $listener); + /** + * Registers a signal listener with the loop, which + * on it's turn registers it with a signal handler + * suitable for the loop implementation. + * + * A listener can only be added once, any attempts + * to add it again will be ignored. + * + * See also [example #4](examples). + * + * @param int $signal + * @param callable $listener + * + * @throws \BadMethodCallException when signals + * aren't supported by the loop, e.g. when required + * extensions are missing. + * + * @return void + */ + public function addSignal($signal, callable $listener); + + /** + * Removed previous registered signal listener from + * the loop, which on it's turn removes it from the + * underlying signal handler. + * + * See also [example #4](examples). + * + * @param int $signal + * @param callable $listener + * + * @return void + */ + public function removeSignal($signal, callable $listener); + /** * Run the event loop until there are no more tasks to perform. */ diff --git a/src/SignalsHandler.php b/src/SignalsHandler.php new file mode 100644 index 00000000..c91bf1e2 --- /dev/null +++ b/src/SignalsHandler.php @@ -0,0 +1,97 @@ +loop = $loop; + $this->on = $on; + $this->off = $off; + } + + public function __destruct() + { + $off = $this->off; + foreach ($this->signals as $signal => $listeners) { + $off($signal); + } + } + + public function add($signal, callable $listener) + { + if (count($this->signals) == 0 && $this->timer === null) { + /** + * Timer to keep the loop alive as long as there are any signal handlers registered + */ + $this->timer = $this->loop->addPeriodicTimer(300, function () {}); + } + + if (!isset($this->signals[$signal])) { + $this->signals[$signal] = []; + + $on = $this->on; + $on($signal); + } + + if (in_array($listener, $this->signals[$signal])) { + return; + } + + $this->signals[$signal][] = $listener; + } + + public function remove($signal, callable $listener) + { + if (!isset($this->signals[$signal])) { + return; + } + + $index = \array_search($listener, $this->signals[$signal], true); + unset($this->signals[$signal][$index]); + + if (isset($this->signals[$signal]) && \count($this->signals[$signal]) === 0) { + unset($this->signals[$signal]); + + $off = $this->off; + $off($signal); + } + + if (count($this->signals) == 0 && $this->timer instanceof TimerInterface) { + $this->loop->cancelTimer($this->timer); + $this->timer = null; + } + } + + public function call($signal) + { + if (!isset($this->signals[$signal])) { + return; + } + + foreach ($this->signals[$signal] as $listener) { + \call_user_func($listener, $signal); + } + } + + public function count($signal) + { + if (!isset($this->signals[$signal])) { + return 0; + } + + return \count($this->signals[$signal]); + } +} diff --git a/src/StreamSelectLoop.php b/src/StreamSelectLoop.php index 085c7703..18de6218 100644 --- a/src/StreamSelectLoop.php +++ b/src/StreamSelectLoop.php @@ -2,6 +2,7 @@ namespace React\EventLoop; +use React\EventLoop\Signal\Pcntl; use React\EventLoop\Tick\FutureTickQueue; use React\EventLoop\Timer\Timer; use React\EventLoop\Timer\TimerInterface; @@ -21,11 +22,32 @@ class StreamSelectLoop implements LoopInterface private $writeStreams = []; private $writeListeners = []; private $running; + private $pcntl = false; + private $signals; public function __construct() { $this->futureTickQueue = new FutureTickQueue(); $this->timers = new Timers(); + $this->pcntl = extension_loaded('pcntl'); + $this->signals = new SignalsHandler( + $this, + function ($signal) { + \pcntl_signal($signal, $f = function ($signal) use (&$f) { + $this->signals->call($signal); + // Ensure there are two copies of the callable around until it has been executed. + // For more information see: https://bugs.php.net/bug.php?id=62452 + // Only an issue for PHP 5, this hack can be removed once PHP 5 suppose has been dropped. + $g = $f; + $f = $g; + }); + }, + function ($signal) { + if ($this->signals->count($signal) === 0) { + \pcntl_signal($signal, SIG_DFL); + } + } + ); } /** @@ -137,6 +159,26 @@ public function futureTick(callable $listener) $this->futureTickQueue->add($listener); } + /** + * {@inheritdoc} + */ + public function addSignal($signal, callable $listener) + { + if ($this->pcntl === false) { + throw new \BadMethodCallException('Event loop feature "signals" isn\'t supported by the "StreamSelectLoop"'); + } + + $this->signals->add($signal, $listener); + } + + /** + * {@inheritdoc} + */ + public function removeSignal($signal, callable $listener) + { + $this->signals->remove($signal, $listener); + } + /** * {@inheritdoc} */ @@ -196,6 +238,9 @@ private function waitForStreamActivity($timeout) $write = $this->writeStreams; $available = $this->streamSelect($read, $write, $timeout); + if ($this->pcntl) { + \pcntl_signal_dispatch(); + } if (false === $available) { // if a system call has been interrupted, // we cannot rely on it's outcome @@ -243,4 +288,20 @@ protected function streamSelect(array &$read, array &$write, $timeout) return 0; } + + /** + * Iterate over signal listeners for the given signal + * and call each of them with the signal as first + * argument. + * + * @param int $signal + * + * @return void + */ + private function handleSignal($signal) + { + foreach ($this->signals[$signal] as $listener) { + \call_user_func($listener, $signal); + } + } } diff --git a/tests/AbstractLoopTest.php b/tests/AbstractLoopTest.php index 2470e555..69bf279f 100644 --- a/tests/AbstractLoopTest.php +++ b/tests/AbstractLoopTest.php @@ -398,6 +398,96 @@ function () { $this->loop->run(); } + public function testSignal() + { + if (!function_exists('posix_kill') || !function_exists('posix_getpid')) { + $this->markTestSkipped('Signal test skipped because functions "posix_kill" and "posix_getpid" are missing.'); + } + + $called = false; + $calledShouldNot = true; + + $timer = $this->loop->addPeriodicTimer(1, function () {}); + + $this->loop->addSignal(SIGUSR2, $func2 = function () use (&$calledShouldNot) { + $calledShouldNot = false; + }); + + $this->loop->addSignal(SIGUSR1, $func1 = function () use (&$func1, &$func2, &$called, $timer) { + $called = true; + $this->loop->removeSignal(SIGUSR1, $func1); + $this->loop->removeSignal(SIGUSR2, $func2); + $this->loop->cancelTimer($timer); + }); + + $this->loop->futureTick(function () { + posix_kill(posix_getpid(), SIGUSR1); + }); + + $this->loop->run(); + + $this->assertTrue($called); + $this->assertTrue($calledShouldNot); + } + + public function testSignalMultipleUsagesForTheSameListener() + { + $funcCallCount = 0; + $func = function () use (&$funcCallCount) { + $funcCallCount++; + }; + $this->loop->addTimer(1, function () {}); + + $this->loop->addSignal(SIGUSR1, $func); + $this->loop->addSignal(SIGUSR1, $func); + + $this->loop->addTimer(0.4, function () { + posix_kill(posix_getpid(), SIGUSR1); + }); + $this->loop->addTimer(0.9, function () use (&$func) { + $this->loop->removeSignal(SIGUSR1, $func); + }); + + $this->loop->run(); + + $this->assertSame(1, $funcCallCount); + } + + public function testSignalsKeepTheLoopRunning() + { + $function = function () {}; + $this->loop->addSignal(SIGUSR1, $function); + $this->loop->addTimer(1.5, function () use ($function) { + $this->loop->removeSignal(SIGUSR1, $function); + $this->loop->stop(); + }); + + $this->assertRunSlowerThan(1.5); + } + + public function testSignalsKeepTheLoopRunningAndRemovingItStopsTheLoop() + { + $function = function () {}; + $this->loop->addSignal(SIGUSR1, $function); + $this->loop->addTimer(1.5, function () use ($function) { + $this->loop->removeSignal(SIGUSR1, $function); + }); + + $this->assertRunFasterThan(1.6); + } + + private function assertRunSlowerThan($minInterval) + { + $start = microtime(true); + + $this->loop->run(); + + $end = microtime(true); + $interval = $end - $start; + + $this->assertLessThan($interval, $minInterval); + } + private function assertRunFasterThan($maxInterval) { $start = microtime(true); diff --git a/tests/SignalsHandlerTest.php b/tests/SignalsHandlerTest.php new file mode 100644 index 00000000..04f37151 --- /dev/null +++ b/tests/SignalsHandlerTest.php @@ -0,0 +1,92 @@ +assertSame(0, $callCount); + $this->assertSame(0, $onCount); + $this->assertSame(0, $offCount); + + $signals->add(SIGUSR1, $func); + $this->assertSame(0, $callCount); + $this->assertSame(1, $onCount); + $this->assertSame(0, $offCount); + + $signals->add(SIGUSR1, $func); + $this->assertSame(0, $callCount); + $this->assertSame(1, $onCount); + $this->assertSame(0, $offCount); + + $signals->add(SIGUSR1, $func); + $this->assertSame(0, $callCount); + $this->assertSame(1, $onCount); + $this->assertSame(0, $offCount); + + $signals->call(SIGUSR1); + $this->assertSame(1, $callCount); + $this->assertSame(1, $onCount); + $this->assertSame(0, $offCount); + + $signals->add(SIGUSR2, $func); + $this->assertSame(1, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(0, $offCount); + + $signals->add(SIGUSR2, $func); + $this->assertSame(1, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(0, $offCount); + + $signals->call(SIGUSR2); + $this->assertSame(2, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(0, $offCount); + + $signals->remove(SIGUSR2, $func); + $this->assertSame(2, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(1, $offCount); + + $signals->remove(SIGUSR2, $func); + $this->assertSame(2, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(1, $offCount); + + $signals->call(SIGUSR2); + $this->assertSame(2, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(1, $offCount); + + $signals->remove(SIGUSR1, $func); + $this->assertSame(2, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(2, $offCount); + + $signals->call(SIGUSR1); + $this->assertSame(2, $callCount); + $this->assertSame(2, $onCount); + $this->assertSame(2, $offCount); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d97d8b77..ea7dd4cc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,3 +5,11 @@ $loader = require __DIR__ . '/../../../../vendor/autoload.php'; } $loader->addPsr4('React\\Tests\\EventLoop\\', __DIR__); + +if (!defined('SIGUSR1')) { + define('SIGUSR1', 1); +} + +if (!defined('SIGUSR2')) { + define('SIGUSR2', 2); +}