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