Skip to content

Commit

Permalink
Add SignalCancellation (#390)
Browse files Browse the repository at this point in the history
  • Loading branch information
trowski authored Jun 14, 2022
1 parent 557f98d commit 01c289e
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 16 deletions.
3 changes: 0 additions & 3 deletions src/CompositeCancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -98,7 +96,6 @@ public function isRequested(): bool
return false;
}

/** @inheritdoc */
public function throwIfRequested(): void
{
foreach ($this->cancellations as [$cancellation]) {
Expand Down
3 changes: 0 additions & 3 deletions src/NullCancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions src/SignalCancellation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

namespace Amp;

use Revolt\EventLoop;

/**
* A SignalCancellation automatically requests cancellation when a given signal is received.
*/
final class SignalCancellation implements Cancellation
{
use ForbidCloning;
use ForbidSerialization;

/** @var list<string> */
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();
}
}
19 changes: 19 additions & 0 deletions src/SignalException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Amp;

/**
* Used as the previous exception to {@see CancelledException} when a {@see SignalCancellation} is triggered.
*
* @see SignalCancellation
*/
class SignalException extends \Exception
{
/**
* @param string $message Exception message.
*/
public function __construct(string $message = "Operation cancelled by signal")
{
parent::__construct($message);
}
}
9 changes: 0 additions & 9 deletions src/TimeoutCancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,25 +53,16 @@ public function subscribe(\Closure $callback): string
return $this->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();
Expand Down
2 changes: 1 addition & 1 deletion src/TimeoutException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
55 changes: 55 additions & 0 deletions test/Cancellation/SignalCancellationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Amp\Cancellation;

use Amp\CancelledException;
use Amp\PHPUnit\AsyncTestCase;
use Amp\SignalCancellation;
use Amp\SignalException;
use Revolt\EventLoop;
use function Amp\delay;

/**
* @requires ext-pcntl
* @requires ext-posix
*/
class SignalCancellationTest extends AsyncTestCase
{
public function testSignal(): void
{
$line = __LINE__ + 1;
$cancellation = new SignalCancellation([\SIGUSR1, \SIGUSR2]);

self::assertFalse($cancellation->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"]);
}
}

0 comments on commit 01c289e

Please sign in to comment.