From 01c289ec8df91ce6f43e25e4fa00d669f4faa862 Mon Sep 17 00:00:00 2001 From: Aaron Piotrowski Date: Tue, 14 Jun 2022 18:21:55 -0500 Subject: [PATCH] Add SignalCancellation (#390) --- src/CompositeCancellation.php | 3 - src/NullCancellation.php | 3 - src/SignalCancellation.php | 87 ++++++++++++++++++++ src/SignalException.php | 19 +++++ src/TimeoutCancellation.php | 9 -- src/TimeoutException.php | 2 +- test/Cancellation/SignalCancellationTest.php | 55 +++++++++++++ 7 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 src/SignalCancellation.php create mode 100644 src/SignalException.php create mode 100644 test/Cancellation/SignalCancellationTest.php diff --git a/src/CompositeCancellation.php b/src/CompositeCancellation.php index 95c38fb9..6a4ab416 100644 --- a/src/CompositeCancellation.php +++ b/src/CompositeCancellation.php @@ -80,13 +80,11 @@ public function subscribe(\Closure $callback): string return $id; } - /** @inheritdoc */ public function unsubscribe(string $id): void { unset($this->callbacks[$id]); } - /** @inheritdoc */ public function isRequested(): bool { foreach ($this->cancellations as [$cancellation]) { @@ -98,7 +96,6 @@ public function isRequested(): bool return false; } - /** @inheritdoc */ public function throwIfRequested(): void { foreach ($this->cancellations as [$cancellation]) { diff --git a/src/NullCancellation.php b/src/NullCancellation.php index 25c82805..cd4e80a8 100644 --- a/src/NullCancellation.php +++ b/src/NullCancellation.php @@ -32,19 +32,16 @@ public function subscribe(\Closure $callback): string return "null-cancellation"; } - /** @inheritdoc */ public function unsubscribe(string $id): void { // nothing to do } - /** @inheritdoc */ public function isRequested(): bool { return false; } - /** @inheritdoc */ public function throwIfRequested(): void { // nothing to do diff --git a/src/SignalCancellation.php b/src/SignalCancellation.php new file mode 100644 index 00000000..488ec6f3 --- /dev/null +++ b/src/SignalCancellation.php @@ -0,0 +1,87 @@ + */ + private readonly array $watchers; + + private readonly Cancellation $cancellation; + + /** + * @param int|int[] $signals Signal number or array of signal numbers. + * @param string $message Message for SignalException. Default is "Operation cancelled by signal". + */ + public function __construct(int|array $signals, string $message = "Operation cancelled by signal") + { + if (\is_int($signals)) { + $signals = [$signals]; + } + + $this->cancellation = $source = new Internal\Cancellable; + + $trace = null; // Defined in case assertions are disabled. + \assert((bool) ($trace = \debug_backtrace(0))); + + $watchers = []; + + $callback = static function () use (&$watchers, $source, $message, $trace): void { + foreach ($watchers as $watcher) { + EventLoop::cancel($watcher); + } + + if ($trace) { + $message .= \sprintf("\r\n%s was created here: %s", self::class, Internal\formatStacktrace($trace)); + } else { + $message .= \sprintf(" (Enable assertions for a backtrace of the %s creation)", self::class); + } + + $source->cancel(new SignalException($message)); + }; + + foreach ($signals as $signal) { + $watchers[] = EventLoop::unreference(EventLoop::onSignal($signal, $callback)); + } + + $this->watchers = $watchers; + } + + /** + * Cancels the delay watcher. + */ + public function __destruct() + { + foreach ($this->watchers as $watcher) { + EventLoop::cancel($watcher); + } + } + + public function subscribe(\Closure $callback): string + { + return $this->cancellation->subscribe($callback); + } + + public function unsubscribe(string $id): void + { + $this->cancellation->unsubscribe($id); + } + + public function isRequested(): bool + { + return $this->cancellation->isRequested(); + } + + public function throwIfRequested(): void + { + $this->cancellation->throwIfRequested(); + } +} diff --git a/src/SignalException.php b/src/SignalException.php new file mode 100644 index 00000000..191c8a03 --- /dev/null +++ b/src/SignalException.php @@ -0,0 +1,19 @@ +cancellation->subscribe($callback); } - /** - * {@inheritdoc} - */ public function unsubscribe(string $id): void { $this->cancellation->unsubscribe($id); } - /** - * {@inheritdoc} - */ public function isRequested(): bool { return $this->cancellation->isRequested(); } - /** - * {@inheritdoc} - */ public function throwIfRequested(): void { $this->cancellation->throwIfRequested(); diff --git a/src/TimeoutException.php b/src/TimeoutException.php index d2a7230f..eece40f6 100644 --- a/src/TimeoutException.php +++ b/src/TimeoutException.php @@ -3,7 +3,7 @@ namespace Amp; /** - * Thrown if a promise doesn't resolve within a specified timeout. + * Used as the previous exception to {@see CancelledException} when a {@see TimeoutCancellation} expires. * * @see TimeoutCancellation */ diff --git a/test/Cancellation/SignalCancellationTest.php b/test/Cancellation/SignalCancellationTest.php new file mode 100644 index 00000000..1d17aeca --- /dev/null +++ b/test/Cancellation/SignalCancellationTest.php @@ -0,0 +1,55 @@ +isRequested()); + + EventLoop::defer(function (): void { + \posix_kill(\getmypid(), \SIGUSR1); + }); + + delay(0.1); + + self::assertTrue($cancellation->isRequested()); + + try { + $cancellation->throwIfRequested(); + } catch (CancelledException $exception) { + self::assertInstanceOf(SignalException::class, $exception->getPrevious()); + + $message = $exception->getPrevious()->getMessage(); + + if ((int) \ini_get('zend.assertions') > 0) { + self::assertStringContainsString('SignalCancellation was created here', $message); + self::assertStringContainsString('SignalCancellationTest.php:' . $line, $message); + } + } + } + + public function testWatcherCancellation(): void + { + $enabled = EventLoop::getInfo()["on_signal"]["enabled"]; + $cancellation = new SignalCancellation([\SIGUSR1, \SIGUSR2]); + self::assertSame($enabled + 2, EventLoop::getInfo()["on_signal"]["enabled"]); + unset($cancellation); + self::assertSame($enabled, EventLoop::getInfo()["on_signal"]["enabled"]); + } +}