From bf22abfc841937f455b31fabbfe1d4250da097bc Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 22:14:12 +0100 Subject: [PATCH 1/3] Require Psalm 7 and publish event-loop I/O capabilities Psalm 7.0.0-rc10 asks for a capability contract on every pure-looking method and on Driver and Suspension. Driver operations stay impure: implementations mutate callback state, touch the process-wide driver, and run user callbacks. Concrete methods use the narrower contract Psalm infers from the body. stubs/EventLoop.phpstub is shipped in the package so consumers can treat scheduling and suspension as io, the same capability as usleep(). This package's own Psalm config does not load that stub, so the real method bodies stay checked against the wider contracts. --- .gitattributes | 3 +- composer.json | 2 +- examples/benchmark-memory.php | 24 ++-- examples/fiber-local-automatic.php | 2 + examples/http-client-suspension.php | 6 + psalm.xml | 13 ++ src/EventLoop.php | 14 ++ src/EventLoop/Driver.php | 50 +++++++ src/EventLoop/Driver/EvDriver.php | 3 + src/EventLoop/Driver/EventDriver.php | 3 + src/EventLoop/Driver/StreamSelectDriver.php | 8 +- src/EventLoop/Driver/TracingDriver.php | 11 ++ src/EventLoop/Driver/UvDriver.php | 18 +++ src/EventLoop/FiberLocal.php | 2 + src/EventLoop/Internal/AbstractDriver.php | 53 +++++++ src/EventLoop/Internal/ClosureHelper.php | 9 +- src/EventLoop/Internal/DeferCallback.php | 3 + src/EventLoop/Internal/DriverCallback.php | 7 + src/EventLoop/Internal/DriverSuspension.php | 3 + src/EventLoop/Internal/SignalCallback.php | 6 + src/EventLoop/Internal/StreamCallback.php | 2 + .../Internal/StreamReadableCallback.php | 3 + .../Internal/StreamWritableCallback.php | 3 + src/EventLoop/Internal/TimerCallback.php | 6 + src/EventLoop/Internal/TimerQueue.php | 19 +++ src/EventLoop/InvalidCallbackError.php | 14 ++ src/EventLoop/Suspension.php | 8 ++ src/EventLoop/UncaughtThrowable.php | 9 ++ stubs/EventLoop.phpstub | 133 ++++++++++++++++++ 29 files changed, 424 insertions(+), 13 deletions(-) create mode 100644 stubs/EventLoop.phpstub diff --git a/.gitattributes b/.gitattributes index 099ad862..561b89c5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ .github export-ignore examples export-ignore -stubs export-ignore +stubs/* export-ignore +stubs/EventLoop.phpstub -export-ignore test export-ignore tools export-ignore .editorconfig export-ignore diff --git a/composer.json b/composer.json index f53002b2..366a5cd6 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "ext-json": "*", "phpunit/phpunit": "^9", "jetbrains/phpstorm-stubs": "^2019.3", - "psalm/phar": "6.20.*" + "psalm/phar": "^7.0.0-rc10" }, "autoload": { "psr-4": { diff --git a/examples/benchmark-memory.php b/examples/benchmark-memory.php index 491d3de0..92109896 100644 --- a/examples/benchmark-memory.php +++ b/examples/benchmark-memory.php @@ -11,6 +11,10 @@ use Revolt\EventLoop; use Revolt\EventLoop\Driver; +use Revolt\EventLoop\Driver\EvDriver; +use Revolt\EventLoop\Driver\EventDriver; +use Revolt\EventLoop\Driver\StreamSelectDriver; +use Revolt\EventLoop\Driver\UvDriver; require __DIR__ . '/../vendor/autoload.php'; @@ -21,15 +25,17 @@ /** @psalm-suppress RiskyCast */ $t = (int) \round((\array_key_exists('t', $args) ? (int) $args['t'] : 0)); -if (\array_key_exists('d', $args)) { - if (\is_string($args['d'])) { - $loopClass = 'Revolt\EventLoop\Driver\\' . $args['d'] . 'Driver'; - if (\class_exists($loopClass)) { - $loop = new ($loopClass)(); - if ($loop instanceof Driver) { - EventLoop::setDriver($loop); - } - } +if (\array_key_exists('d', $args) && \is_string($args['d'])) { + $loop = match ($args['d']) { + 'Ev' => EvDriver::isSupported() ? new EvDriver() : null, + 'Event' => EventDriver::isSupported() ? new EventDriver() : null, + 'StreamSelect' => new StreamSelectDriver(), + 'Uv' => UvDriver::isSupported() ? new UvDriver() : null, + default => null, + }; + + if ($loop instanceof Driver) { + EventLoop::setDriver($loop); } } diff --git a/examples/fiber-local-automatic.php b/examples/fiber-local-automatic.php index 7a8d0c73..243d74cc 100644 --- a/examples/fiber-local-automatic.php +++ b/examples/fiber-local-automatic.php @@ -13,6 +13,8 @@ * This might be used to log the current URL, authenticated user, or request identifier in an HTTP server. * * @psalm-suppress UnusedProperty + * + * @psalm-mutable */ final class Logger { diff --git a/examples/http-client-suspension.php b/examples/http-client-suspension.php index c15a3af4..bcc39658 100644 --- a/examples/http-client-suspension.php +++ b/examples/http-client-suspension.php @@ -57,9 +57,15 @@ function fetch(string $url): string return $buffer; } +/** + * Returns the header block of an HTTP response. + * + * @psalm-pure + */ function extractHeader(string $httpResponse): string { return \explode("\r\n\r\n", $httpResponse, 2)[0]; } +// Printed on the CLI, where the header is text rather than HTML. echo extractHeader(fetch('http://www.google.com/')); diff --git a/psalm.xml b/psalm.xml index fd49f972..81965bad 100644 --- a/psalm.xml +++ b/psalm.xml @@ -98,6 +98,19 @@ + + + + + + + + + + + + + diff --git a/src/EventLoop.php b/src/EventLoop.php index a6b1fa16..fac5351b 100644 --- a/src/EventLoop.php +++ b/src/EventLoop.php @@ -35,24 +35,36 @@ public static function setDriver(Driver $driver): void try { /** @psalm-suppress InternalClass */ self::$driver = new class () extends AbstractDriver { + /** + * @psalm-pure + */ #[\Override] protected function activate(array $callbacks): void { throw new \Error("Can't activate callback during garbage collection."); } + /** + * @psalm-pure + */ #[\Override] protected function dispatch(bool $blocking): void { throw new \Error("Can't dispatch during garbage collection."); } + /** + * @psalm-capabilities read-props + */ #[\Override] protected function deactivate(DriverCallback $callback): void { // do nothing } + /** + * @psalm-pure + */ #[\Override] public function getHandle(): mixed { @@ -415,6 +427,8 @@ public static function run(): void * Disable construction as this is a static class. * * @psalm-suppress UnusedConstructor + * + * @psalm-capabilities read-props */ private function __construct() { diff --git a/src/EventLoop/Driver.php b/src/EventLoop/Driver.php index 80464aa5..ceea32cd 100644 --- a/src/EventLoop/Driver.php +++ b/src/EventLoop/Driver.php @@ -7,6 +7,8 @@ /** * The driver MUST run in its own fiber and execute callbacks in a separate fiber. If fibers are reused, the driver * needs to call {@see FiberLocal::clear()} after running the callback. + * + * @psalm-mutable */ interface Driver { @@ -24,6 +26,8 @@ interface Driver * error handler or exceptions that would be passed to an error handler but none exists to handle them. * * @throws \Error Thrown if the event loop is already running. + * + * @psalm-impure */ public function run(): void; @@ -32,6 +36,8 @@ public function run(): void; * * When an event loop is stopped, it continues with its current tick and exits the loop afterwards. Multiple calls * to stop MUST be ignored and MUST NOT raise an exception. + * + * @psalm-impure */ public function stop(): void; @@ -41,11 +47,15 @@ public function stop(): void; * Calls from the same fiber will return the same suspension object. * * @return Suspension + * + * @psalm-impure */ public function getSuspension(): Suspension; /** * @return bool True if the event loop is running, false if it is stopped. + * + * @psalm-impure */ public function isRunning(): bool; @@ -60,6 +70,8 @@ public function isRunning(): bool; * * @param \Closure(...):void $closure The callback to queue. * @param mixed ...$args The callback arguments. + * + * @psalm-impure */ public function queue(\Closure $closure, mixed ...$args): void; @@ -76,6 +88,8 @@ public function queue(\Closure $closure, mixed ...$args): void; * callback invocation. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-impure */ public function defer(\Closure $closure): string; @@ -93,6 +107,8 @@ public function defer(\Closure $closure): string; * callback invocation. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-impure */ public function delay(float $delay, \Closure $closure): string; @@ -110,6 +126,8 @@ public function delay(float $delay, \Closure $closure): string; * @param \Closure(string):void $closure The callback to repeat. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-impure */ public function repeat(float $interval, \Closure $closure): string; @@ -130,6 +148,8 @@ public function repeat(float $interval, \Closure $closure): string; * @param \Closure(string, resource):void $closure The callback to execute. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-impure */ public function onReadable(mixed $stream, \Closure $closure): string; @@ -150,6 +170,8 @@ public function onReadable(mixed $stream, \Closure $closure): string; * @param \Closure(string, resource):void $closure The callback to execute. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-impure */ public function onWritable(mixed $stream, \Closure $closure): string; @@ -171,6 +193,8 @@ public function onWritable(mixed $stream, \Closure $closure): string; * @return string A unique identifier that can be used to cancel, enable or disable the callback. * * @throws UnsupportedFeatureException If signal handling is not supported. + * + * @psalm-impure */ public function onSignal(int $signal, \Closure $closure): string; @@ -185,6 +209,8 @@ public function onSignal(int $signal, \Closure $closure): string; * @return string The callback identifier. * * @throws InvalidCallbackError If the callback identifier is invalid. + * + * @psalm-impure */ public function enable(string $callbackId): string; @@ -195,6 +221,8 @@ public function enable(string $callbackId): string; * callback is permanently invalid. Calling this function MUST NOT fail, even if passed an invalid identifier. * * @param string $callbackId The callback identifier. + * + * @psalm-impure */ public function cancel(string $callbackId): void; @@ -210,6 +238,8 @@ public function cancel(string $callbackId): void; * @param string $callbackId The callback identifier. * * @return string The callback identifier. + * + * @psalm-impure */ public function disable(string $callbackId): string; @@ -224,6 +254,8 @@ public function disable(string $callbackId): string; * @return string The callback identifier. * * @throws InvalidCallbackError If the callback identifier is invalid. + * + * @psalm-impure */ public function reference(string $callbackId): string; @@ -236,6 +268,8 @@ public function reference(string $callbackId): string; * @param string $callbackId The callback identifier. * * @return string The callback identifier. + * + * @psalm-impure */ public function unreference(string $callbackId): string; @@ -250,6 +284,8 @@ public function unreference(string $callbackId): string; * * @param null|\Closure(\Throwable):void $errorHandler The callback to execute. `null` will clear the current * handler. + * + * @psalm-impure */ public function setErrorHandler(?\Closure $errorHandler): void; @@ -257,6 +293,8 @@ public function setErrorHandler(?\Closure $errorHandler): void; * Gets the error handler closure or {@code null} if none is set. * * @return null|\Closure(\Throwable):void The previous handler, `null` if there was none. + * + * @psalm-impure */ public function getErrorHandler(): ?\Closure; @@ -270,6 +308,8 @@ public function getErrorHandler(): ?\Closure; * instance. * * @return null|object|resource The loop handle the event loop operates on. `null` if there is none. + * + * @psalm-impure */ public function getHandle(): mixed; @@ -277,6 +317,8 @@ public function getHandle(): mixed; * Returns all registered non-cancelled callback identifiers. * * @return string[] Callback identifiers. + * + * @psalm-impure */ public function getIdentifiers(): array; @@ -286,6 +328,8 @@ public function getIdentifiers(): array; * @param string $callbackId The callback identifier. * * @return CallbackType The callback type. + * + * @psalm-impure */ public function getType(string $callbackId): CallbackType; @@ -295,6 +339,8 @@ public function getType(string $callbackId): CallbackType; * @param string $callbackId The callback identifier. * * @return bool `true` if the callback is currently enabled, otherwise `false`. + * + * @psalm-impure */ public function isEnabled(string $callbackId): bool; @@ -304,6 +350,8 @@ public function isEnabled(string $callbackId): bool; * @param string $callbackId The callback identifier. * * @return bool `true` if the callback is currently referenced, otherwise `false`. + * + * @psalm-impure */ public function isReferenced(string $callbackId): bool; @@ -313,6 +361,8 @@ public function isReferenced(string $callbackId): bool; * If this method isn't implemented, dumping the event loop in a busy application, even indirectly, is a pain. * * @return array + * + * @psalm-impure */ public function __debugInfo(): array; } diff --git a/src/EventLoop/Driver/EvDriver.php b/src/EventLoop/Driver/EvDriver.php index 9699664b..60caa25c 100644 --- a/src/EventLoop/Driver/EvDriver.php +++ b/src/EventLoop/Driver/EvDriver.php @@ -19,6 +19,9 @@ final class EvDriver extends AbstractDriver /** @var array|null */ private static ?array $activeSignals = null; + /** + * @psalm-pure + */ public static function isSupported(): bool { return \extension_loaded("ev"); diff --git a/src/EventLoop/Driver/EventDriver.php b/src/EventLoop/Driver/EventDriver.php index 36b6040e..02e0a50d 100644 --- a/src/EventLoop/Driver/EventDriver.php +++ b/src/EventLoop/Driver/EventDriver.php @@ -19,6 +19,9 @@ final class EventDriver extends AbstractDriver /** @var array|null */ private static ?array $activeSignals = null; + /** + * @psalm-pure + */ public static function isSupported(): bool { return \extension_loaded("event"); diff --git a/src/EventLoop/Driver/StreamSelectDriver.php b/src/EventLoop/Driver/StreamSelectDriver.php index d072ff31..1f634cdf 100644 --- a/src/EventLoop/Driver/StreamSelectDriver.php +++ b/src/EventLoop/Driver/StreamSelectDriver.php @@ -53,7 +53,10 @@ public function __construct() && \function_exists('pcntl_signal_dispatch') && \function_exists('pcntl_signal'); - $this->streamSelectErrorHandler = function (int $errno, string $message): void { + $this->streamSelectErrorHandler = /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ + function (int $errno, string $message): void { // Casing changed in PHP 8 from 'unable' to 'Unable' if (\stripos($message, "stream_select(): unable to select [4]: ") === 0) { // EINTR $this->streamSelectIgnoreResult = true; @@ -102,6 +105,9 @@ public function onSignal(int $signal, \Closure $closure): string return parent::onSignal($signal, $closure); } + /** + * @psalm-pure + */ #[\Override] public function getHandle(): mixed { diff --git a/src/EventLoop/Driver/TracingDriver.php b/src/EventLoop/Driver/TracingDriver.php index 1888e99a..4951f637 100644 --- a/src/EventLoop/Driver/TracingDriver.php +++ b/src/EventLoop/Driver/TracingDriver.php @@ -207,6 +207,9 @@ public function getHandle(): mixed return $this->driver->getHandle(); } + /** + * @psalm-capabilities read-props + */ public function dump(): string { $dump = "Enabled, referenced callbacks keeping the loop running: "; @@ -260,11 +263,17 @@ public function queue(\Closure $closure, mixed ...$args): void $this->driver->queue($closure, ...$args); } + /** + * @psalm-capabilities read-props + */ private function getCreationTrace(string $callbackId): string { return $this->creationTraces[$callbackId] ?? 'No creation trace, yet.'; } + /** + * @psalm-capabilities read-props + */ private function getCancelTrace(string $callbackId): string { return $this->cancelTraces[$callbackId] ?? 'No cancellation trace, yet.'; @@ -284,6 +293,8 @@ private function getCancelTrace(string $callbackId): string * }> $trace Output of `debug_backtrace()`. * * @return string Formatted stacktrace. + * + * @psalm-pure */ private function formatStacktrace(array $trace): string { diff --git a/src/EventLoop/Driver/UvDriver.php b/src/EventLoop/Driver/UvDriver.php index 98c81dd5..d213fbab 100644 --- a/src/EventLoop/Driver/UvDriver.php +++ b/src/EventLoop/Driver/UvDriver.php @@ -14,6 +14,9 @@ final class UvDriver extends AbstractDriver { + /** + * @psalm-pure + */ public static function isSupported(): bool { return \extension_loaded("uv"); @@ -129,6 +132,9 @@ public function getHandle(): mixed return $this->handle; } + /** + * @psalm-capabilities read-props + */ #[\Override] protected function now(): float { @@ -138,6 +144,9 @@ protected function now(): float return \uv_now($this->handle) / 1000; } + /** + * @psalm-capabilities read-props + */ #[\Override] protected function dispatch(bool $blocking): void { @@ -145,6 +154,9 @@ protected function dispatch(bool $blocking): void \uv_run($this->handle, $blocking ? \UV::RUN_ONCE : \UV::RUN_NOWAIT); } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ #[\Override] protected function activate(array $callbacks): void { @@ -213,6 +225,9 @@ protected function activate(array $callbacks): void } } + /** + * @psalm-capabilities read-props + */ #[\Override] protected function deactivate(DriverCallback $callback): void { @@ -252,6 +267,9 @@ protected function deactivate(DriverCallback $callback): void } } + /** + * @psalm-pure + */ private function getStreamCallbackFlags(StreamCallback $callback): int { if ($callback instanceof StreamWritableCallback) { diff --git a/src/EventLoop/FiberLocal.php b/src/EventLoop/FiberLocal.php index 34dd2611..395d87f4 100644 --- a/src/EventLoop/FiberLocal.php +++ b/src/EventLoop/FiberLocal.php @@ -10,6 +10,8 @@ * Each instance stores data separately for each fiber. Usage examples include contextual logging data. * * @template T + * + * @api */ final class FiberLocal { diff --git a/src/EventLoop/Internal/AbstractDriver.php b/src/EventLoop/Internal/AbstractDriver.php index ef7513b0..8e6236b1 100644 --- a/src/EventLoop/Internal/AbstractDriver.php +++ b/src/EventLoop/Internal/AbstractDriver.php @@ -131,6 +131,9 @@ public function stop(): void $this->stopped = true; } + /** + * @psalm-capabilities read-props + */ #[\Override] public function isRunning(): bool { @@ -143,6 +146,9 @@ public function queue(\Closure $closure, mixed ...$args): void $this->microtaskQueue->enqueue([$closure, $args]); } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ #[\Override] public function defer(\Closure $closure): string { @@ -184,6 +190,9 @@ public function repeat(float $interval, \Closure $closure): string return $timerCallback->id; } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ #[\Override] public function onReadable(mixed $stream, \Closure $closure): string { @@ -195,6 +204,9 @@ public function onReadable(mixed $stream, \Closure $closure): string return $streamCallback->id; } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ #[\Override] public function onWritable($stream, \Closure $closure): string { @@ -333,6 +345,9 @@ public function getSuspension(): Suspension return $suspension; } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ #[\Override] public function setErrorHandler(?\Closure $errorHandler): void { @@ -345,6 +360,9 @@ public function getErrorHandler(): ?\Closure return $this->errorHandler; } + /** + * @psalm-capabilities read-props + */ #[\Override] public function __debugInfo(): array { @@ -357,12 +375,18 @@ public function __debugInfo(): array // @codeCoverageIgnoreEnd } + /** + * @psalm-capabilities read-props + */ #[\Override] public function getIdentifiers(): array { return \array_keys($this->callbacks); } + /** + * @psalm-capabilities read-props + */ #[\Override] public function getType(string $callbackId): CallbackType { @@ -371,6 +395,9 @@ public function getType(string $callbackId): CallbackType return $callback->getType(); } + /** + * @psalm-capabilities read-props + */ #[\Override] public function isEnabled(string $callbackId): bool { @@ -379,6 +406,9 @@ public function isEnabled(string $callbackId): bool return $callback->enabled; } + /** + * @psalm-capabilities read-props + */ #[\Override] public function isReferenced(string $callbackId): bool { @@ -391,16 +421,22 @@ public function isReferenced(string $callbackId): bool * Activates (enables) all the given callbacks. * * @param array $callbacks + * + * @psalm-impure */ abstract protected function activate(array $callbacks): void; /** * Dispatches any pending read/write, timer, and signal events. + * + * @psalm-impure */ abstract protected function dispatch(bool $blocking): void; /** * Deactivates (disables) the given callback. + * + * @psalm-impure */ abstract protected function deactivate(DriverCallback $callback): void; @@ -436,6 +472,8 @@ final protected function error(\Closure $closure, \Throwable $exception): void * * Note this value does not necessarily correlate to wall-clock time, rather the value returned is meant to be used * in relative comparisons to prior values returned by this method (intervals, expiration calculations, etc.). + * + * @psalm-impure */ abstract protected function now(): float; @@ -464,6 +502,8 @@ private function invokeMicrotasks(): void /** * @return bool True if no enabled and referenced callbacks remain in the loop. + * + * @psalm-capabilities read-props */ private function isEmpty(): bool { @@ -538,6 +578,8 @@ private function invokeCallbacks(): void /** * @param \Closure():mixed $interrupt + * + * @psalm-capabilities read-props|write-this-props|write-refs */ private function setInterrupt(\Closure $interrupt): void { @@ -546,6 +588,9 @@ private function setInterrupt(\Closure $interrupt): void $this->interrupt = $interrupt; } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ private function invokeInterrupt(): void { \assert($this->interrupt !== null); @@ -663,6 +708,9 @@ private function createErrorCallback(): void }; } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ private function callbackId(): string { $callbackId = $this->nextId; @@ -677,6 +725,9 @@ private function callbackId(): string return $callbackId; } + /** + * @psalm-pure + */ final public function __serialize(): never { throw new \Error(__CLASS__ . ' does not support serialization'); @@ -684,6 +735,8 @@ final public function __serialize(): never /** * @psalm-suppress UnusedParam $data param required. + * + * @psalm-pure */ final public function __unserialize(array $data): never { diff --git a/src/EventLoop/Internal/ClosureHelper.php b/src/EventLoop/Internal/ClosureHelper.php index 48f8fa26..da7478b7 100644 --- a/src/EventLoop/Internal/ClosureHelper.php +++ b/src/EventLoop/Internal/ClosureHelper.php @@ -4,9 +4,16 @@ namespace Revolt\EventLoop\Internal; -/** @internal */ +/** + * @internal + * + * @psalm-immutable + */ final class ClosureHelper { + /** + * @psalm-capabilities read-props + */ public static function getDescription(\Closure $closure): string { try { diff --git a/src/EventLoop/Internal/DeferCallback.php b/src/EventLoop/Internal/DeferCallback.php index b2bd8436..21041aec 100644 --- a/src/EventLoop/Internal/DeferCallback.php +++ b/src/EventLoop/Internal/DeferCallback.php @@ -9,6 +9,9 @@ /** @internal */ final class DeferCallback extends DriverCallback { + /** + * @psalm-pure + */ #[\Override] public function getType(): CallbackType { diff --git a/src/EventLoop/Internal/DriverCallback.php b/src/EventLoop/Internal/DriverCallback.php index 8cb47bf8..8e8e4922 100644 --- a/src/EventLoop/Internal/DriverCallback.php +++ b/src/EventLoop/Internal/DriverCallback.php @@ -23,10 +23,15 @@ public function __construct( ) { } + /** + * @psalm-capabilities read-props + */ abstract public function getType(): CallbackType; /** * @param string $property + * + * @psalm-pure */ public function __get(string $property): never { @@ -36,6 +41,8 @@ public function __get(string $property): never /** * @param string $property * @param mixed $value + * + * @psalm-pure */ public function __set(string $property, mixed $value): never { diff --git a/src/EventLoop/Internal/DriverSuspension.php b/src/EventLoop/Internal/DriverSuspension.php index e0edbc79..b876b817 100644 --- a/src/EventLoop/Internal/DriverSuspension.php +++ b/src/EventLoop/Internal/DriverSuspension.php @@ -179,6 +179,9 @@ public function throw(\Throwable $throwable): void } } + /** + * @psalm-pure + */ private function formatStacktrace(array $trace): string { return \implode("\n", \array_map(static function ($e, $i) { diff --git a/src/EventLoop/Internal/SignalCallback.php b/src/EventLoop/Internal/SignalCallback.php index 33e02cf3..5110cd95 100644 --- a/src/EventLoop/Internal/SignalCallback.php +++ b/src/EventLoop/Internal/SignalCallback.php @@ -9,6 +9,9 @@ /** @internal */ final class SignalCallback extends DriverCallback { + /** + * @psalm-capabilities read-props + */ public function __construct( string $id, \Closure $closure, @@ -17,6 +20,9 @@ public function __construct( parent::__construct($id, $closure); } + /** + * @psalm-pure + */ #[\Override] public function getType(): CallbackType { diff --git a/src/EventLoop/Internal/StreamCallback.php b/src/EventLoop/Internal/StreamCallback.php index 56a25478..292d20f6 100644 --- a/src/EventLoop/Internal/StreamCallback.php +++ b/src/EventLoop/Internal/StreamCallback.php @@ -9,6 +9,8 @@ abstract class StreamCallback extends DriverCallback { /** * @param resource $stream + * + * @psalm-capabilities read-props */ public function __construct( string $id, diff --git a/src/EventLoop/Internal/StreamReadableCallback.php b/src/EventLoop/Internal/StreamReadableCallback.php index a68c793e..39babee4 100644 --- a/src/EventLoop/Internal/StreamReadableCallback.php +++ b/src/EventLoop/Internal/StreamReadableCallback.php @@ -9,6 +9,9 @@ /** @internal */ final class StreamReadableCallback extends StreamCallback { + /** + * @psalm-pure + */ #[\Override] public function getType(): CallbackType { diff --git a/src/EventLoop/Internal/StreamWritableCallback.php b/src/EventLoop/Internal/StreamWritableCallback.php index a3c86265..23c3df89 100644 --- a/src/EventLoop/Internal/StreamWritableCallback.php +++ b/src/EventLoop/Internal/StreamWritableCallback.php @@ -9,6 +9,9 @@ /** @internal */ final class StreamWritableCallback extends StreamCallback { + /** + * @psalm-pure + */ #[\Override] public function getType(): CallbackType { diff --git a/src/EventLoop/Internal/TimerCallback.php b/src/EventLoop/Internal/TimerCallback.php index 7b6d3144..1fcdf5cc 100644 --- a/src/EventLoop/Internal/TimerCallback.php +++ b/src/EventLoop/Internal/TimerCallback.php @@ -9,6 +9,9 @@ /** @internal */ final class TimerCallback extends DriverCallback { + /** + * @psalm-capabilities read-props + */ public function __construct( string $id, public readonly float $interval, @@ -19,6 +22,9 @@ public function __construct( parent::__construct($id, $callback); } + /** + * @psalm-capabilities read-props + */ #[\Override] public function getType(): CallbackType { diff --git a/src/EventLoop/Internal/TimerQueue.php b/src/EventLoop/Internal/TimerQueue.php index 8caef94f..b0705330 100644 --- a/src/EventLoop/Internal/TimerQueue.php +++ b/src/EventLoop/Internal/TimerQueue.php @@ -8,6 +8,8 @@ * Uses a binary tree stored in an array to implement a heap. * * @internal + * + * @psalm-capabilities read-props|write-this-props|write-refs */ final class TimerQueue { @@ -21,6 +23,8 @@ final class TimerQueue * Inserts the callback into the queue. * * Time complexity: O(log(n)). + * + * @psalm-capabilities read-props|write-this-props|write-refs */ public function insert(TimerCallback $callback): void { @@ -37,6 +41,8 @@ public function insert(TimerCallback $callback): void * Removes the given callback from the queue. * * Time complexity: O(log(n)). + * + * @psalm-capabilities read-props|write-this-props|write-refs */ public function remove(TimerCallback $callback): void { @@ -57,6 +63,8 @@ public function remove(TimerCallback $callback): void * @param float $now Current event loop time. * * @return TimerCallback|null Expired callback at the top of the heap or null if the callback has not expired. + * + * @psalm-capabilities read-props|write-this-props|write-refs */ public function extract(float $now): ?TimerCallback { @@ -80,6 +88,8 @@ public function extract(float $now): ?TimerCallback * Time complexity: O(1). * * @return float|null Expiration time of the callback at the top of the heap or null if the heap is empty. + * + * @psalm-capabilities read-props */ public function peek(): ?float { @@ -88,6 +98,8 @@ public function peek(): ?float /** * @param int $node Rebuild the data array from the given node upward. + * + * @psalm-capabilities read-props|write-this-props|write-refs */ private function heapifyUp(int $node): void { @@ -100,6 +112,8 @@ private function heapifyUp(int $node): void /** * @param int $node Rebuild the data array from the given node downward. + * + * @psalm-capabilities read-props|write-this-props|write-refs */ private function heapifyDown(int $node): void { @@ -122,6 +136,9 @@ private function heapifyDown(int $node): void } } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ private function swap(int $left, int $right): void { $temp = $this->callbacks[$left]; @@ -135,6 +152,8 @@ private function swap(int $left, int $right): void /** * @param int $node Remove the given node and then rebuild the data array. + * + * @psalm-capabilities read-props|write-this-props|write-refs */ private function removeAndRebuild(int $node): void { diff --git a/src/EventLoop/InvalidCallbackError.php b/src/EventLoop/InvalidCallbackError.php index 8be00e2e..f1ea0e2f 100644 --- a/src/EventLoop/InvalidCallbackError.php +++ b/src/EventLoop/InvalidCallbackError.php @@ -6,6 +6,11 @@ use Revolt\EventLoop\Internal\ClosureHelper; +/** + * Error thrown for an invalid event-loop callback. + * + * @api + */ final class InvalidCallbackError extends \Error { public const E_NONNULL_RETURN = 1; @@ -13,6 +18,8 @@ final class InvalidCallbackError extends \Error /** * MUST be thrown if any callback returns a non-null value. + * + * @psalm-capabilities read-props */ public static function nonNullReturn(string $callbackId, \Closure $closure): self { @@ -27,6 +34,8 @@ public static function nonNullReturn(string $callbackId, \Closure $closure): sel * MUST be thrown if any operation (except disable() and cancel()) is attempted with an invalid callback identifier. * * An invalid callback identifier is any identifier that is not yet emitted by the driver or cancelled by the user. + * + * @psalm-pure */ public static function invalidIdentifier(string $callbackId): self { @@ -45,6 +54,8 @@ public static function invalidIdentifier(string $callbackId): self /** * @param string $callbackId The callback identifier. * @param string $message The exception message. + * + * @psalm-capabilities read-props */ private function __construct(string $callbackId, int $code, string $message) { @@ -62,6 +73,9 @@ public function getCallbackId(): string return $this->callbackId; } + /** + * @psalm-capabilities read-props|write-this-props|write-refs + */ public function addInfo(string $key, string $message): void { $this->info[$key] = $message; diff --git a/src/EventLoop/Suspension.php b/src/EventLoop/Suspension.php index 216cfc17..81b3baf1 100644 --- a/src/EventLoop/Suspension.php +++ b/src/EventLoop/Suspension.php @@ -21,11 +21,15 @@ * ``` * * @template T + * + * @psalm-mutable */ interface Suspension { /** * @param T $value The value to return from the call to {@see suspend()}. + * + * @psalm-impure */ public function resume(mixed $value = null): void; @@ -33,11 +37,15 @@ public function resume(mixed $value = null): void; * Returns the value provided to {@see resume()} or throws the exception provided to {@see throw()}. * * @return T + * + * @psalm-impure */ public function suspend(): mixed; /** * Throws the given exception from the call to {@see suspend()}. + * + * @psalm-impure */ public function throw(\Throwable $throwable): void; } diff --git a/src/EventLoop/UncaughtThrowable.php b/src/EventLoop/UncaughtThrowable.php index 2ee4c742..9f9886a9 100644 --- a/src/EventLoop/UncaughtThrowable.php +++ b/src/EventLoop/UncaughtThrowable.php @@ -8,6 +8,9 @@ final class UncaughtThrowable extends \Error { + /** + * @psalm-pure + */ public static function throwingCallback(\Closure $closure, \Throwable $previous): self { return new self( @@ -17,11 +20,17 @@ public static function throwingCallback(\Closure $closure, \Throwable $previous) ); } + /** + * @psalm-pure + */ public static function throwingErrorHandler(\Closure $closure, \Throwable $previous): self { return new self("Uncaught %s thrown in event loop error handler %s%s", $closure, $previous); } + /** + * @psalm-capabilities read-props + */ private function __construct(string $message, \Closure $closure, \Throwable $previous) { parent::__construct(\sprintf( diff --git a/stubs/EventLoop.phpstub b/stubs/EventLoop.phpstub new file mode 100644 index 00000000..8ee549a0 --- /dev/null +++ b/stubs/EventLoop.phpstub @@ -0,0 +1,133 @@ + Date: Fri, 30 Oct 2026 12:28:54 +0100 Subject: [PATCH 2/3] Declare event-loop io capabilities in the source instead of a stub --- .gitattributes | 3 +- src/EventLoop.php | 48 +++++++ src/EventLoop/Internal/DriverSuspension.php | 2 + src/EventLoop/Suspension.php | 6 +- stubs/EventLoop.phpstub | 133 -------------------- 5 files changed, 54 insertions(+), 138 deletions(-) delete mode 100644 stubs/EventLoop.phpstub diff --git a/.gitattributes b/.gitattributes index 561b89c5..099ad862 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,6 @@ .github export-ignore examples export-ignore -stubs/* export-ignore -stubs/EventLoop.phpstub -export-ignore +stubs export-ignore test export-ignore tools export-ignore .editorconfig export-ignore diff --git a/src/EventLoop.php b/src/EventLoop.php index fac5351b..6868b50a 100644 --- a/src/EventLoop.php +++ b/src/EventLoop.php @@ -16,6 +16,9 @@ /** * Accessor to allow global access to the event loop. * + * Scheduling and suspension are `io` for callers, like `usleep()`: the driver is the scheduler, and + * what other fibers do while the current one is suspended is their own effect. + * * @see Driver */ final class EventLoop @@ -95,6 +98,9 @@ protected function now(): float * * @param \Closure(...):void $closure The callback to queue. * @param mixed ...$args The callback arguments. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function queue(\Closure $closure, mixed ...$args): void { @@ -114,6 +120,9 @@ public static function queue(\Closure $closure, mixed ...$args): void * invalidated before the callback invocation. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function defer(\Closure $closure): string { @@ -134,6 +143,9 @@ public static function defer(\Closure $closure): string * before the callback invocation. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function delay(float $delay, \Closure $closure): string { @@ -154,6 +166,9 @@ public static function delay(float $delay, \Closure $closure): string * @param \Closure(string):void $closure The callback to repeat. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function repeat(float $interval, \Closure $closure): string { @@ -177,6 +192,9 @@ public static function repeat(float $interval, \Closure $closure): string * @param \Closure(string, resource):void $closure The callback to execute. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function onReadable(mixed $stream, \Closure $closure): string { @@ -200,6 +218,9 @@ public static function onReadable(mixed $stream, \Closure $closure): string * @param \Closure(string, resource):void $closure The callback to execute. * * @return string A unique identifier that can be used to cancel, enable or disable the callback. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function onWritable(mixed $stream, \Closure $closure): string { @@ -224,6 +245,9 @@ public static function onWritable(mixed $stream, \Closure $closure): string * @return string A unique identifier that can be used to cancel, enable or disable the callback. * * @throws UnsupportedFeatureException If signal handling is not supported. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function onSignal(int $signal, \Closure $closure): string { @@ -241,6 +265,9 @@ public static function onSignal(int $signal, \Closure $closure): string * @return string The callback identifier. * * @throws InvalidCallbackError If the callback identifier is invalid. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function enable(string $callbackId): string { @@ -259,6 +286,9 @@ public static function enable(string $callbackId): string * @param string $callbackId The callback identifier. * * @return string The callback identifier. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function disable(string $callbackId): string { @@ -272,6 +302,9 @@ public static function disable(string $callbackId): string * callback is permanently invalid. Calling this function MUST NOT fail, even if passed an invalid identifier. * * @param string $callbackId The callback identifier. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function cancel(string $callbackId): void { @@ -289,6 +322,9 @@ public static function cancel(string $callbackId): void * @return string The callback identifier. * * @throws InvalidCallbackError If the callback identifier is invalid. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function reference(string $callbackId): string { @@ -304,6 +340,9 @@ public static function reference(string $callbackId): string * @param string $callbackId The callback identifier. * * @return string The callback identifier. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function unreference(string $callbackId): string { @@ -364,6 +403,9 @@ public static function getType(string $callbackId): CallbackType * @param string $callbackId The callback identifier. * * @return bool `true` if the callback is currently enabled, otherwise `false`. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function isEnabled(string $callbackId): bool { @@ -376,6 +418,9 @@ public static function isEnabled(string $callbackId): bool * @param string $callbackId The callback identifier. * * @return bool `true` if the callback is currently referenced, otherwise `false`. + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function isReferenced(string $callbackId): bool { @@ -403,6 +448,9 @@ public static function getDriver(): Driver * Calls from the same fiber will return the same suspension object. * * @return Suspension + * + * @psalm-capabilities io + * @psalm-suppress ImpureMethodCall */ public static function getSuspension(): Suspension { diff --git a/src/EventLoop/Internal/DriverSuspension.php b/src/EventLoop/Internal/DriverSuspension.php index b876b817..1b940e38 100644 --- a/src/EventLoop/Internal/DriverSuspension.php +++ b/src/EventLoop/Internal/DriverSuspension.php @@ -13,6 +13,8 @@ * * @template T * @implements Suspension + * + * @psalm-suppress ImmutableDependency The driver's bookkeeping and the fibers it runs are not the caller's effects. */ final class DriverSuspension implements Suspension { diff --git a/src/EventLoop/Suspension.php b/src/EventLoop/Suspension.php index 81b3baf1..5f1886b0 100644 --- a/src/EventLoop/Suspension.php +++ b/src/EventLoop/Suspension.php @@ -29,7 +29,7 @@ interface Suspension /** * @param T $value The value to return from the call to {@see suspend()}. * - * @psalm-impure + * @psalm-capabilities io */ public function resume(mixed $value = null): void; @@ -38,14 +38,14 @@ public function resume(mixed $value = null): void; * * @return T * - * @psalm-impure + * @psalm-capabilities io */ public function suspend(): mixed; /** * Throws the given exception from the call to {@see suspend()}. * - * @psalm-impure + * @psalm-capabilities io */ public function throw(\Throwable $throwable): void; } diff --git a/stubs/EventLoop.phpstub b/stubs/EventLoop.phpstub deleted file mode 100644 index 8ee549a0..00000000 --- a/stubs/EventLoop.phpstub +++ /dev/null @@ -1,133 +0,0 @@ - Date: Fri, 30 Oct 2026 12:38:32 +0100 Subject: [PATCH 3/3] Give the fiber-local examples distinct logger classes --- examples/fiber-local-automatic.php | 3 +++ examples/fiber-local-manual.php | 7 +++++-- psalm.xml | 6 ------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/fiber-local-automatic.php b/examples/fiber-local-automatic.php index 243d74cc..014efcaf 100644 --- a/examples/fiber-local-automatic.php +++ b/examples/fiber-local-automatic.php @@ -21,6 +21,9 @@ final class Logger private int $nextId = 1; private FiberLocal $transactionId; + /** + * @psalm-capabilities read-props + */ public function __construct() { $this->transactionId = new FiberLocal(fn () => $this->nextId++); diff --git a/examples/fiber-local-manual.php b/examples/fiber-local-manual.php index 04bb7fda..5ffe25cc 100644 --- a/examples/fiber-local-manual.php +++ b/examples/fiber-local-manual.php @@ -14,10 +14,13 @@ * * @psalm-suppress UnusedProperty */ -final class Logger +final class ManualLogger { private FiberLocal $transactionId; + /** + * @psalm-capabilities read-props + */ public function __construct() { $this->transactionId = new FiberLocal(fn () => throw new \Exception('Unknown transaction ID')); @@ -45,7 +48,7 @@ public function log(string $message): void } } -$logger = new Logger(); +$logger = new ManualLogger(); $logger->setTransactionId(1); EventLoop::delay(1, static function () use ($logger) { diff --git a/psalm.xml b/psalm.xml index 81965bad..9cee1117 100644 --- a/psalm.xml +++ b/psalm.xml @@ -15,12 +15,6 @@ - - - - - -