diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e3888b99..cc84ff10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,7 +15,6 @@ jobs:
- operating-system: 'ubuntu-latest'
php-version: '8.2'
- psalm: 'skip'
- operating-system: 'ubuntu-latest'
php-version: '8.3'
diff --git a/composer.json b/composer.json
index a9f22db2..c2c52d92 100644
--- a/composer.json
+++ b/composer.json
@@ -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": {
diff --git a/psalm.xml b/psalm.xml
index 7d6bdb8f..597c3cb2 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -10,62 +10,4 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Cancellation.php b/src/Cancellation.php
index 51f56449..b9ffe35f 100644
--- a/src/Cancellation.php
+++ b/src/Cancellation.php
@@ -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
{
@@ -17,6 +24,9 @@ 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;
@@ -24,11 +34,16 @@ 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;
@@ -36,6 +51,8 @@ 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;
}
diff --git a/src/CancelledException.php b/src/CancelledException.php
index 8f21b8f9..e923e5a3 100644
--- a/src/CancelledException.php
+++ b/src/CancelledException.php
@@ -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);
diff --git a/src/Closable.php b/src/Closable.php
index a36ed27c..f0ca2789 100644
--- a/src/Closable.php
+++ b/src/Closable.php
@@ -2,11 +2,23 @@
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;
@@ -14,6 +26,8 @@ public function close(): void;
* Returns whether this resource has been closed.
*
* @return bool `true` if closed, otherwise `false`.
+ *
+ * @psalm-impure
*/
public function isClosed(): bool;
@@ -21,6 +35,8 @@ 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;
}
diff --git a/src/CompositeCancellation.php b/src/CompositeCancellation.php
index 94ac3af5..b49e9b72 100644
--- a/src/CompositeCancellation.php
+++ b/src/CompositeCancellation.php
@@ -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;
@@ -12,7 +19,7 @@ final class CompositeCancellation implements Cancellation
/** @var array */
private array $cancellations = [];
- private string $nextId = "a";
+ private string $nextId = 'a';
/** @var array */
private array $callbacks = [];
@@ -21,31 +28,38 @@ final class CompositeCancellation implements Cancellation
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) {
@@ -65,11 +79,14 @@ public function __destruct()
$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);
if ($this->exception) {
EventLoop::queue($callback, $this->exception);
@@ -80,12 +97,16 @@ public function subscribe(\Closure $callback): string
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
{
@@ -93,7 +114,7 @@ public function isRequested(): bool
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;
@@ -103,6 +124,7 @@ public function isRequested(): bool
return false;
}
+ /** @psalm-capabilities read-props|write-props|write-refs */
#[\Override]
public function throwIfRequested(): void
{
@@ -110,7 +132,7 @@ public function throwIfRequested(): void
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();
}
diff --git a/src/CompositeException.php b/src/CompositeException.php
index 1093ba56..704b8b57 100644
--- a/src/CompositeException.php
+++ b/src/CompositeException.php
@@ -2,6 +2,11 @@
namespace Amp;
+/**
+ * Thrown when multiple operations fail and each failure should be retained.
+ *
+ * @api
+ */
final class CompositeException extends \Exception
{
/**
@@ -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 $reasons
+ *
+ * @psalm-capabilities read-props
*/
public function __construct(array $reasons, ?string $message = null)
{
@@ -32,6 +39,8 @@ public function getReasons(): array
/**
* @param non-empty-array $reasons
+ *
+ * @psalm-pure
*/
private function generateMessage(array $reasons): string
{
diff --git a/src/CompositeLengthException.php b/src/CompositeLengthException.php
index 225936aa..bfcdfdad 100644
--- a/src/CompositeLengthException.php
+++ b/src/CompositeLengthException.php
@@ -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);
diff --git a/src/DeferredCancellation.php b/src/DeferredCancellation.php
index 7480f988..3c828d1b 100644
--- a/src/DeferredCancellation.php
+++ b/src/DeferredCancellation.php
@@ -34,6 +34,8 @@
*
* @see Cancellation
* @see CancelledException
+ *
+ * @api
*/
final class DeferredCancellation
{
@@ -44,6 +46,7 @@ final class DeferredCancellation
private readonly Cancellation $cancellation;
+ /** @psalm-capabilities read-props */
public function __construct()
{
$this->source = new Internal\Cancellable();
@@ -60,6 +63,7 @@ public function getCancellation(): Cancellation
return $this->cancellation;
}
+ /** @psalm-capabilities read-props */
public function isCancelled(): bool
{
return $this->source->isRequested();
diff --git a/src/DeferredFuture.php b/src/DeferredFuture.php
index e45a582e..d0ab7159 100644
--- a/src/DeferredFuture.php
+++ b/src/DeferredFuture.php
@@ -4,6 +4,8 @@
/**
* @template T
+ *
+ * @api
*/
final class DeferredFuture
{
@@ -44,6 +46,8 @@ public function error(\Throwable $throwable): void
/**
* @return bool True if the operation has completed.
+ *
+ * @psalm-capabilities read-props
*/
public function isComplete(): bool
{
diff --git a/src/ForbidCloning.php b/src/ForbidCloning.php
index 3dd69a72..73c4b7cc 100644
--- a/src/ForbidCloning.php
+++ b/src/ForbidCloning.php
@@ -4,6 +4,7 @@
trait ForbidCloning
{
+ /** @psalm-pure */
final protected function __clone()
{
throw new \Error(__CLASS__ . ' does not support cloning');
diff --git a/src/ForbidSerialization.php b/src/ForbidSerialization.php
index 8db98ae1..c86a14bd 100644
--- a/src/ForbidSerialization.php
+++ b/src/ForbidSerialization.php
@@ -4,16 +4,18 @@
trait ForbidSerialization
{
+ /** @psalm-pure */
final public function __serialize(): never
{
throw new \Error(__CLASS__ . ' does not support serialization');
}
- /**
- * @psalm-suppress UnusedParam $data required for __unserialize() prototype.
- */
+ /** @psalm-pure */
final public function __unserialize(array $data): never
{
+ // $data is required by the __unserialize() prototype and is intentionally unused.
+ unset($data);
+
throw new \Error(__CLASS__ . ' does not support deserialization');
}
}
diff --git a/src/Future.php b/src/Future.php
index 313b5980..8aac3a01 100644
--- a/src/Future.php
+++ b/src/Future.php
@@ -7,7 +7,11 @@
use Revolt\EventLoop;
/**
+ * The result of an asynchronous operation.
+ *
* @template-covariant T
+ *
+ * @api
*/
final class Future
{
@@ -32,7 +36,9 @@ public static function iterate(iterable $futures, ?Cancellation $cancellation =
// Directly iterate in case of an array, because there can't be suspensions during iteration
if (\is_array($futures)) {
foreach ($futures as $key => $future) {
- if (!$future instanceof self) {
+ /** @var object $unchecked */
+ $unchecked = $future;
+ if (!$unchecked instanceof self) {
throw new \TypeError('Array must only contain instances of ' . self::class);
}
$iterator->enqueue($future->state, $key, $future);
@@ -55,7 +61,9 @@ public static function iterate(iterable $futures, ?Cancellation $cancellation =
try {
foreach ($futures as $key => $future) {
- if (!$future instanceof self) {
+ /** @var object $unchecked */
+ $unchecked = $future;
+ if (!$unchecked instanceof self) {
throw new \TypeError('Iterable must only provide instances of ' . self::class);
}
@@ -119,6 +127,8 @@ public function __construct(FutureState $state)
/**
* @return bool True if the operation has completed.
+ *
+ * @psalm-capabilities read-props
*/
public function isComplete(): bool
{
@@ -129,6 +139,8 @@ public function isComplete(): bool
* Do not forward unhandled errors to the event loop handler.
*
* @return Future
+ *
+ * @psalm-capabilities read-props|write-props|write-refs
*/
public function ignore(): self
{
@@ -141,8 +153,6 @@ public function ignore(): self
* Attaches a callback that is invoked if this future completes. The returned future is completed with the return
* value of the callback, or errors with an exception thrown from the callback.
*
- * @psalm-suppress InvalidTemplateParam
- *
* @template Tr
*
* @param \Closure(T):Tr $map
@@ -236,6 +246,11 @@ public function finally(\Closure $finally): self
* Throws an exception if the operation fails.
*
* @return T
+ *
+ * @psalm-capabilities read-props|write-props|read-globals|write-globals|io
+ * @psalm-purity-template P <= read-props|write-this-props|write-props|write-refs|io
+ * @psalm-param null|Cancellation[P] $cancellation
+ * @psalm-purity-from-template P
*/
public function await(?Cancellation $cancellation = null): mixed
{
@@ -266,8 +281,9 @@ public function await(?Cancellation $cancellation = null): mixed
try {
return $suspension->suspend();
} finally {
- /** @psalm-suppress PossiblyNullArgument $cancellationId will not be null if $cancellation is not null. */
- $cancellation?->unsubscribe($cancellationId);
+ if ($cancellation !== null && $cancellationId !== null) {
+ $cancellation->unsubscribe($cancellationId);
+ }
}
}
}
diff --git a/src/Future/UnhandledFutureError.php b/src/Future/UnhandledFutureError.php
index 1c6e04c9..416a541c 100644
--- a/src/Future/UnhandledFutureError.php
+++ b/src/Future/UnhandledFutureError.php
@@ -4,9 +4,12 @@
/**
* Will be thrown to the event loop error handler in case a future exception is not handled.
+ *
+ * @api
*/
final class UnhandledFutureError extends \Error
{
+ /** @psalm-capabilities read-props */
public function __construct(\Throwable $previous, ?string $origin = null)
{
$message = 'Unhandled future: ' . $previous::class . ': "' . $previous->getMessage()
diff --git a/src/Future/functions.php b/src/Future/functions.php
index b383dd63..550a3309 100644
--- a/src/Future/functions.php
+++ b/src/Future/functions.php
@@ -58,7 +58,7 @@ function awaitAny(iterable $futures, ?Cancellation $cancellation = null): mixed
* @template Tk of array-key
* @template Tv
*
- * @param positive-int $count
+ * @param positive-int $count Number of successful results required. Must be greater than 0.
* @param iterable> $futures
* @param Cancellation|null $cancellation Optional cancellation.
*
@@ -69,6 +69,8 @@ function awaitAny(iterable $futures, ?Cancellation $cancellation = null): mixed
*/
function awaitAnyN(int $count, iterable $futures, ?Cancellation $cancellation = null): array
{
+ // positive-int is a docblock. PHP still accepts 0 and negative values.
+ /** @var int $count */
if ($count <= 0) {
throw new \ValueError('Argument #1 ($count) must be greater than 0, got ' . $count);
}
diff --git a/src/Internal/Cancellable.php b/src/Internal/Cancellable.php
index 1ac0e24a..192c3fa6 100644
--- a/src/Internal/Cancellable.php
+++ b/src/Internal/Cancellable.php
@@ -11,6 +11,8 @@
/**
* Cancellation with public cancellation method.
*
+ * @implements \Amp\Cancellation[read-props|write-this-props|write-refs|io]
+ *
* @internal
*/
final class Cancellable implements Cancellation
@@ -18,7 +20,7 @@ final class Cancellable implements Cancellation
use ForbidCloning;
use ForbidSerialization;
- private string $nextId = "a";
+ private string $nextId = 'a';
/** @var \Closure[] */
private array $callbacks = [];
@@ -29,6 +31,7 @@ final class Cancellable implements Cancellation
private bool $requested = false;
+ /** @psalm-capabilities read-props|write-this-props|write-refs|io */
public function cancel(?\Throwable $previous = null): void
{
if ($this->requested) {
@@ -52,16 +55,20 @@ public function cancel(?\Throwable $previous = null): void
}
}
+ /** @psalm-capabilities read-props|write-this-props|write-refs */
private function getException(): CancelledException
{
return $this->exception ??= new CancelledException($this->previous);
}
+ /**
+ * @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);
if ($this->requested) {
$exception = $this->getException();
@@ -73,18 +80,25 @@ public function subscribe(\Closure $callback): string
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
{
return $this->requested;
}
+ /**
+ * @psalm-capabilities read-props|write-this-props|write-refs
+ */
#[\Override]
public function throwIfRequested(): void
{
diff --git a/src/Internal/FutureIterator.php b/src/Internal/FutureIterator.php
index 8dd3ecb9..1aac90db 100644
--- a/src/Internal/FutureIterator.php
+++ b/src/Internal/FutureIterator.php
@@ -141,6 +141,11 @@ public function consume(): ?array
return $item;
}
+ /**
+ * Unsubscribes outstanding callbacks. The engine calls this.
+ *
+ * @api
+ */
public function __destruct()
{
$this->cancellation->unsubscribe($this->cancellationId);
diff --git a/src/Internal/FutureState.php b/src/Internal/FutureState.php
index b0cb11d5..2f71d4c7 100644
--- a/src/Internal/FutureState.php
+++ b/src/Internal/FutureState.php
@@ -18,7 +18,7 @@ final class FutureState
use ForbidCloning;
use ForbidSerialization;
- // Static so they can be used as array keys
+ /** Static so subscription identifiers stay unique across all futures. */
private static string $nextId = 'a';
private bool $complete = false;
@@ -52,6 +52,11 @@ public function __construct()
})());
}
+ /**
+ * Reports an unhandled error to the event loop. The engine calls this.
+ *
+ * @api
+ */
public function __destruct()
{
if ($this->throwable && !$this->handled) {
@@ -69,11 +74,13 @@ public function __destruct()
* the future.
*
* @return string Identifier that can be used to cancel interest for this future.
+ *
+ * @psalm-capabilities read-props|write-this-props|read-globals|write-globals|io
*/
public function subscribe(\Closure $callback): string
{
$id = self::$nextId;
- \PHP_VERSION_ID >= 80300 ? self::$nextId = \str_increment(self::$nextId) : ++self::$nextId;
+ self::$nextId = \str_increment(self::$nextId);
$this->handled = true; // Even if unsubscribed later, consider the future handled.
@@ -92,6 +99,8 @@ public function subscribe(\Closure $callback): string
* Cancellations are advisory only. The callback might still be called if it is already queued for execution.
*
* @param string $id Identifier returned from subscribe()
+ *
+ * @psalm-capabilities read-props|write-this-props|write-refs
*/
public function unsubscribe(string $id): void
{
@@ -134,6 +143,8 @@ public function error(\Throwable $throwable): void
/**
* @return bool True if the operation has completed.
+ *
+ * @psalm-capabilities read-props
*/
public function isComplete(): bool
{
@@ -142,6 +153,8 @@ public function isComplete(): bool
/**
* Suppress the exception thrown to the loop error handler if and operation error is not handled by a callback.
+ *
+ * @psalm-capabilities read-props|write-this-props|write-refs
*/
public function ignore(): void
{
diff --git a/src/Internal/WrappedCancellation.php b/src/Internal/WrappedCancellation.php
index 6b62d644..e419ea3a 100644
--- a/src/Internal/WrappedCancellation.php
+++ b/src/Internal/WrappedCancellation.php
@@ -7,6 +7,11 @@
use Amp\ForbidSerialization;
/**
+ * Forwards cancellation to another cancellation, keeping this object's capabilities tied to that one.
+ *
+ * @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
+ * @implements Cancellation[C]
+ *
* @internal
*/
final class WrappedCancellation implements Cancellation
@@ -14,29 +19,46 @@ final class WrappedCancellation implements Cancellation
use ForbidCloning;
use ForbidSerialization;
+ /**
+ * @param Cancellation[C] $cancellation
+ *
+ * @psalm-capabilities read-props
+ */
public function __construct(
private readonly Cancellation $cancellation
) {
}
+ /**
+ * @psalm-capabilities read-props
+ * @psalm-purity-from-template C
+ */
#[\Override]
public function subscribe(\Closure $callback): string
{
return $this->cancellation->subscribe($callback);
}
+ /**
+ * @psalm-capabilities read-props
+ * @psalm-purity-from-template C
+ */
#[\Override]
public function unsubscribe(string $id): void
{
$this->cancellation->unsubscribe($id);
}
+ /** @psalm-capabilities read-props */
#[\Override]
public function isRequested(): bool
{
return $this->cancellation->isRequested();
}
+ /**
+ * @psalm-capabilities read-props|write-this-props|write-props|write-refs
+ */
#[\Override]
public function throwIfRequested(): void
{
diff --git a/src/Internal/functions.php b/src/Internal/functions.php
index b4659244..0cd89262 100644
--- a/src/Internal/functions.php
+++ b/src/Internal/functions.php
@@ -19,6 +19,8 @@
*
* @codeCoverageIgnore
* @internal
+ *
+ * @psalm-pure
*/
function formatStacktrace(array $trace): string
{
@@ -44,8 +46,12 @@ function formatStacktrace(array $trace): string
*/
function isDebugEnabled(): bool
{
- /** @psalm-suppress RiskyTruthyFalsyComparison */
- return match (\getenv("AMP_DEBUG") ?: "0") {
+ $value = \getenv("AMP_DEBUG");
+ if ($value === false || $value === "") {
+ $value = "0";
+ }
+
+ return match ($value) {
"0", "false", "off" => false,
default => true,
} || (\defined("AMP_DEBUG") && \AMP_DEBUG);
diff --git a/src/Interval.php b/src/Interval.php
index 6cfebb1b..2cd22b19 100644
--- a/src/Interval.php
+++ b/src/Interval.php
@@ -7,6 +7,8 @@
/**
* This object invokes the given callback within a new coroutine every $interval seconds until either the
* {@see self::disable()} method is called or the object is destroyed.
+ *
+ * @api
*/
final class Interval
{
diff --git a/src/NullCancellation.php b/src/NullCancellation.php
index cb62bf15..c1548c7f 100644
--- a/src/NullCancellation.php
+++ b/src/NullCancellation.php
@@ -24,30 +24,38 @@
* ```
*
* instead.
+ *
+ * @implements Cancellation[read-props]
+ *
+ * @api
*/
final class NullCancellation implements Cancellation
{
use ForbidCloning;
use ForbidSerialization;
+ /** @psalm-pure */
#[\Override]
public function subscribe(\Closure $callback): string
{
return "null-cancellation";
}
+ /** @psalm-capabilities read-props */
#[\Override]
public function unsubscribe(string $id): void
{
// nothing to do
}
+ /** @psalm-pure */
#[\Override]
public function isRequested(): bool
{
return false;
}
+ /** @psalm-pure */
#[\Override]
public function throwIfRequested(): void
{
diff --git a/src/SignalCancellation.php b/src/SignalCancellation.php
index d4e13e21..e32ead52 100644
--- a/src/SignalCancellation.php
+++ b/src/SignalCancellation.php
@@ -6,6 +6,10 @@
/**
* A SignalCancellation automatically requests cancellation when a given signal is received.
+ *
+ * @implements Cancellation[read-props|write-props|write-refs|io]
+ *
+ * @api
*/
final class SignalCancellation implements Cancellation
{
@@ -15,7 +19,7 @@ final class SignalCancellation implements Cancellation
/** @var list */
private readonly array $callbackIds;
- private readonly Cancellation $cancellation;
+ private readonly Internal\Cancellable $cancellation;
/**
* @param int|int[] $signals Signal number or array of signal numbers.
@@ -29,16 +33,16 @@ public function __construct(int|array $signals, string $message = "Operation can
$this->cancellation = $source = new Internal\Cancellable();
- \assert((bool) ($trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS)));
+ $trace = \ini_get('zend.assertions') === '1' ? \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS) : null;
$callbackIds = [];
- $callback = static function () use (&$callbackIds, &$trace, $source, $message): void {
+ $callback = static function () use (&$callbackIds, $trace, $source, $message): void {
foreach ($callbackIds as $callbackId) {
EventLoop::cancel($callbackId);
}
- if ($trace ?? false) {
+ if ($trace !== null) {
$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);
@@ -64,24 +68,34 @@ public function __destruct()
}
}
+ /**
+ * @psalm-capabilities read-props|write-props|write-refs|io
+ */
#[\Override]
public function subscribe(\Closure $callback): string
{
return $this->cancellation->subscribe($callback);
}
+ /**
+ * @psalm-capabilities read-props|write-props|write-refs
+ */
#[\Override]
public function unsubscribe(string $id): void
{
$this->cancellation->unsubscribe($id);
}
+ /** @psalm-capabilities read-props */
#[\Override]
public function isRequested(): bool
{
return $this->cancellation->isRequested();
}
+ /**
+ * @psalm-capabilities read-props|write-props|write-refs
+ */
#[\Override]
public function throwIfRequested(): void
{
diff --git a/src/SignalException.php b/src/SignalException.php
index 3ca48798..6128f574 100644
--- a/src/SignalException.php
+++ b/src/SignalException.php
@@ -6,11 +6,15 @@
* Used as the previous exception to {@see CancelledException} when a {@see SignalCancellation} is triggered.
*
* @see SignalCancellation
+ *
+ * @api
*/
class SignalException extends \Exception
{
/**
* @param string $message Exception message.
+ *
+ * @psalm-capabilities read-props
*/
public function __construct(string $message = "Operation cancelled by signal")
{
diff --git a/src/TimeoutCancellation.php b/src/TimeoutCancellation.php
index 3ffa4392..0af53a8d 100644
--- a/src/TimeoutCancellation.php
+++ b/src/TimeoutCancellation.php
@@ -6,6 +6,10 @@
/**
* A TimeoutCancellation automatically requests cancellation after the timeout has elapsed.
+ *
+ * @implements Cancellation[read-props|write-props|write-refs|io]
+ *
+ * @api
*/
final class TimeoutCancellation implements Cancellation
{
@@ -14,7 +18,7 @@ final class TimeoutCancellation implements Cancellation
private readonly string $callbackId;
- private readonly Cancellation $cancellation;
+ private readonly Internal\Cancellable $cancellation;
/**
* @param float $timeout Seconds until cancellation is requested.
@@ -24,10 +28,10 @@ public function __construct(float $timeout, string $message = "Operation timed o
{
$this->cancellation = $source = new Internal\Cancellable();
- \assert((bool) ($trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS)));
+ $trace = \ini_get('zend.assertions') === '1' ? \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS) : null;
- $this->callbackId = EventLoop::delay($timeout, static function () use (&$trace, $source, $message): void {
- if ($trace ?? false) {
+ $this->callbackId = EventLoop::delay($timeout, static function () use ($trace, $source, $message): void {
+ if ($trace !== null) {
$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);
@@ -47,24 +51,34 @@ public function __destruct()
EventLoop::cancel($this->callbackId);
}
+ /**
+ * @psalm-capabilities read-props|write-props|write-refs|io
+ */
#[\Override]
public function subscribe(\Closure $callback): string
{
return $this->cancellation->subscribe($callback);
}
+ /**
+ * @psalm-capabilities read-props|write-props|write-refs
+ */
#[\Override]
public function unsubscribe(string $id): void
{
$this->cancellation->unsubscribe($id);
}
+ /** @psalm-capabilities read-props */
#[\Override]
public function isRequested(): bool
{
return $this->cancellation->isRequested();
}
+ /**
+ * @psalm-capabilities read-props|write-props|write-refs
+ */
#[\Override]
public function throwIfRequested(): void
{
diff --git a/src/TimeoutException.php b/src/TimeoutException.php
index c995684b..43e45f61 100644
--- a/src/TimeoutException.php
+++ b/src/TimeoutException.php
@@ -6,11 +6,15 @@
* Used as the previous exception to {@see CancelledException} when a {@see TimeoutCancellation} expires.
*
* @see TimeoutCancellation
+ *
+ * @api
*/
class TimeoutException extends \Exception
{
/**
* @param string $message Exception message.
+ *
+ * @psalm-capabilities read-props
*/
public function __construct(string $message = "Operation timed out")
{
diff --git a/src/functions.php b/src/functions.php
index eed32ddb..150cefdb 100644
--- a/src/functions.php
+++ b/src/functions.php
@@ -24,13 +24,14 @@ function async(\Closure $closure, mixed ...$args): Future
$s = $state;
$c = $closure;
- /* Null function arguments so an exception thrown from the closure does not contain the FutureState object
+ /* Drop the parameters so an exception thrown from the closure does not contain the FutureState object
* in the stack trace, which would create a circular reference, preventing immediate garbage collection */
- $state = $closure = null;
+ unset($state, $closure);
try {
- // Clear $args to allow garbage collection of arguments during fiber execution
- $s->complete($c(...$args, ...($args = [])));
+ // array_splice() reads and empties $args before the closure runs, so a trace or a
+ // suspended fiber does not keep the arguments alive on this frame.
+ $s->complete($c(...\array_splice($args, 0)));
} catch (\Throwable $exception) {
$s->error($exception);
}
@@ -50,8 +51,7 @@ function async(\Closure $closure, mixed ...$args): Future
*/
function now(): float
{
- /** @psalm-suppress InvalidOperand */
- return (float) \hrtime(true) / 1_000_000_000;
+ return (float) \hrtime(true) / 1_000_000_000.0;
}
/**
@@ -60,6 +60,11 @@ function now(): float
* @param float $timeout Number of seconds to wait.
* @param bool $reference If false, unreference the underlying watcher.
* @param Cancellation|null $cancellation Cancel waiting if cancellation is requested.
+ *
+ * @psalm-capabilities io
+ * @psalm-purity-template P <= read-props|write-this-props|write-props|write-refs|io
+ * @psalm-param null|Cancellation[P] $cancellation
+ * @psalm-purity-from-template P
*/
function delay(float $timeout, bool $reference = true, ?Cancellation $cancellation = null): void
{
@@ -78,8 +83,9 @@ function delay(float $timeout, bool $reference = true, ?Cancellation $cancellati
} finally {
EventLoop::cancel($callbackId);
- /** @psalm-suppress PossiblyNullArgument $cancellationId will not be null if $cancellation is not null. */
- $cancellation?->unsubscribe($cancellationId);
+ if ($cancellation !== null && $cancellationId !== null) {
+ $cancellation->unsubscribe($cancellationId);
+ }
}
}
@@ -91,12 +97,21 @@ function delay(float $timeout, bool $reference = true, ?Cancellation $cancellati
* @param Cancellation|null $cancellation Cancel waiting if cancellation is requested.
*
* @return int Caught signal number.
+ *
* @throws UnsupportedFeatureException
+ *
+ * @psalm-capabilities io
+ * @psalm-purity-template P <= read-props|write-this-props|write-props|write-refs|io
+ * @psalm-param null|Cancellation[P] $cancellation
+ * @psalm-purity-from-template P
*/
function trapSignal(int|array $signals, bool $reference = true, ?Cancellation $cancellation = null): int
{
$suspension = EventLoop::getSuspension();
- $callback = static fn (string $watcher, int $signal) => $suspension->resume($signal);
+ $callback = static function (string $watcher, int $signal) use ($suspension): void {
+ $suspension->resume($signal);
+ };
+
$id = $cancellation?->subscribe(static fn (CancelledException $exception) => $suspension->throw($exception));
$callbackIds = [];
@@ -119,8 +134,9 @@ function trapSignal(int|array $signals, bool $reference = true, ?Cancellation $c
EventLoop::cancel($callbackId);
}
- /** @psalm-suppress PossiblyNullArgument $id will not be null if $cancellation is not null. */
- $cancellation?->unsubscribe($id);
+ if ($cancellation !== null && $id !== null) {
+ $cancellation->unsubscribe($id);
+ }
}
}