From 8b84a4a6cc0b49dfd6fd3c67501d2eac8e4c5978 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Fri, 23 Oct 2026 18:19:44 +0200 Subject: [PATCH 01/10] Declare delay()'s Psalm capabilities: io --- src/functions.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/functions.php b/src/functions.php index eed32ddb..a4a9bdd8 100644 --- a/src/functions.php +++ b/src/functions.php @@ -60,6 +60,8 @@ 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 */ function delay(float $timeout, bool $reference = true, ?Cancellation $cancellation = null): void { From da2eba70fe48e35cb6d5c9e2b2c2760a1cfec7fa Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 20:32:55 +0100 Subject: [PATCH 02/10] Upgrade Psalm to 7.0.0-rc10 and annotate capabilities Psalm 7 rejects the previous docblocks and reports purity, reference, and dead-code issues. Fix those in the implementation, and declare capabilities so delay() stays io while callers pay for the cancellation they pass. --- .gitattributes | 1 + .github/workflows/ci.yml | 1 - composer.json | 2 +- psalm.xml | 60 +-------- src/Cancellation.php | 17 +++ src/CancelledException.php | 3 + src/Closable.php | 16 +++ src/CompositeCancellation.php | 85 +++++++------ src/CompositeException.php | 9 ++ src/CompositeLengthException.php | 6 + src/DeferredCancellation.php | 4 + src/DeferredFuture.php | 4 + src/ForbidCloning.php | 1 + src/ForbidSerialization.php | 8 +- src/Future.php | 44 +++++-- src/Future/UnhandledFutureError.php | 3 + src/Future/functions.php | 2 +- src/Internal/Cancellable.php | 19 ++- src/Internal/CompositeCancellationState.php | 28 +++++ src/Internal/FutureIterator.php | 5 + src/Internal/FutureState.php | 20 ++- src/Internal/WrappedCancellation.php | 22 ++++ src/Internal/functions.php | 41 +++++- src/Interval.php | 2 + src/NullCancellation.php | 8 ++ src/SignalCancellation.php | 22 +++- src/SignalException.php | 4 + src/TimeoutCancellation.php | 22 +++- src/TimeoutException.php | 4 + src/functions.php | 76 ++++++++---- stubs/RevoltEventLoop.phpstub | 130 ++++++++++++++++++++ 31 files changed, 514 insertions(+), 155 deletions(-) create mode 100644 src/Internal/CompositeCancellationState.php create mode 100644 stubs/RevoltEventLoop.phpstub diff --git a/.gitattributes b/.gitattributes index 2fefd2e7..c5910fa3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,4 @@ README.md export-ignore CONTRIBUTING.md export-ignore CHANGELOG.md export-ignore psalm.xml export-ignore +stubs export-ignore 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..69d730a2 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "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..493be643 100644 --- a/psalm.xml +++ b/psalm.xml @@ -11,61 +11,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + 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..c77688aa 100644 --- a/src/CompositeCancellation.php +++ b/src/CompositeCancellation.php @@ -2,99 +2,101 @@ namespace Amp; +use Amp\Internal\CompositeCancellationState; use Revolt\EventLoop; +/** + * A cancellation that is requested when any of the given cancellations is requested. + * + * @implements Cancellation[read-props|write-props|io] + * + * @api + */ final class CompositeCancellation implements Cancellation { use ForbidCloning; use ForbidSerialization; - /** @var array */ - private array $cancellations = []; - - private string $nextId = "a"; - - /** @var array */ - private array $callbacks = []; - - private ?CancelledException $exception = null; + private readonly CompositeCancellationState $state; public function __construct(Cancellation ...$cancellations) { - $thatException = &$this->exception; - $thatCallbacks = &$this->callbacks; - $thatCancellations = &$this->cancellations; - $onCancel = static function (CancelledException $exception) use ( - &$thatException, - &$thatCallbacks, - &$thatCancellations, - ): void { - if ($thatException) { + // The callback keeps this state object, not the cancellation, so destroying the cancellation can + // unsubscribe and break the cycle between the state and the child cancellations. + $state = new CompositeCancellationState(); + $this->state = $state; + + $onCancel = static function (CancelledException $exception) use ($state): void { + if ($state->exception) { return; } - $thatException = $exception; + $state->exception = $exception; - foreach ($thatCancellations as [$cancellation, $id]) { + foreach ($state->cancellations as [$cancellation, $id]) { $cancellation->unsubscribe($id); } - $thatCancellations = []; + $state->cancellations = []; - foreach ($thatCallbacks as $callback) { + foreach ($state->callbacks as $callback) { EventLoop::queue($callback, $exception); } - $thatCallbacks = []; + $state->callbacks = []; }; foreach ($cancellations as $cancellation) { $id = $cancellation->subscribe($onCancel); - $this->cancellations[] = [$cancellation, $id]; + $state->cancellations[] = [$cancellation, $id]; } } public function __destruct() { - foreach ($this->cancellations as [$cancellation, $id]) { + foreach ($this->state->cancellations as [$cancellation, $id]) { $cancellation->unsubscribe($id); } - // The reference created in the constructor causes this property to persist beyond the life of this object, - // so explicitly removing references will speed up garbage collection. - $this->cancellations = []; + $this->state->cancellations = []; } + /** + * @psalm-capabilities read-props|write-props|io + */ #[\Override] public function subscribe(\Closure $callback): string { - $id = $this->nextId; - \PHP_VERSION_ID >= 80300 ? $this->nextId = \str_increment($this->nextId) : ++$this->nextId; + $id = 'a' . $this->state->nextId++; - if ($this->exception) { - EventLoop::queue($callback, $this->exception); + if ($this->state->exception) { + EventLoop::queue($callback, $this->state->exception); } else { - $this->callbacks[$id] = $callback; + $this->state->callbacks[$id] = $callback; } return $id; } + /** + * @psalm-capabilities read-props|write-props + */ #[\Override] public function unsubscribe(string $id): void { - unset($this->callbacks[$id]); + unset($this->state->callbacks[$id]); } + /** @psalm-capabilities read-props */ #[\Override] public function isRequested(): bool { - if ($this->exception) { + if ($this->state->exception) { return true; } - // Check each cancellation directly, as the callback setting $this->exception is invoked asynchronously. - foreach ($this->cancellations as [$cancellation]) { + // Check each cancellation directly, as the callback setting the exception is invoked asynchronously. + foreach ($this->state->cancellations as [$cancellation]) { if ($cancellation->isRequested()) { return true; } @@ -103,15 +105,16 @@ public function isRequested(): bool return false; } + /** @psalm-capabilities read-props|write-props */ #[\Override] public function throwIfRequested(): void { - if ($this->exception) { - throw $this->exception; + if ($this->state->exception) { + throw $this->state->exception; } - // Check each cancellation directly, as the callback setting $this->exception is invoked asynchronously. - foreach ($this->cancellations as [$cancellation]) { + // Check each cancellation directly, as the callback setting the exception is invoked asynchronously. + foreach ($this->state->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..c09a8d72 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,9 +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) { - throw new \TypeError('Array must only contain instances of ' . self::class); - } + /** @var object $unchecked */ + $unchecked = $future; + self::assertFuture($unchecked, 'Array must only contain instances of ' . self::class); $iterator->enqueue($future->state, $key, $future); } $iterator->complete(); @@ -55,9 +59,9 @@ public static function iterate(iterable $futures, ?Cancellation $cancellation = try { foreach ($futures as $key => $future) { - if (!$future instanceof self) { - throw new \TypeError('Iterable must only provide instances of ' . self::class); - } + /** @var object $unchecked */ + $unchecked = $future; + self::assertFuture($unchecked, 'Iterable must only provide instances of ' . self::class); if (!$enqueue($future, $key)) { return; @@ -119,6 +123,8 @@ public function __construct(FutureState $state) /** * @return bool True if the operation has completed. + * + * @psalm-capabilities read-props */ public function isComplete(): bool { @@ -129,6 +135,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 +149,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 +242,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 +277,21 @@ 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); + } + } + } + + /** + * @param mixed $future Value yielded by the iterable passed to {@see self::iterate()}. + * + * @psalm-pure + */ + private static function assertFuture(mixed $future, string $message): void + { + if (!$future instanceof self) { + throw new \TypeError($message); } } } 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..08ccf833 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 int $count Number of successful results required. Must be greater than 0. * @param iterable> $futures * @param Cancellation|null $cancellation Optional cancellation. * diff --git a/src/Internal/Cancellable.php b/src/Internal/Cancellable.php index 1ac0e24a..1351e5e2 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 int $nextId = 0; /** @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,19 @@ 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; + $id = 'a' . $this->nextId++; if ($this->requested) { $exception = $this->getException(); @@ -73,18 +79,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/CompositeCancellationState.php b/src/Internal/CompositeCancellationState.php new file mode 100644 index 00000000..0b4c1be8 --- /dev/null +++ b/src/Internal/CompositeCancellationState.php @@ -0,0 +1,28 @@ + */ + public array $cancellations = []; + + /** @var array */ + public array $callbacks = []; + + public int $nextId = 0; + + public ?CancelledException $exception = null; +} 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..1f9a38fe 100644 --- a/src/Internal/FutureState.php +++ b/src/Internal/FutureState.php @@ -18,8 +18,8 @@ final class FutureState use ForbidCloning; use ForbidSerialization; - // Static so they can be used as array keys - private static string $nextId = 'a'; + /** Static so subscription identifiers stay unique across all futures. */ + private static int $nextId = 0; 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,12 @@ 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; + $id = 'a' . self::$nextId++; $this->handled = true; // Even if unsubscribed later, consider the future handled. @@ -92,6 +98,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 +142,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 +152,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..48846484 100644 --- a/src/Internal/functions.php +++ b/src/Internal/functions.php @@ -19,6 +19,8 @@ * * @codeCoverageIgnore * @internal + * + * @psalm-pure */ function formatStacktrace(array $trace): string { @@ -37,6 +39,37 @@ function formatStacktrace(array $trace): string }, $trace, \array_keys($trace))); } +/** + * Captures a debug backtrace when assertions are enabled. + * + * `assert()` does not evaluate its argument unless zend.assertions is 1, so assigning inside an assertion leaves + * the trace unset when assertions are disabled. Capture it directly instead. The frame of this function is removed + * so the first frame is the caller, matching a trace taken in the caller. + * + * @return list, + * class?: class-string, + * file?: string, + * function: string, + * line?: int, + * object?: object, + * type?: string, + * }>|null + * + * @internal + */ +function captureDebugTrace(): ?array +{ + if (\ini_get('zend.assertions') !== '1') { + return null; + } + + $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); + \array_shift($trace); + + return $trace; +} + /** * @return bool True if the environment variable AMP_DEBUG is set to a value other than "0", "false", or "off" or * if the constant AMP_DEBUG is defined and set to a truthy value. @@ -44,8 +77,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..e9cedb23 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 = Internal\captureDebugTrace(); $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..c0da8478 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 = Internal\captureDebugTrace(); - $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 a4a9bdd8..8484557e 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; } /** @@ -62,14 +62,21 @@ function now(): float * @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 { $suspension = EventLoop::getSuspension(); $callbackId = EventLoop::delay($timeout, static fn () => $suspension->resume()); - $cancellationId = $cancellation?->subscribe( - static fn (CancelledException $exception) => $suspension->throw($exception) - ); + + $cancellationId = null; + if ($cancellation !== null) { + $cancellationId = $cancellation->subscribe( + static fn (CancelledException $exception) => $suspension->throw($exception) + ); + } if (!$reference) { EventLoop::unreference($callbackId); @@ -80,8 +87,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); + } } } @@ -93,13 +101,25 @@ 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); - $id = $cancellation?->subscribe(static fn (CancelledException $exception) => $suspension->throw($exception)); + $callback = static function (string $watcher, int $signal) use ($suspension): void { + $suspension->resume($signal); + }; + + $id = null; + if ($cancellation !== null) { + $id = $cancellation->subscribe(static fn (CancelledException $exception) => $suspension->throw($exception)); + } $callbackIds = []; @@ -121,8 +141,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); + } } } @@ -155,17 +176,24 @@ function weakClosure(\Closure $closure): \Closure $methodName = $reflection->getShortName(); if (!\str_starts_with($methodName, '{closure')) { - // Closure from first-class callable or \Closure::fromCallable(), declare an anonymous closure to rebind. - /** @psalm-suppress InvalidScope Closure is bound before being invoked. */ - $closure = fn (mixed ...$args): mixed => $this->{$methodName}(...$args); - if ($useBindTo && $scope) { - $closure = $closure->bindTo(null, $scope->name); - } - } else { - // Rebind to remove reference to $that - $closure = $closure->bindTo($reference); + // First-class callable or \Closure::fromCallable(). Reflection invokes the method, including a + // private one, in its declaring scope. An unbound `$this` here is not in a class, which Psalm rejects. + $method = new \ReflectionMethod($scope->name ?? $that::class, $methodName); + + /** @var TClosure */ + return static function (mixed ...$args) use ($reference, $method): mixed { + $that = $reference->get(); + if (!$that) { + throw new \Error('Weakened closure invoked after referenced object destroyed'); + } + + return $method->getClosure($that)(...$args); + }; } + // Rebind to remove reference to $that + $closure = $closure->bindTo($reference); + if (!$closure) { throw new \RuntimeException('Unable to rebind closure scoped to ' . ($scope?->name ?? $that::class)); } diff --git a/stubs/RevoltEventLoop.phpstub b/stubs/RevoltEventLoop.phpstub new file mode 100644 index 00000000..5814a27e --- /dev/null +++ b/stubs/RevoltEventLoop.phpstub @@ -0,0 +1,130 @@ + Date: Sun, 25 Oct 2026 21:25:32 +0100 Subject: [PATCH 03/10] Restore str_increment ids and the awaitAnyN runtime check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit positive-int is only a docblock, so awaitAnyN still throws ValueError for a non-positive count. Callback ids follow str_increment (a, b, …, aa). PHP 8.3+ calls str_increment(); 8.1 and 8.2 advance the same sequence without ++, which Psalm 7 reports even inside the version guard. --- src/CompositeCancellation.php | 3 +- src/Future/functions.php | 4 ++- src/Internal/Cancellable.php | 5 +-- src/Internal/CompositeCancellationState.php | 2 +- src/Internal/FutureState.php | 5 +-- src/Internal/functions.php | 40 +++++++++++++++++++++ 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/CompositeCancellation.php b/src/CompositeCancellation.php index c77688aa..ce13e5b6 100644 --- a/src/CompositeCancellation.php +++ b/src/CompositeCancellation.php @@ -67,7 +67,8 @@ public function __destruct() #[\Override] public function subscribe(\Closure $callback): string { - $id = 'a' . $this->state->nextId++; + $id = $this->state->nextId; + $this->state->nextId = Internal\incrementId($id); if ($this->state->exception) { EventLoop::queue($callback, $this->state->exception); diff --git a/src/Future/functions.php b/src/Future/functions.php index 08ccf833..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 int $count Number of successful results required. Must be greater than 0. + * @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 1351e5e2..5a2e5d15 100644 --- a/src/Internal/Cancellable.php +++ b/src/Internal/Cancellable.php @@ -20,7 +20,7 @@ final class Cancellable implements Cancellation use ForbidCloning; use ForbidSerialization; - private int $nextId = 0; + private string $nextId = 'a'; /** @var \Closure[] */ private array $callbacks = []; @@ -67,7 +67,8 @@ private function getException(): CancelledException #[\Override] public function subscribe(\Closure $callback): string { - $id = 'a' . $this->nextId++; + $id = $this->nextId; + $this->nextId = incrementId($id); if ($this->requested) { $exception = $this->getException(); diff --git a/src/Internal/CompositeCancellationState.php b/src/Internal/CompositeCancellationState.php index 0b4c1be8..e52db447 100644 --- a/src/Internal/CompositeCancellationState.php +++ b/src/Internal/CompositeCancellationState.php @@ -22,7 +22,7 @@ final class CompositeCancellationState /** @var array */ public array $callbacks = []; - public int $nextId = 0; + public string $nextId = 'a'; public ?CancelledException $exception = null; } diff --git a/src/Internal/FutureState.php b/src/Internal/FutureState.php index 1f9a38fe..6f82f88f 100644 --- a/src/Internal/FutureState.php +++ b/src/Internal/FutureState.php @@ -19,7 +19,7 @@ final class FutureState use ForbidSerialization; /** Static so subscription identifiers stay unique across all futures. */ - private static int $nextId = 0; + private static string $nextId = 'a'; private bool $complete = false; @@ -79,7 +79,8 @@ public function __destruct() */ public function subscribe(\Closure $callback): string { - $id = 'a' . self::$nextId++; + $id = self::$nextId; + self::$nextId = incrementId($id); $this->handled = true; // Even if unsubscribed later, consider the future handled. diff --git a/src/Internal/functions.php b/src/Internal/functions.php index 48846484..c3d31ca4 100644 --- a/src/Internal/functions.php +++ b/src/Internal/functions.php @@ -70,6 +70,46 @@ function captureDebugTrace(): ?array return $trace; } +/** + * Returns the next alphanumeric id (`a`, `b`, …, `z`, `aa`), the same sequence as `++` on a non-numeric string. + * + * PHP 8.3 and newer use {@see \str_increment()}. Older versions do not have that function. + * + * @psalm-pure + * @internal + */ +function incrementId(string $id): string +{ + if (\PHP_VERSION_ID >= 80300) { + return \str_increment($id); + } + + $characters = \str_split($id); + for ($i = \count($characters) - 1; $i >= 0; $i--) { + $character = $characters[$i]; + if ($character === '9' || $character === 'Z' || $character === 'z') { + $characters[$i] = match ($character) { + '9' => '0', + 'Z' => 'A', + default => 'a', + }; + continue; + } + + $characters[$i] = \chr(\ord($character) + 1); + + return \implode('', $characters); + } + + $prefix = match ($characters[0] ?? '') { + '0' => '1', + 'A' => 'A', + default => 'a', + }; + + return $prefix . \implode('', $characters); +} + /** * @return bool True if the environment variable AMP_DEBUG is set to a value other than "0", "false", or "off" or * if the constant AMP_DEBUG is defined and set to a truthy value. From df15530c4ceae66b499a3855c0180cebd52c3bc5 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 21:41:06 +0100 Subject: [PATCH 04/10] Call str_increment() and drop the local id and trace helpers symfony/polyfill-php83 provides str_increment() on PHP 8.1 and 8.2, so callback ids stay on that sequence. Debug traces are captured only when zend.assertions is 1. --- composer.json | 3 +- src/CompositeCancellation.php | 2 +- src/Internal/Cancellable.php | 2 +- src/Internal/FutureState.php | 2 +- src/Internal/functions.php | 71 ----------------------------------- src/SignalCancellation.php | 2 +- src/TimeoutCancellation.php | 2 +- 7 files changed, 7 insertions(+), 77 deletions(-) diff --git a/composer.json b/composer.json index 69d730a2..c2c52d92 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ ], "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", diff --git a/src/CompositeCancellation.php b/src/CompositeCancellation.php index ce13e5b6..f6a9f425 100644 --- a/src/CompositeCancellation.php +++ b/src/CompositeCancellation.php @@ -68,7 +68,7 @@ public function __destruct() public function subscribe(\Closure $callback): string { $id = $this->state->nextId; - $this->state->nextId = Internal\incrementId($id); + $this->state->nextId = \str_increment($this->state->nextId); if ($this->state->exception) { EventLoop::queue($callback, $this->state->exception); diff --git a/src/Internal/Cancellable.php b/src/Internal/Cancellable.php index 5a2e5d15..192c3fa6 100644 --- a/src/Internal/Cancellable.php +++ b/src/Internal/Cancellable.php @@ -68,7 +68,7 @@ private function getException(): CancelledException public function subscribe(\Closure $callback): string { $id = $this->nextId; - $this->nextId = incrementId($id); + $this->nextId = \str_increment($this->nextId); if ($this->requested) { $exception = $this->getException(); diff --git a/src/Internal/FutureState.php b/src/Internal/FutureState.php index 6f82f88f..2f71d4c7 100644 --- a/src/Internal/FutureState.php +++ b/src/Internal/FutureState.php @@ -80,7 +80,7 @@ public function __destruct() public function subscribe(\Closure $callback): string { $id = self::$nextId; - self::$nextId = incrementId($id); + self::$nextId = \str_increment(self::$nextId); $this->handled = true; // Even if unsubscribed later, consider the future handled. diff --git a/src/Internal/functions.php b/src/Internal/functions.php index c3d31ca4..0cd89262 100644 --- a/src/Internal/functions.php +++ b/src/Internal/functions.php @@ -39,77 +39,6 @@ function formatStacktrace(array $trace): string }, $trace, \array_keys($trace))); } -/** - * Captures a debug backtrace when assertions are enabled. - * - * `assert()` does not evaluate its argument unless zend.assertions is 1, so assigning inside an assertion leaves - * the trace unset when assertions are disabled. Capture it directly instead. The frame of this function is removed - * so the first frame is the caller, matching a trace taken in the caller. - * - * @return list, - * class?: class-string, - * file?: string, - * function: string, - * line?: int, - * object?: object, - * type?: string, - * }>|null - * - * @internal - */ -function captureDebugTrace(): ?array -{ - if (\ini_get('zend.assertions') !== '1') { - return null; - } - - $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); - \array_shift($trace); - - return $trace; -} - -/** - * Returns the next alphanumeric id (`a`, `b`, …, `z`, `aa`), the same sequence as `++` on a non-numeric string. - * - * PHP 8.3 and newer use {@see \str_increment()}. Older versions do not have that function. - * - * @psalm-pure - * @internal - */ -function incrementId(string $id): string -{ - if (\PHP_VERSION_ID >= 80300) { - return \str_increment($id); - } - - $characters = \str_split($id); - for ($i = \count($characters) - 1; $i >= 0; $i--) { - $character = $characters[$i]; - if ($character === '9' || $character === 'Z' || $character === 'z') { - $characters[$i] = match ($character) { - '9' => '0', - 'Z' => 'A', - default => 'a', - }; - continue; - } - - $characters[$i] = \chr(\ord($character) + 1); - - return \implode('', $characters); - } - - $prefix = match ($characters[0] ?? '') { - '0' => '1', - 'A' => 'A', - default => 'a', - }; - - return $prefix . \implode('', $characters); -} - /** * @return bool True if the environment variable AMP_DEBUG is set to a value other than "0", "false", or "off" or * if the constant AMP_DEBUG is defined and set to a truthy value. diff --git a/src/SignalCancellation.php b/src/SignalCancellation.php index e9cedb23..e32ead52 100644 --- a/src/SignalCancellation.php +++ b/src/SignalCancellation.php @@ -33,7 +33,7 @@ public function __construct(int|array $signals, string $message = "Operation can $this->cancellation = $source = new Internal\Cancellable(); - $trace = Internal\captureDebugTrace(); + $trace = \ini_get('zend.assertions') === '1' ? \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS) : null; $callbackIds = []; diff --git a/src/TimeoutCancellation.php b/src/TimeoutCancellation.php index c0da8478..0af53a8d 100644 --- a/src/TimeoutCancellation.php +++ b/src/TimeoutCancellation.php @@ -28,7 +28,7 @@ public function __construct(float $timeout, string $message = "Operation timed o { $this->cancellation = $source = new Internal\Cancellable(); - $trace = Internal\captureDebugTrace(); + $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 !== null) { From ad3bce72cf98e0c44467d6d8c22eda17892b70a1 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 21:46:10 +0100 Subject: [PATCH 05/10] Keep CompositeCancellation's subscriptions on the cancellation The child callback shares those properties by reference, so it does not retain the cancellation and the destructor can still unsubscribe. --- src/CompositeCancellation.php | 82 +++++++++++++-------- src/Internal/CompositeCancellationState.php | 28 ------- 2 files changed, 50 insertions(+), 60 deletions(-) delete mode 100644 src/Internal/CompositeCancellationState.php diff --git a/src/CompositeCancellation.php b/src/CompositeCancellation.php index f6a9f425..b49e9b72 100644 --- a/src/CompositeCancellation.php +++ b/src/CompositeCancellation.php @@ -2,13 +2,12 @@ namespace Amp; -use Amp\Internal\CompositeCancellationState; use Revolt\EventLoop; /** * A cancellation that is requested when any of the given cancellations is requested. * - * @implements Cancellation[read-props|write-props|io] + * @implements Cancellation[read-props|write-this-props|write-refs|io] * * @api */ @@ -17,87 +16,106 @@ final class CompositeCancellation implements Cancellation use ForbidCloning; use ForbidSerialization; - private readonly CompositeCancellationState $state; + /** @var array */ + private array $cancellations = []; + + private string $nextId = 'a'; + + /** @var array */ + private array $callbacks = []; + + private ?CancelledException $exception = null; public function __construct(Cancellation ...$cancellations) { - // The callback keeps this state object, not the cancellation, so destroying the cancellation can - // unsubscribe and break the cycle between the state and the child cancellations. - $state = new CompositeCancellationState(); - $this->state = $state; - - $onCancel = static function (CancelledException $exception) use ($state): void { - if ($state->exception) { + // 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 ( + &$subscribed, + &$callbacks, + &$currentException, + ): void { + if ($currentException) { return; } - $state->exception = $exception; + $currentException = $exception; - foreach ($state->cancellations as [$cancellation, $id]) { + foreach ($subscribed as [$cancellation, $id]) { $cancellation->unsubscribe($id); } - $state->cancellations = []; + $subscribed = []; - foreach ($state->callbacks as $callback) { + foreach ($callbacks as $callback) { EventLoop::queue($callback, $exception); } - $state->callbacks = []; + $callbacks = []; }; foreach ($cancellations as $cancellation) { $id = $cancellation->subscribe($onCancel); - $state->cancellations[] = [$cancellation, $id]; + $this->cancellations[] = [$cancellation, $id]; } } public function __destruct() { - foreach ($this->state->cancellations as [$cancellation, $id]) { + foreach ($this->cancellations as [$cancellation, $id]) { $cancellation->unsubscribe($id); } - $this->state->cancellations = []; + // The reference created in the constructor causes this property to persist beyond the life of this object, + // so explicitly removing references will speed up garbage collection. + $this->cancellations = []; } /** - * @psalm-capabilities read-props|write-props|io + * @psalm-capabilities read-props|write-this-props|write-refs|io */ #[\Override] public function subscribe(\Closure $callback): string { - $id = $this->state->nextId; - $this->state->nextId = \str_increment($this->state->nextId); + $id = $this->nextId; + $this->nextId = \str_increment($this->nextId); - if ($this->state->exception) { - EventLoop::queue($callback, $this->state->exception); + if ($this->exception) { + EventLoop::queue($callback, $this->exception); } else { - $this->state->callbacks[$id] = $callback; + $this->callbacks[$id] = $callback; } return $id; } /** - * @psalm-capabilities read-props|write-props + * @psalm-capabilities read-props|write-this-props|write-refs */ #[\Override] public function unsubscribe(string $id): void { - unset($this->state->callbacks[$id]); + unset($this->callbacks[$id]); } /** @psalm-capabilities read-props */ #[\Override] public function isRequested(): bool { - if ($this->state->exception) { + if ($this->exception) { return true; } // Check each cancellation directly, as the callback setting the exception is invoked asynchronously. - foreach ($this->state->cancellations as [$cancellation]) { + foreach ($this->cancellations as [$cancellation]) { if ($cancellation->isRequested()) { return true; } @@ -106,16 +124,16 @@ public function isRequested(): bool return false; } - /** @psalm-capabilities read-props|write-props */ + /** @psalm-capabilities read-props|write-props|write-refs */ #[\Override] public function throwIfRequested(): void { - if ($this->state->exception) { - throw $this->state->exception; + if ($this->exception) { + throw $this->exception; } // Check each cancellation directly, as the callback setting the exception is invoked asynchronously. - foreach ($this->state->cancellations as [$cancellation]) { + foreach ($this->cancellations as [$cancellation]) { $cancellation->throwIfRequested(); } } diff --git a/src/Internal/CompositeCancellationState.php b/src/Internal/CompositeCancellationState.php deleted file mode 100644 index e52db447..00000000 --- a/src/Internal/CompositeCancellationState.php +++ /dev/null @@ -1,28 +0,0 @@ - */ - public array $cancellations = []; - - /** @var array */ - public array $callbacks = []; - - public string $nextId = 'a'; - - public ?CancelledException $exception = null; -} From 94a899d07db21048135ed7ce60532a2e7531a667 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 21:47:45 +0100 Subject: [PATCH 06/10] Restore nullsafe cancellation subscribe in delay and trapSignal The nullsafe subscribe call is equivalent to the explicit null check. The unsubscribe site stays explicit so a null id is not passed without a suppress. --- src/functions.php | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/functions.php b/src/functions.php index 8484557e..067e1b6a 100644 --- a/src/functions.php +++ b/src/functions.php @@ -70,13 +70,9 @@ function delay(float $timeout, bool $reference = true, ?Cancellation $cancellati { $suspension = EventLoop::getSuspension(); $callbackId = EventLoop::delay($timeout, static fn () => $suspension->resume()); - - $cancellationId = null; - if ($cancellation !== null) { - $cancellationId = $cancellation->subscribe( - static fn (CancelledException $exception) => $suspension->throw($exception) - ); - } + $cancellationId = $cancellation?->subscribe( + static fn (CancelledException $exception) => $suspension->throw($exception) + ); if (!$reference) { EventLoop::unreference($callbackId); @@ -116,10 +112,7 @@ function trapSignal(int|array $signals, bool $reference = true, ?Cancellation $c $suspension->resume($signal); }; - $id = null; - if ($cancellation !== null) { - $id = $cancellation->subscribe(static fn (CancelledException $exception) => $suspension->throw($exception)); - } + $id = $cancellation?->subscribe(static fn (CancelledException $exception) => $suspension->throw($exception)); $callbackIds = []; From 682f5af2ed3379b9d4b9f45cd674fc41c7203830 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 21:48:42 +0100 Subject: [PATCH 07/10] Restore the original weakClosure implementation A first-class callable is rebound with an anonymous closure. The InvalidScope suppress stays because that closure is bound before it is invoked. --- src/functions.php | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/functions.php b/src/functions.php index 067e1b6a..150cefdb 100644 --- a/src/functions.php +++ b/src/functions.php @@ -169,24 +169,17 @@ function weakClosure(\Closure $closure): \Closure $methodName = $reflection->getShortName(); if (!\str_starts_with($methodName, '{closure')) { - // First-class callable or \Closure::fromCallable(). Reflection invokes the method, including a - // private one, in its declaring scope. An unbound `$this` here is not in a class, which Psalm rejects. - $method = new \ReflectionMethod($scope->name ?? $that::class, $methodName); - - /** @var TClosure */ - return static function (mixed ...$args) use ($reference, $method): mixed { - $that = $reference->get(); - if (!$that) { - throw new \Error('Weakened closure invoked after referenced object destroyed'); - } - - return $method->getClosure($that)(...$args); - }; + // Closure from first-class callable or \Closure::fromCallable(), declare an anonymous closure to rebind. + /** @psalm-suppress InvalidScope Closure is bound before being invoked. */ + $closure = fn (mixed ...$args): mixed => $this->{$methodName}(...$args); + if ($useBindTo && $scope) { + $closure = $closure->bindTo(null, $scope->name); + } + } else { + // Rebind to remove reference to $that + $closure = $closure->bindTo($reference); } - // Rebind to remove reference to $that - $closure = $closure->bindTo($reference); - if (!$closure) { throw new \RuntimeException('Unable to rebind closure scoped to ' . ($scope?->name ?? $that::class)); } From f442ede10482e08fd6f425ff321d2508ff115685 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 21:49:31 +0100 Subject: [PATCH 08/10] Inline the Future::iterate type check The yielded value is widened to object before the instanceof check, so a non-Future still throws TypeError at runtime. --- src/Future.php | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Future.php b/src/Future.php index c09a8d72..8aac3a01 100644 --- a/src/Future.php +++ b/src/Future.php @@ -38,7 +38,9 @@ public static function iterate(iterable $futures, ?Cancellation $cancellation = foreach ($futures as $key => $future) { /** @var object $unchecked */ $unchecked = $future; - self::assertFuture($unchecked, 'Array must only contain instances of ' . self::class); + if (!$unchecked instanceof self) { + throw new \TypeError('Array must only contain instances of ' . self::class); + } $iterator->enqueue($future->state, $key, $future); } $iterator->complete(); @@ -61,7 +63,9 @@ public static function iterate(iterable $futures, ?Cancellation $cancellation = foreach ($futures as $key => $future) { /** @var object $unchecked */ $unchecked = $future; - self::assertFuture($unchecked, 'Iterable must only provide instances of ' . self::class); + if (!$unchecked instanceof self) { + throw new \TypeError('Iterable must only provide instances of ' . self::class); + } if (!$enqueue($future, $key)) { return; @@ -282,16 +286,4 @@ public function await(?Cancellation $cancellation = null): mixed } } } - - /** - * @param mixed $future Value yielded by the iterable passed to {@see self::iterate()}. - * - * @psalm-pure - */ - private static function assertFuture(mixed $future, string $message): void - { - if (!$future instanceof self) { - throw new \TypeError($message); - } - } } From e0f3b57834d7f12a452bf95e38c00e3274ffea7b Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 22:15:37 +0100 Subject: [PATCH 09/10] Load the event-loop I/O stub from Revolt Psalm now reads vendor/revolt/event-loop/stubs/EventLoop.phpstub. That file is added by revoltphp/event-loop#134 and is no longer copied into this repository. --- .gitattributes | 1 - psalm.xml | 2 +- stubs/RevoltEventLoop.phpstub | 130 ---------------------------------- 3 files changed, 1 insertion(+), 132 deletions(-) delete mode 100644 stubs/RevoltEventLoop.phpstub diff --git a/.gitattributes b/.gitattributes index c5910fa3..2fefd2e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,4 +8,3 @@ README.md export-ignore CONTRIBUTING.md export-ignore CHANGELOG.md export-ignore psalm.xml export-ignore -stubs export-ignore diff --git a/psalm.xml b/psalm.xml index 493be643..8d450dbf 100644 --- a/psalm.xml +++ b/psalm.xml @@ -12,6 +12,6 @@ - + diff --git a/stubs/RevoltEventLoop.phpstub b/stubs/RevoltEventLoop.phpstub deleted file mode 100644 index 5814a27e..00000000 --- a/stubs/RevoltEventLoop.phpstub +++ /dev/null @@ -1,130 +0,0 @@ - Date: Fri, 30 Oct 2026 12:28:56 +0100 Subject: [PATCH 10/10] Drop the event-loop stub --- psalm.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/psalm.xml b/psalm.xml index 8d450dbf..597c3cb2 100644 --- a/psalm.xml +++ b/psalm.xml @@ -10,8 +10,4 @@ - - - -