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); +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy