Skip to content
Open
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ jobs:

- operating-system: 'ubuntu-latest'
php-version: '8.2'
psalm: 'skip'

- operating-system: 'ubuntu-latest'
php-version: '8.3'
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@
],
"require": {
"php": ">=8.1",
"revolt/event-loop": "^1 || ^0.2"
"revolt/event-loop": "^1 || ^0.2",
"symfony/polyfill-php83": "^1.43"
},
"require-dev": {
"amphp/php-cs-fixer-config": "^2",
"phpunit/phpunit": "^9",
"psalm/phar": "6.16.1"
"psalm/phar": "7.0.0-rc10"
},
"autoload": {
"psr-4": {
Expand Down
58 changes: 0 additions & 58 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,62 +10,4 @@
<projectFiles>
<directory name="src" />
</projectFiles>

<issueHandlers>
<ClassMustBeFinal>
<errorLevel type="suppress">
<file name="src/CancelledException.php"/>
<file name="src/SignalException.php"/>
<file name="src/TimeoutException.php"/>
</errorLevel>
</ClassMustBeFinal>

<DocblockTypeContradiction>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</DocblockTypeContradiction>

<MissingClosureReturnType>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</MissingClosureReturnType>

<PossiblyUnusedMethod>
<errorLevel type="suppress">
<directory name="src"/>
</errorLevel>
</PossiblyUnusedMethod>

<RedundantConditionGivenDocblockType>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</RedundantConditionGivenDocblockType>

<StringIncrement>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</StringIncrement>

<UnusedClass>
<errorLevel type="suppress">
<directory name="src"/>
</errorLevel>
</UnusedClass>

<UnusedVariable>
<errorLevel type="suppress">
<file name="src/functions.php"/>
</errorLevel>
</UnusedVariable>

<UnsupportedPropertyReferenceUsage>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</UnsupportedPropertyReferenceUsage>
</issueHandlers>
</psalm>
17 changes: 17 additions & 0 deletions src/Cancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

/**
* Cancellations are simple objects that allow registering handlers to subscribe to cancellation requests.
*
* What {@see subscribe()} and {@see unsubscribe()} may do depends on the implementation. Callers that accept any
* cancellation inherit that implementation's capabilities.
*
* @psalm-purity-template read-props <= C(read-props|write-this-props|write-props|write-refs|io) <= read-props|write-this-props|write-props|write-refs|io
*
* @api
*/
interface Cancellation
{
Expand All @@ -17,25 +24,35 @@ interface Cancellation
* `CancelledException` as first argument that may be used to fail the operation.
*
* @return string Identifier that can be used to cancel the subscription.
*
* @psalm-pure
* @psalm-purity-from-template C
*/
public function subscribe(\Closure $callback): string;

/**
* Unsubscribes a previously registered handler.
*
* The handler will no longer be called as long as this method isn't invoked from a subscribed callback.
*
* @psalm-pure
* @psalm-purity-from-template C
*/
public function unsubscribe(string $id): void;

/**
* Returns whether cancellation has been requested yet.
*
* @psalm-capabilities read-props
*/
public function isRequested(): bool;

/**
* Throws the `CancelledException` if cancellation has been requested, otherwise does nothing.
*
* @throws CancelledException
*
* @psalm-capabilities read-props|write-this-props|write-props|write-refs
*/
public function throwIfRequested(): void;
}
3 changes: 3 additions & 0 deletions src/CancelledException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
*
* @see Cancellation
* @see DeferredCancellation
*
* @api
*/
class CancelledException extends \Exception
{
/** @psalm-capabilities read-props */
public function __construct(?\Throwable $previous = null)
{
parent::__construct("The operation was cancelled", 0, $previous);
Expand Down
16 changes: 16 additions & 0 deletions src/Closable.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,41 @@

namespace Amp;

/**
* A resource that can be closed.
*
* This package does not implement the interface. Closing a resource may suspend the event loop and mutate state, so
* the methods are impure and implementors are not restricted further.
*
* @psalm-mutable
*
* @api
*/
interface Closable
{
/**
* Closes the resource, marking it as unusable.
* Whether pending operations are aborted or not is implementation dependent.
*
* @psalm-impure
*/
public function close(): void;

/**
* Returns whether this resource has been closed.
*
* @return bool `true` if closed, otherwise `false`.
*
* @psalm-impure
*/
public function isClosed(): bool;

/**
* Registers a callback that is invoked when this resource is closed.
*
* @param \Closure():void $onClose
*
* @psalm-impure
*/
public function onClose(\Closure $onClose): void;
}
54 changes: 38 additions & 16 deletions src/CompositeCancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

use Revolt\EventLoop;

/**
* A cancellation that is requested when any of the given cancellations is requested.
*
* @implements Cancellation[read-props|write-this-props|write-refs|io]
*
* @api
*/
final class CompositeCancellation implements Cancellation
{
use ForbidCloning;
Expand All @@ -12,7 +19,7 @@
/** @var array<int, array{Cancellation, string}> */
private array $cancellations = [];

private string $nextId = "a";
private string $nextId = 'a';

/** @var array<string, \Closure(CancelledException): void> */
private array $callbacks = [];
Expand All @@ -21,31 +28,38 @@

public function __construct(Cancellation ...$cancellations)
{
$thatException = &$this->exception;
$thatCallbacks = &$this->callbacks;
$thatCancellations = &$this->cancellations;
// The callback shares these properties by reference and does not retain this object, so
// destroying the cancellation unsubscribes while a child still holds the callback.
$subscribed = [];
$callbacks = [];
$currentException = null;

$this->cancellations = &$subscribed;
$this->callbacks = &$callbacks;
$this->exception = &$currentException;

$onCancel = static function (CancelledException $exception) use (
&$thatException,
&$thatCallbacks,
&$thatCancellations,
&$subscribed,
&$callbacks,
&$currentException,
): void {
if ($thatException) {
if ($currentException) {
return;
}

$thatException = $exception;
$currentException = $exception;

foreach ($thatCancellations as [$cancellation, $id]) {
foreach ($subscribed as [$cancellation, $id]) {
$cancellation->unsubscribe($id);
}

$thatCancellations = [];
$subscribed = [];

foreach ($thatCallbacks as $callback) {
foreach ($callbacks as $callback) {
EventLoop::queue($callback, $exception);
}

$thatCallbacks = [];
$callbacks = [];
};

foreach ($cancellations as $cancellation) {
Expand All @@ -65,14 +79,17 @@
$this->cancellations = [];
}

/**
* @psalm-capabilities read-props|write-this-props|write-refs|io
*/
#[\Override]
public function subscribe(\Closure $callback): string
{
$id = $this->nextId;
\PHP_VERSION_ID >= 80300 ? $this->nextId = \str_increment($this->nextId) : ++$this->nextId;
$this->nextId = \str_increment($this->nextId);

Check failure on line 89 in src/CompositeCancellation.php

View workflow job for this annotation

GitHub Actions / PHP 8.2

ImpureFunctionCall

src/CompositeCancellation.php:89:25: ImpureFunctionCall: The context is read-props|write-this-props|write-refs|io but function call on str_increment requires impure (see https://psalm.dev/202)

if ($this->exception) {
EventLoop::queue($callback, $this->exception);

Check failure on line 92 in src/CompositeCancellation.php

View workflow job for this annotation

GitHub Actions / PHP 8.3

ImpureMethodCall

src/CompositeCancellation.php:92:13: ImpureMethodCall: The context is read-props|write-this-props|write-refs|io but method Revolt\EventLoop::queue requires impure (see https://psalm.dev/203)

Check failure on line 92 in src/CompositeCancellation.php

View workflow job for this annotation

GitHub Actions / PHP 8.2

ImpureMethodCall

src/CompositeCancellation.php:92:13: ImpureMethodCall: The context is read-props|write-this-props|write-refs|io but method Revolt\EventLoop::queue requires impure (see https://psalm.dev/203)

Check failure on line 92 in src/CompositeCancellation.php

View workflow job for this annotation

GitHub Actions / PHP 8.5

ImpureMethodCall

src/CompositeCancellation.php:92:13: ImpureMethodCall: The context is read-props|write-this-props|write-refs|io but method Revolt\EventLoop::queue requires impure (see https://psalm.dev/203)

Check failure on line 92 in src/CompositeCancellation.php

View workflow job for this annotation

GitHub Actions / PHP 8.4

ImpureMethodCall

src/CompositeCancellation.php:92:13: ImpureMethodCall: The context is read-props|write-this-props|write-refs|io but method Revolt\EventLoop::queue requires impure (see https://psalm.dev/203)
} else {
$this->callbacks[$id] = $callback;
}
Expand All @@ -80,20 +97,24 @@
return $id;
}

/**
* @psalm-capabilities read-props|write-this-props|write-refs
*/
#[\Override]
public function unsubscribe(string $id): void
{
unset($this->callbacks[$id]);
}

/** @psalm-capabilities read-props */
#[\Override]
public function isRequested(): bool
{
if ($this->exception) {
return true;
}

// Check each cancellation directly, as the callback setting $this->exception is invoked asynchronously.
// Check each cancellation directly, as the callback setting the exception is invoked asynchronously.
foreach ($this->cancellations as [$cancellation]) {
if ($cancellation->isRequested()) {
return true;
Expand All @@ -103,14 +124,15 @@
return false;
}

/** @psalm-capabilities read-props|write-props|write-refs */
#[\Override]
public function throwIfRequested(): void
{
if ($this->exception) {
throw $this->exception;
}

// Check each cancellation directly, as the callback setting $this->exception is invoked asynchronously.
// Check each cancellation directly, as the callback setting the exception is invoked asynchronously.
foreach ($this->cancellations as [$cancellation]) {
$cancellation->throwIfRequested();
}
Expand Down
9 changes: 9 additions & 0 deletions src/CompositeException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

namespace Amp;

/**
* Thrown when multiple operations fail and each failure should be retained.
*
* @api
*/
final class CompositeException extends \Exception
{
/**
Expand All @@ -14,6 +19,8 @@ final class CompositeException extends \Exception
* @param string|null $message Exception message, defaults to message generated from passed exceptions.
*
* @psalm-assert non-empty-array<array-key, \Throwable> $reasons
*
* @psalm-capabilities read-props
*/
public function __construct(array $reasons, ?string $message = null)
{
Expand All @@ -32,6 +39,8 @@ public function getReasons(): array

/**
* @param non-empty-array<array-key, \Throwable> $reasons
*
* @psalm-pure
*/
private function generateMessage(array $reasons): string
{
Expand Down
6 changes: 6 additions & 0 deletions src/CompositeLengthException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

namespace Amp;

/**
* Thrown when a future combinator is given fewer futures than it requires.
*
* @api
*/
final class CompositeLengthException extends \Exception
{
/** @psalm-capabilities read-props */
public function __construct(string $message)
{
parent::__construct($message);
Expand Down
4 changes: 4 additions & 0 deletions src/DeferredCancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
*
* @see Cancellation
* @see CancelledException
*
* @api
*/
final class DeferredCancellation
{
Expand All @@ -44,6 +46,7 @@ final class DeferredCancellation

private readonly Cancellation $cancellation;

/** @psalm-capabilities read-props */
public function __construct()
{
$this->source = new Internal\Cancellable();
Expand All @@ -60,6 +63,7 @@ public function getCancellation(): Cancellation
return $this->cancellation;
}

/** @psalm-capabilities read-props */
public function isCancelled(): bool
{
return $this->source->isRequested();
Expand Down
Loading
Loading