From 42d343b9af44942c93555f7f3b3c390d980d0bb2 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Thu, 24 Feb 2022 11:05:01 +0100 Subject: [PATCH 01/20] Only stop loop if a pending promise resolves/rejects --- src/functions.php | 16 +++-- tests/AwaitTest.php | 143 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/functions.php b/src/functions.php index ad91688..a3e0390 100644 --- a/src/functions.php +++ b/src/functions.php @@ -56,18 +56,25 @@ function await(PromiseInterface $promise) $resolved = null; $exception = null; $rejected = false; + $loopStarted = false; $promise->then( - function ($c) use (&$resolved, &$wait) { + function ($c) use (&$resolved, &$wait, &$loopStarted) { $resolved = $c; $wait = false; - Loop::stop(); + + if ($loopStarted) { + Loop::stop(); + } }, - function ($error) use (&$exception, &$rejected, &$wait) { + function ($error) use (&$exception, &$rejected, &$wait, &$loopStarted) { $exception = $error; $rejected = true; $wait = false; - Loop::stop(); + + if ($loopStarted) { + Loop::stop(); + } } ); @@ -76,6 +83,7 @@ function ($error) use (&$exception, &$rejected, &$wait) { $promise = null; while ($wait) { + $loopStarted = true; Loop::run(); } diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 95a8b5f..da9079a 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -84,6 +84,149 @@ public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerSto $this->assertEquals(2, React\Async\await($promise)); } + public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutRunningLoop() + { + $now = true; + + Loop::futureTick(function () use (&$now) { + $now = false; + }); + + $promise = new Promise(function ($resolve) { + $resolve(42); + }); + + React\Async\await($promise); + $this->assertTrue($now); + } + + public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutStoppingLoop() + { + $ticks = 0; + + $promise = new Promise(function ($resolve) { + $resolve(42); + }); + + // Loop will execute this tick first + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + // Loop will execute this tick third + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + }); + }); + + // Loop will execute this tick second + Loop::futureTick(function () use (&$promise){ + // await won't stop the loop if promise already resolved -> third tick will trigger + React\Async\await($promise); + }); + + Loop::run(); + + $this->assertEquals(2, $ticks); + } + + public function testAwaitWithPendingPromiseThatWillResolveWillStopLoopBeforeLastTimerFinishes() + { + $promise = new Promise(function ($resolve) { + Loop::addTimer(0.02, function () use ($resolve) { + $resolve(2); + }); + }); + + $ticks = 0; + + // Loop will execute this tick first + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + // This timer will never finish because Loop gets stopped by await + // Loop needs to be manually started again to finish this timer + Loop::addTimer(0.04, function () use (&$ticks) { + ++$ticks; + }); + }); + + // await stops the loop when promise resolves after 0.02s + Loop::futureTick(function () use (&$promise){ + React\Async\await($promise); + }); + + Loop::run(); + + // This bahvior exists in v2 & v3 of async, we recommend to use fibers in v4 (PHP>=8.1) + $this->assertEquals(1, $ticks); + } + + public function testAwaitWithAlreadyRejectedPromiseWillReturnWithoutStoppingLoop() + { + $ticks = 0; + + $promise = new Promise(function ($_, $reject) { + throw new \Exception(); + }); + + // Loop will execute this tick first + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + // Loop will execute this tick third + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + }); + }); + + // Loop will execute this tick second + Loop::futureTick(function () use (&$promise){ + try { + // await won't stop the loop if promise already rejected -> third tick will trigger + React\Async\await($promise); + } catch (\Exception $e) { + // no-op + } + }); + + Loop::run(); + + $this->assertEquals(2, $ticks); + } + + public function testAwaitWithPendingPromiseThatWillRejectWillStopLoopBeforeLastTimerFinishes() + { + $promise = new Promise(function ($_, $reject) { + Loop::addTimer(0.02, function () use (&$reject) { + $reject(new \Exception()); + }); + }); + + $ticks = 0; + + // Loop will execute this tick first + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + // This timer will never finish because Loop gets stopped by await + // Loop needs to be manually started again to finish this timer + Loop::addTimer(0.04, function () use (&$ticks) { + ++$ticks; + }); + }); + + // Loop will execute this tick second + // await stops the loop when promise rejects after 0.02s + Loop::futureTick(function () use (&$promise){ + try { + React\Async\await($promise); + } catch (\Exception $e) { + // no-op + } + }); + + Loop::run(); + + // This bahvior exists in v2 & v3 of async, we recommend to use fibers in v4 (PHP>=8.1) + $this->assertEquals(1, $ticks); + } + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() { if (class_exists('React\Promise\When')) { From f960c5cc63c5b713afa30ea7add5d080bc622a35 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Fri, 18 Mar 2022 11:37:18 +0100 Subject: [PATCH 02/20] Add badge to show number of project installations --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 522bef6..f172404 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Async [![CI status](https://github.com/reactphp/async/workflows/CI/badge.svg)](https://github.com/reactphp/async/actions) +[![installs on Packagist](https://img.shields.io/packagist/dt/react/async?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/async) Async utilities for [ReactPHP](https://reactphp.org/). From a15a9e7d02eb233079d1ea8be8def11b6af1e895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 5 Jun 2022 19:39:31 +0200 Subject: [PATCH 03/20] Support `iterable` type for `parallel()` + `series()` + `waterfall()` --- README.md | 6 ++--- src/functions.php | 24 ++++++++++++++----- tests/ParallelTest.php | 45 +++++++++++++++++++++++++++++++++++ tests/SeriesTest.php | 45 +++++++++++++++++++++++++++++++++++ tests/WaterfallTest.php | 52 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f172404..b0887a5 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ $promise->then(function (int $bytes) { ### parallel() -The `parallel(array> $tasks): PromiseInterface,Exception>` function can be used +The `parallel(iterable> $tasks): PromiseInterface,Exception>` function can be used like this: ```php @@ -250,7 +250,7 @@ React\Async\parallel([ ### series() -The `series(array> $tasks): PromiseInterface,Exception>` function can be used +The `series(iterable> $tasks): PromiseInterface,Exception>` function can be used like this: ```php @@ -292,7 +292,7 @@ React\Async\series([ ### waterfall() -The `waterfall(array> $tasks): PromiseInterface` function can be used +The `waterfall(iterable> $tasks): PromiseInterface` function can be used like this: ```php diff --git a/src/functions.php b/src/functions.php index a3e0390..2eaad39 100644 --- a/src/functions.php +++ b/src/functions.php @@ -282,10 +282,10 @@ function coroutine(callable $function, ...$args): PromiseInterface } /** - * @param array> $tasks + * @param iterable> $tasks * @return PromiseInterface,Exception> */ -function parallel(array $tasks): PromiseInterface +function parallel(iterable $tasks): PromiseInterface { $pending = []; $deferred = new Deferred(function () use (&$pending) { @@ -299,6 +299,10 @@ function parallel(array $tasks): PromiseInterface $results = []; $errored = false; + if (!\is_array($tasks)) { + $tasks = \iterator_to_array($tasks); + } + $numTasks = count($tasks); if (0 === $numTasks) { $deferred->resolve($results); @@ -340,10 +344,10 @@ function parallel(array $tasks): PromiseInterface } /** - * @param array> $tasks + * @param iterable> $tasks * @return PromiseInterface,Exception> */ -function series(array $tasks): PromiseInterface +function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { @@ -354,6 +358,10 @@ function series(array $tasks): PromiseInterface }); $results = []; + if (!\is_array($tasks)) { + $tasks = \iterator_to_array($tasks); + } + /** @var callable():void $next */ $taskCallback = function ($result) use (&$results, &$next) { $results[] = $result; @@ -380,10 +388,10 @@ function series(array $tasks): PromiseInterface } /** - * @param array> $tasks + * @param iterable> $tasks * @return PromiseInterface */ -function waterfall(array $tasks): PromiseInterface +function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { @@ -393,6 +401,10 @@ function waterfall(array $tasks): PromiseInterface $pending = null; }); + if (!\is_array($tasks)) { + $tasks = \iterator_to_array($tasks); + } + /** @var callable $next */ $next = function ($value = null) use (&$tasks, &$next, $deferred, &$pending) { if (0 === count($tasks)) { diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index b77a3ca..284ccc0 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -17,6 +17,19 @@ public function testParallelWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } + public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + { + $tasks = (function () { + if (false) { + yield; + } + })(); + + $promise = React\Async\parallel($tasks); + + $promise->then($this->expectCallableOnceWith([])); + } + public function testParallelWithTasks() { $tasks = array( @@ -49,6 +62,38 @@ function () { $timer->assertInRange(0.1, 0.2); } + public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + { + $tasks = (function () { + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.1, function () use ($resolve) { + $resolve('foo'); + }); + }); + }; + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.11, function () use ($resolve) { + $resolve('bar'); + }); + }); + }; + })(); + + $promise = React\Async\parallel($tasks); + + $promise->then($this->expectCallableOnceWith(array('foo', 'bar'))); + + $timer = new Timer($this); + $timer->start(); + + Loop::run(); + + $timer->stop(); + $timer->assertInRange(0.1, 0.2); + } + public function testParallelWithErrorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() { $called = 0; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 7cedf91..38937eb 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -17,6 +17,19 @@ public function testSeriesWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } + public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + { + $tasks = (function () { + if (false) { + yield; + } + })(); + + $promise = React\Async\series($tasks); + + $promise->then($this->expectCallableOnceWith([])); + } + public function testSeriesWithTasks() { $tasks = array( @@ -49,6 +62,38 @@ function () { $timer->assertInRange(0.10, 0.20); } + public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + { + $tasks = (function () { + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.051, function () use ($resolve) { + $resolve('foo'); + }); + }); + }; + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.051, function () use ($resolve) { + $resolve('bar'); + }); + }); + }; + })(); + + $promise = React\Async\series($tasks); + + $promise->then($this->expectCallableOnceWith(array('foo', 'bar'))); + + $timer = new Timer($this); + $timer->start(); + + Loop::run(); + + $timer->stop(); + $timer->assertInRange(0.10, 0.20); + } + public function testSeriesWithError() { $called = 0; diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index b0c5c3c..70f1ee6 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -17,6 +17,19 @@ public function testWaterfallWithoutTasks() $promise->then($this->expectCallableOnceWith(null)); } + public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull() + { + $tasks = (function () { + if (false) { + yield; + } + })(); + + $promise = React\Async\waterfall($tasks); + + $promise->then($this->expectCallableOnceWith(null)); + } + public function testWaterfallWithTasks() { $tasks = array( @@ -56,6 +69,45 @@ function ($bar) { $timer->assertInRange(0.15, 0.30); } + public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentValue() + { + $tasks = (function () { + yield function ($foo = 'foo') { + return new Promise(function ($resolve) use ($foo) { + Loop::addTimer(0.05, function () use ($resolve, $foo) { + $resolve($foo); + }); + }); + }; + yield function ($foo) { + return new Promise(function ($resolve) use ($foo) { + Loop::addTimer(0.05, function () use ($resolve, $foo) { + $resolve($foo . 'bar'); + }); + }); + }; + yield function ($bar) { + return new Promise(function ($resolve) use ($bar) { + Loop::addTimer(0.05, function () use ($resolve, $bar) { + $resolve($bar . 'baz'); + }); + }); + }; + })(); + + $promise = React\Async\waterfall($tasks); + + $promise->then($this->expectCallableOnceWith('foobarbaz')); + + $timer = new Timer($this); + $timer->start(); + + Loop::run(); + + $timer->stop(); + $timer->assertInRange(0.15, 0.30); + } + public function testWaterfallWithError() { $called = 0; From 9660313007018542e10c4f19c8d8c8a99f26d2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 11 Jun 2022 14:22:27 +0200 Subject: [PATCH 04/20] Take advantage of iterators instead of converting to array first --- src/functions.php | 63 ++++++++++++++++++++++++----------------- tests/ParallelTest.php | 20 +++++++++++++ tests/SeriesTest.php | 42 +++++++++++++++++++++++++++ tests/WaterfallTest.php | 42 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 26 deletions(-) diff --git a/src/functions.php b/src/functions.php index 2eaad39..02fbd88 100644 --- a/src/functions.php +++ b/src/functions.php @@ -297,19 +297,10 @@ function parallel(iterable $tasks): PromiseInterface $pending = []; }); $results = []; - $errored = false; + $continue = true; - if (!\is_array($tasks)) { - $tasks = \iterator_to_array($tasks); - } - - $numTasks = count($tasks); - if (0 === $numTasks) { - $deferred->resolve($results); - } - - $taskErrback = function ($error) use (&$pending, $deferred, &$errored) { - $errored = true; + $taskErrback = function ($error) use (&$pending, $deferred, &$continue) { + $continue = false; $deferred->reject($error); foreach ($pending as $promise) { @@ -321,25 +312,31 @@ function parallel(iterable $tasks): PromiseInterface }; foreach ($tasks as $i => $task) { - $taskCallback = function ($result) use (&$results, &$pending, $numTasks, $i, $deferred) { + $taskCallback = function ($result) use (&$results, &$pending, &$continue, $i, $deferred) { $results[$i] = $result; + unset($pending[$i]); - if (count($results) === $numTasks) { + if (!$pending && !$continue) { $deferred->resolve($results); } }; - $promise = call_user_func($task); + $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending[$i] = $promise; $promise->then($taskCallback, $taskErrback); - if ($errored) { + if (!$continue) { break; } } + $continue = false; + if (!$pending) { + $deferred->resolve($results); + } + return $deferred->promise(); } @@ -358,8 +355,9 @@ function series(iterable $tasks): PromiseInterface }); $results = []; - if (!\is_array($tasks)) { - $tasks = \iterator_to_array($tasks); + if ($tasks instanceof \IteratorAggregate) { + $tasks = $tasks->getIterator(); + assert($tasks instanceof \Iterator); } /** @var callable():void $next */ @@ -369,13 +367,19 @@ function series(iterable $tasks): PromiseInterface }; $next = function () use (&$tasks, $taskCallback, $deferred, &$results, &$pending) { - if (0 === count($tasks)) { + if ($tasks instanceof \Iterator ? !$tasks->valid() : !$tasks) { $deferred->resolve($results); return; } - $task = array_shift($tasks); - $promise = call_user_func($task); + if ($tasks instanceof \Iterator) { + $task = $tasks->current(); + $tasks->next(); + } else { + $task = \array_shift($tasks); + } + + $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending = $promise; @@ -401,19 +405,26 @@ function waterfall(iterable $tasks): PromiseInterface $pending = null; }); - if (!\is_array($tasks)) { - $tasks = \iterator_to_array($tasks); + if ($tasks instanceof \IteratorAggregate) { + $tasks = $tasks->getIterator(); + assert($tasks instanceof \Iterator); } /** @var callable $next */ $next = function ($value = null) use (&$tasks, &$next, $deferred, &$pending) { - if (0 === count($tasks)) { + if ($tasks instanceof \Iterator ? !$tasks->valid() : !$tasks) { $deferred->resolve($value); return; } - $task = array_shift($tasks); - $promise = call_user_func_array($task, func_get_args()); + if ($tasks instanceof \Iterator) { + $task = $tasks->current(); + $tasks->next(); + } else { + $task = \array_shift($tasks); + } + + $promise = \call_user_func_array($task, func_get_args()); assert($promise instanceof PromiseInterface); $pending = $promise; diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 284ccc0..1a5759b 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -5,6 +5,7 @@ use React; use React\EventLoop\Loop; use React\Promise\Promise; +use function React\Promise\reject; class ParallelTest extends TestCase { @@ -126,6 +127,25 @@ function () use (&$called) { $this->assertSame(2, $called); } + public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { + yield function () use (&$called) { + return reject(new \RuntimeException('Rejected ' . ++$called)); + }; + } + })(); + + $promise = React\Async\parallel($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $called); + } + public function testParallelWithErrorWillCancelPendingPromises() { $cancelled = 0; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 38937eb..2583639 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -5,6 +5,7 @@ use React; use React\EventLoop\Loop; use React\Promise\Promise; +use function React\Promise\reject; class SeriesTest extends TestCase { @@ -125,6 +126,47 @@ function () use (&$called) { $this->assertSame(1, $called); } + public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { + yield function () use (&$called) { + return reject(new \RuntimeException('Rejected ' . ++$called)); + }; + } + })(); + + $promise = React\Async\series($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $called); + } + + public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $tasks = new class() implements \IteratorAggregate { + public $called = 0; + + public function getIterator(): \Iterator + { + while (true) { + yield function () { + return reject(new \RuntimeException('Rejected ' . ++$this->called)); + }; + } + } + }; + + $promise = React\Async\series($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $tasks->called); + } + public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() { $cancelled = 0; diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 70f1ee6..ace1877 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -5,6 +5,7 @@ use React; use React\EventLoop\Loop; use React\Promise\Promise; +use function React\Promise\reject; class WaterfallTest extends TestCase { @@ -139,6 +140,47 @@ function () use (&$called) { $this->assertSame(1, $called); } + public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { + yield function () use (&$called) { + return reject(new \RuntimeException('Rejected ' . ++$called)); + }; + } + })(); + + $promise = React\Async\waterfall($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $called); + } + + public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $tasks = new class() implements \IteratorAggregate { + public $called = 0; + + public function getIterator(): \Iterator + { + while (true) { + yield function () { + return reject(new \RuntimeException('Rejected ' . ++$this->called)); + }; + } + } + }; + + $promise = React\Async\waterfall($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $tasks->called); + } + public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() { $cancelled = 0; From e194657b2b794b0f63f8ea065fc913643d63a9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Jun 2022 19:02:21 +0200 Subject: [PATCH 05/20] Forward compatibility with upcoming Promise v3 --- composer.json | 2 +- src/functions.php | 11 +++++------ tests/SeriesTest.php | 2 +- tests/WaterfallTest.php | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index a839932..0be4bf5 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": ">=7.1", "react/event-loop": "^1.2", - "react/promise": "^2.8 || ^1.2.1" + "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { "phpunit/phpunit": "^9.3 || ^7.5" diff --git a/src/functions.php b/src/functions.php index a3e0390..8dfff90 100644 --- a/src/functions.php +++ b/src/functions.php @@ -3,7 +3,6 @@ namespace React\Async; use React\EventLoop\Loop; -use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use function React\Promise\reject; @@ -235,7 +234,7 @@ function coroutine(callable $function, ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { // cancel pending promise(s) as long as generator function keeps yielding - while ($promise instanceof CancellablePromiseInterface) { + while ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $temp = $promise; $promise = null; $temp->cancel(); @@ -290,7 +289,7 @@ function parallel(array $tasks): PromiseInterface $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { - if ($promise instanceof CancellablePromiseInterface) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } } @@ -309,7 +308,7 @@ function parallel(array $tasks): PromiseInterface $deferred->reject($error); foreach ($pending as $promise) { - if ($promise instanceof CancellablePromiseInterface) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } } @@ -347,7 +346,7 @@ function series(array $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - if ($pending instanceof CancellablePromiseInterface) { + if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } $pending = null; @@ -387,7 +386,7 @@ function waterfall(array $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - if ($pending instanceof CancellablePromiseInterface) { + if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } $pending = null; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 7cedf91..188651d 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -87,7 +87,7 @@ public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResult $tasks = array( function () { return new Promise(function ($resolve) { - $resolve(); + $resolve(null); }); }, function () use (&$cancelled) { diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index b0c5c3c..4643d82 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -94,7 +94,7 @@ public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnRes $tasks = array( function () { return new Promise(function ($resolve) { - $resolve(); + $resolve(null); }); }, function () use (&$cancelled) { From f081feefe36a3f8aaf04eda342fd799ba8d1fc2c Mon Sep 17 00:00:00 2001 From: Nicolas Hedger Date: Tue, 28 Jun 2022 07:38:21 +0200 Subject: [PATCH 06/20] chore(docs): remove leading dollar signs --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b0887a5..55e1568 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,7 @@ Once released, this project will follow [SemVer](https://semver.org/). At the moment, this will install the latest development version: ```bash -$ composer require react/async:dev-main +composer require react/async:dev-main ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. @@ -354,13 +354,13 @@ To run the test suite, you first need to clone this repo and then install all dependencies [through Composer](https://getcomposer.org/): ```bash -$ composer install +composer install ``` To run the test suite, go to the project root and run: ```bash -$ php vendor/bin/phpunit +vendor/bin/phpunit ``` ## License From 7058eb2fe61fa251b8c1aa38efb5921ace958d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 2 Jun 2022 15:15:16 +0200 Subject: [PATCH 07/20] Consistent cancellation semantics for `coroutine()` --- src/functions.php | 8 +++---- tests/CoroutineTest.php | 47 +++++++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/functions.php b/src/functions.php index 95c0bee..122c656 100644 --- a/src/functions.php +++ b/src/functions.php @@ -233,12 +233,10 @@ function coroutine(callable $function, ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { - // cancel pending promise(s) as long as generator function keeps yielding - while ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { - $temp = $promise; - $promise = null; - $temp->cancel(); + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { + $promise->cancel(); } + $promise = null; }); /** @var callable $next */ diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 6e461d5..adc82bc 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -106,42 +106,53 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue( $promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer'))); } - - public function testCoroutineWillCancelPendingPromiseWhenCallingCancelOnResultingPromise() + public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects() { - $cancelled = 0; - $promise = coroutine(function () use (&$cancelled) { - yield new Promise(function () use (&$cancelled) { - ++$cancelled; + $promise = coroutine(function () { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); }); }); $promise->cancel(); - $this->assertEquals(1, $cancelled); + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); } - public function testCoroutineWillCancelAllPendingPromisesWhenFunctionContinuesToYieldWhenCallingCancelOnResultingPromise() + public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue() { $promise = coroutine(function () { - $promise = new Promise(function () { }, function () { - throw new \RuntimeException('Frist operation cancelled', 21); - }); - try { - yield $promise; + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + }); } catch (\RuntimeException $e) { - // ignore exception and continue + return 42; } + }); - yield new Promise(function () { }, function () { - throw new \RuntimeException('Second operation cancelled', 42); - }); + $promise->cancel(); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatYieldsSecondPromise() + { + $promise = coroutine(function () { + try { + yield new Promise(function () { }, function () { + throw new \RuntimeException('First operation cancelled'); + }); + } catch (\RuntimeException $e) { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Second operation never cancelled'); + }); + } }); $promise->cancel(); - $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Second operation cancelled', 42))); + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns() From 3c3b812be77aec14bf8300b052ba589c9a5bc95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 11 Jul 2022 16:17:23 +0200 Subject: [PATCH 08/20] Prepare v3.0.0 release --- CHANGELOG.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++---- README.md | 12 +++-------- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d17161..67f544d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,55 @@ -CHANGELOG -========= +# Changelog -* 1.0.0 (2013-02-07) +## 3.0.0 (2022-07-11) - * First tagged release +A major new feature release, see [**release announcement**](https://clue.engineering/2022/announcing-reactphp-async). + +* We'd like to emphasize that this component is production ready and battle-tested. + We plan to support all long-term support (LTS) releases for at least 24 months, + so you have a rock-solid foundation to build on top of. + +* The v4 release will be the way forward for this package. However, we will still + actively support v3 and v2 to provide a smooth upgrade path for those not yet + on PHP 8.1+. If you're using an older PHP version, you may use either version + which all provide a compatible API but may not take advantage of newer language + features. You may target multiple versions at the same time to support a wider range of + PHP versions: + + * [`4.x` branch](https://github.com/reactphp/async/tree/4.x) (PHP 8.1+) + * [`3.x` branch](https://github.com/reactphp/async/tree/3.x) (PHP 7.1+) + * [`2.x` branch](https://github.com/reactphp/async/tree/2.x) (PHP 5.3+) + +This update involves some major new features and a minor BC break over the +`v2.0.0` release. We've tried hard to avoid BC breaks where possible and +minimize impact otherwise. We expect that most consumers of this package will be +affected by BC breaks, but updating should take no longer than a few minutes. +See below for more details: + +* Feature / BC break: Require PHP 7.1+ and add type declarations. + (#11 by @clue) + +* Feature: Add Generator-based `coroutine()` function. + (#12, #13 and #54 by @clue) + +* Feature: Support iterable type for `parallel()` + `series()` + `waterfall()`. + (#45 by @clue) + +The following changes had to be ported to this release due to our branching +strategy, but also appeared in the `v2.0.0` release: + +* Feature: Only stop loop for `await()` if a pending promise resolves/rejects. + (#33 by @SimonFrings) + +* Feature: Forward compatibility with upcoming Promise v3. + (#47 by @clue) + +* Minor documentation improvements. + (#37 by @SimonFrings and #52 by @nhedger) + +## 2.0.0 (2022-07-11) + +See [`2.x` CHANGELOG](https://github.com/reactphp/async/blob/2.x/CHANGELOG.md) for more details. + +## 1.0.0 (2013-02-07) + +* First tagged release diff --git a/README.md b/README.md index 55e1568..df72988 100644 --- a/README.md +++ b/README.md @@ -329,11 +329,11 @@ React\Async\waterfall([ The recommended way to install this library is [through Composer](https://getcomposer.org/). [New to Composer?](https://getcomposer.org/doc/00-intro.md) -Once released, this project will follow [SemVer](https://semver.org/). -At the moment, this will install the latest development version: +This project follows [SemVer](https://semver.org/). +This will install the latest supported version from this branch: ```bash -composer require react/async:dev-main +composer require react/async:^3 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. @@ -342,12 +342,6 @@ This project aims to run on any platform and thus does not require any PHP extensions and supports running on PHP 7.1 through current PHP 8+. It's *highly recommended to use the latest supported PHP version* for this project. -We're committed to providing long-term support (LTS) options and to provide a -smooth upgrade path. If you're using an older PHP version, you may use the -[`2.x` branch](https://github.com/reactphp/async/tree/2.x) which provides a -compatible API but does not take advantage of newer language features. You may -target both versions at the same time to support a wider range of PHP versions. - ## Tests To run the test suite, you first need to clone this repo and then install all From 4aa50b21911c34f2ce62ffccde9017f458ad9a04 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Sun, 14 Aug 2022 00:47:27 +0200 Subject: [PATCH 09/20] Test on PHP 8.2 With PHP 8.2 coming out later this year, we should be reading for it's release to ensure all out code works on it. Refs: https://github.com/reactphp/event-loop/pull/258 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b1b36b..6ea7650 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: strategy: matrix: php: + - 8.2 - 8.1 - 8.0 - 7.4 From b63adfa99890a454190179dc282629aa68052f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 30 Oct 2022 13:16:12 +0100 Subject: [PATCH 10/20] Update test suite and report failed assertions --- .github/workflows/ci.yml | 5 +++-- composer.json | 2 +- phpunit.xml.dist | 17 +++++++++++++---- phpunit.xml.legacy | 10 +++++++++- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b1b36b..1c4c705 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: PHPUnit: name: PHPUnit (PHP ${{ matrix.php }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: php: @@ -18,11 +18,12 @@ jobs: - 7.2 - 7.1 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: xdebug + ini-file: development - run: composer install - run: vendor/bin/phpunit --coverage-text if: ${{ matrix.php >= 7.3 }} diff --git a/composer.json b/composer.json index 0be4bf5..2729de2 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^7.5" + "phpunit/phpunit": "^9.5 || ^7.5" }, "autoload": { "files": [ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fa88e7e..7a9577e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,11 +1,12 @@ - - + + convertDeprecationsToExceptions="true"> ./tests/ @@ -16,4 +17,12 @@ ./src/ + + + + + + + + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy index 2157b25..232aae2 100644 --- a/phpunit.xml.legacy +++ b/phpunit.xml.legacy @@ -1,6 +1,6 @@ - + ./src/ + + + + + + + + From cd0a864e027aac81aa183e4f35830c226d029ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 6 Dec 2022 16:04:28 +0100 Subject: [PATCH 11/20] Add new `delay()` function to delay program execution --- README.md | 69 ++++++++++++++++++++++++++++++++++++++ src/functions.php | 80 +++++++++++++++++++++++++++++++++++++++++++++ tests/DelayTest.php | 50 ++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 tests/DelayTest.php diff --git a/README.md b/README.md index df72988..1e49806 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ an event loop, it can be used with this library. * [Usage](#usage) * [await()](#await) * [coroutine()](#coroutine) + * [delay()](#delay) * [parallel()](#parallel) * [series()](#series) * [waterfall()](#waterfall) @@ -206,6 +207,74 @@ $promise->then(function (int $bytes) { }); ``` +### delay() + +The `delay(float $seconds): void` function can be used to +delay program execution for duration given in `$seconds`. + +```php +React\Async\delay($seconds); +``` + +This function will only return after the given number of `$seconds` have +elapsed. If there are no other events attached to this loop, it will behave +similar to PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php). + +```php +echo 'a'; +React\Async\delay(1.0); +echo 'b'; + +// prints "a" at t=0.0s +// prints "b" at t=1.0s +``` + +Unlike PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php), +this function may not necessarily halt execution of the entire process thread. +Instead, it allows the event loop to run any other events attached to the +same loop until the delay returns: + +```php +echo 'a'; +Loop::addTimer(1.0, function () { + echo 'b'; +}); +React\Async\delay(3.0); +echo 'c'; + +// prints "a" at t=0.0s +// prints "b" at t=1.0s +// prints "c" at t=3.0s +``` + +This behavior is especially useful if you want to delay the program execution +of a particular routine, such as when building a simple polling or retry +mechanism: + +```php +try { + something(); +} catch (Throwable $e) { + // in case of error, retry after a short delay + React\Async\delay(1.0); + something(); +} +``` + +Because this function only returns after some time has passed, it can be +considered *blocking* from the perspective of the calling code. While the +delay is running, this function will assume control over the event loop. +Internally, it will `run()` the [default loop](https://github.com/reactphp/event-loop#loop) +until the delay returns and then calls `stop()` to terminate execution of the +loop. This means this function is more suited for short-lived promise executions +when using promise-based APIs is not feasible. For long-running applications, +using promise-based APIs by leveraging chained `then()` calls is usually preferable. + +Internally, the `$seconds` argument will be used as a timer for the loop so that +it keeps running until this timer triggers. This implies that if you pass a +really small (or negative) value, it will still start a timer and will thus +trigger at the earliest possible time in the future. + ### parallel() The `parallel(iterable> $tasks): PromiseInterface,Exception>` function can be used diff --git a/src/functions.php b/src/functions.php index 122c656..8039266 100644 --- a/src/functions.php +++ b/src/functions.php @@ -4,6 +4,7 @@ use React\EventLoop\Loop; use React\Promise\Deferred; +use React\Promise\Promise; use React\Promise\PromiseInterface; use function React\Promise\reject; use function React\Promise\resolve; @@ -101,6 +102,85 @@ function ($error) use (&$exception, &$rejected, &$wait, &$loopStarted) { } +/** + * Delay program execution for duration given in `$seconds`. + * + * ```php + * React\Async\delay($seconds); + * ``` + * + * This function will only return after the given number of `$seconds` have + * elapsed. If there are no other events attached to this loop, it will behave + * similar to PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php). + * + * ```php + * echo 'a'; + * React\Async\delay(1.0); + * echo 'b'; + * + * // prints "a" at t=0.0s + * // prints "b" at t=1.0s + * ``` + * + * Unlike PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php), + * this function may not necessarily halt execution of the entire process thread. + * Instead, it allows the event loop to run any other events attached to the + * same loop until the delay returns: + * + * ```php + * echo 'a'; + * Loop::addTimer(1.0, function () { + * echo 'b'; + * }); + * React\Async\delay(3.0); + * echo 'c'; + * + * // prints "a" at t=0.0s + * // prints "b" at t=1.0s + * // prints "c" at t=3.0s + * ``` + * + * This behavior is especially useful if you want to delay the program execution + * of a particular routine, such as when building a simple polling or retry + * mechanism: + * + * ```php + * try { + * something(); + * } catch (Throwable $e) { + * // in case of error, retry after a short delay + * React\Async\delay(1.0); + * something(); + * } + * ``` + * + * Because this function only returns after some time has passed, it can be + * considered *blocking* from the perspective of the calling code. While the + * delay is running, this function will assume control over the event loop. + * Internally, it will `run()` the [default loop](https://github.com/reactphp/event-loop#loop) + * until the delay returns and then calls `stop()` to terminate execution of the + * loop. This means this function is more suited for short-lived promise executions + * when using promise-based APIs is not feasible. For long-running applications, + * using promise-based APIs by leveraging chained `then()` calls is usually preferable. + * + * Internally, the `$seconds` argument will be used as a timer for the loop so that + * it keeps running until this timer triggers. This implies that if you pass a + * really small (or negative) value, it will still start a timer and will thus + * trigger at the earliest possible time in the future. + * + * @param float $seconds + * @return void + * @uses await() + */ +function delay(float $seconds): void +{ + await(new Promise(function (callable $resolve) use ($seconds): void { + Loop::addTimer($seconds, function () use ($resolve): void { + $resolve(null); + }); + })); +} + /** * Execute a Generator-based coroutine to "await" promises. * diff --git a/tests/DelayTest.php b/tests/DelayTest.php new file mode 100644 index 0000000..d34c16e --- /dev/null +++ b/tests/DelayTest.php @@ -0,0 +1,50 @@ +assertEqualsWithDelta(0.02, $time, 0.01); + } + + public function testDelaySmallPeriodBlocksForCloseToZeroSeconds() + { + $time = microtime(true); + delay(0.000001); + $time = microtime(true) - $time; + + $this->assertLessThan(0.01, $time); + } + + public function testDelayNegativePeriodBlocksForCloseToZeroSeconds() + { + $time = microtime(true); + delay(-1); + $time = microtime(true) - $time; + + $this->assertLessThan(0.01, $time); + } + + public function testDelayRunsOtherEventsWhileWaiting() + { + $buffer = 'a'; + Loop::addTimer(0.001, function () use (&$buffer) { + $buffer .= 'c'; + }); + $buffer .= 'b'; + delay(0.002); + $buffer .= 'd'; + + $this->assertEquals('abcd', $buffer); + } +} From 9fb518d421718e091b68e77f5582a2e03f29cd6b Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 25 Jan 2023 08:12:24 +0100 Subject: [PATCH 12/20] Template params can only have one argument The fact that a promise can also be rejected with a Throwable and/or Exception is implied and there is no need to also define that here. Refs: https://github.com/reactphp/promise/pull/223 --- README.md | 6 +++--- src/functions.php | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1e49806..5c13bc5 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ trigger at the earliest possible time in the future. ### parallel() -The `parallel(iterable> $tasks): PromiseInterface,Exception>` function can be used +The `parallel(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -319,7 +319,7 @@ React\Async\parallel([ ### series() -The `series(iterable> $tasks): PromiseInterface,Exception>` function can be used +The `series(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -361,7 +361,7 @@ React\Async\series([ ### waterfall() -The `waterfall(iterable> $tasks): PromiseInterface` function can be used +The `waterfall(iterable> $tasks): PromiseInterface` function can be used like this: ```php diff --git a/src/functions.php b/src/functions.php index 8039266..28c16d0 100644 --- a/src/functions.php +++ b/src/functions.php @@ -359,8 +359,8 @@ function coroutine(callable $function, ...$args): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface,Exception> + * @param iterable> $tasks + * @return PromiseInterface> */ function parallel(iterable $tasks): PromiseInterface { @@ -418,8 +418,8 @@ function parallel(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface,Exception> + * @param iterable> $tasks + * @return PromiseInterface> */ function series(iterable $tasks): PromiseInterface { @@ -469,8 +469,8 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface + * @param iterable> $tasks + * @return PromiseInterface */ function waterfall(iterable $tasks): PromiseInterface { From 303107b1a7ae8be1cfd4dde62cf3e8acd6514afd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 26 Oct 2022 11:26:02 +0200 Subject: [PATCH 13/20] Add PHPStan to test environment --- .gitattributes | 1 + .github/workflows/ci.yml | 22 ++++++++++++++++++++++ README.md | 6 ++++++ composer.json | 1 + phpstan.neon.dist | 11 +++++++++++ src/functions.php | 4 ++-- tests/CoroutineTest.php | 4 ++++ tests/ParallelTest.php | 1 + tests/SeriesTest.php | 1 + tests/WaterfallTest.php | 1 + 10 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 phpstan.neon.dist diff --git a/.gitattributes b/.gitattributes index 21be40c..5d5606d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ /.gitattributes export-ignore /.github/ export-ignore /.gitignore export-ignore +/phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /phpunit.xml.legacy export-ignore /tests/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71802dd..43c9cb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,25 @@ jobs: if: ${{ matrix.php >= 7.3 }} - run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy if: ${{ matrix.php < 7.3 }} + + PHPStan: + name: PHPStan (PHP ${{ matrix.php }}) + runs-on: ubuntu-22.04 + strategy: + matrix: + php: + - 8.2 + - 8.1 + - 8.0 + - 7.4 + - 7.3 + - 7.2 + - 7.1 + steps: + - uses: actions/checkout@v3 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer install + - run: vendor/bin/phpstan diff --git a/README.md b/README.md index 5c13bc5..3c82749 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,12 @@ To run the test suite, go to the project root and run: vendor/bin/phpunit ``` +On top of this, we use PHPStan on level 3 to ensure type safety across the project: + +```bash +vendor/bin/phpstan +``` + ## License MIT, see [LICENSE file](LICENSE). diff --git a/composer.json b/composer.json index 2729de2..e208dd3 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { + "phpstan/phpstan": "1.10.18 || 1.4.10", "phpunit/phpunit": "^9.5 || ^7.5" }, "autoload": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..903fb1f --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + level: 3 + + paths: + - src/ + - tests/ + + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # ignore generic usage like `PromiseInterface` until fixed upstream + - '/^PHPDoc .* contains generic type React\\Promise\\PromiseInterface<.+> but interface React\\Promise\\PromiseInterface is not generic\.$/' diff --git a/src/functions.php b/src/functions.php index 28c16d0..7d5b7b9 100644 --- a/src/functions.php +++ b/src/functions.php @@ -294,7 +294,7 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(...$args):\Generator $function + * @param callable(mixed ...$args):\Generator $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is * @return PromiseInterface * @since 3.0.0 @@ -437,9 +437,9 @@ function series(iterable $tasks): PromiseInterface assert($tasks instanceof \Iterator); } - /** @var callable():void $next */ $taskCallback = function ($result) use (&$results, &$next) { $results[] = $result; + assert($next instanceof \Closure); $next(); }; diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index adc82bc..5ec4cde 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -114,6 +114,7 @@ public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendin }); }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); @@ -131,6 +132,7 @@ public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendi } }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then($this->expectCallableOnceWith(42)); @@ -150,6 +152,7 @@ public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPro } }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then($this->expectCallableNever(), $this->expectCallableNever()); @@ -209,6 +212,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject }); }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); unset($promise); diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 1a5759b..98bbce2 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -193,6 +193,7 @@ function () use (&$cancelled) { ); $promise = React\Async\parallel($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(2, $cancelled); diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 404c907..0bc5017 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -185,6 +185,7 @@ function () use (&$cancelled) { ); $promise = React\Async\series($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(1, $cancelled); diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 2fbbc23..d2f947f 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -199,6 +199,7 @@ function () use (&$cancelled) { ); $promise = React\Async\waterfall($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(1, $cancelled); From 9b92ce5238dbacc46733b2fbc418293d3b038333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 26 Oct 2022 12:37:20 +0200 Subject: [PATCH 14/20] Improve type definitions and update to PHPStan level `max` --- README.md | 2 +- phpstan.neon.dist | 2 +- src/functions.php | 18 +++++++++++++++--- tests/AwaitTest.php | 28 +++++++++++++-------------- tests/CoroutineTest.php | 42 ++++++++++++++++++++--------------------- tests/DelayTest.php | 8 ++++---- tests/ParallelTest.php | 25 ++++++++++++------------ tests/SeriesTest.php | 26 +++++++++++++------------ tests/TestCase.php | 28 +++++++++++++-------------- tests/Timer.php | 17 +++++++++++------ tests/WaterfallTest.php | 26 +++++++++++++------------ 11 files changed, 121 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 3c82749..1453a09 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,7 @@ To run the test suite, go to the project root and run: vendor/bin/phpunit ``` -On top of this, we use PHPStan on level 3 to ensure type safety across the project: +On top of this, we use PHPStan on max level to ensure type safety across the project: ```bash vendor/bin/phpstan diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 903fb1f..b7f8ddb 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ parameters: - level: 3 + level: max paths: - src/ diff --git a/src/functions.php b/src/functions.php index 7d5b7b9..0096496 100644 --- a/src/functions.php +++ b/src/functions.php @@ -56,6 +56,8 @@ function await(PromiseInterface $promise) $resolved = null; $exception = null; $rejected = false; + + /** @var bool $loopStarted */ $loopStarted = false; $promise->then( @@ -294,7 +296,7 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(mixed ...$args):\Generator $function + * @param callable(mixed ...$args):(\Generator|mixed) $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is * @return PromiseInterface * @since 3.0.0 @@ -313,6 +315,7 @@ function coroutine(callable $function, ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { + /** @var ?PromiseInterface $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } @@ -333,6 +336,7 @@ function coroutine(callable $function, ...$args): PromiseInterface return; } + /** @var mixed $promise */ $promise = $generator->current(); if (!$promise instanceof PromiseInterface) { $next = null; @@ -342,6 +346,7 @@ function coroutine(callable $function, ...$args): PromiseInterface return; } + assert($next instanceof \Closure); $promise->then(function ($value) use ($generator, $next) { $generator->send($value); $next(); @@ -364,6 +369,7 @@ function coroutine(callable $function, ...$args): PromiseInterface */ function parallel(iterable $tasks): PromiseInterface { + /** @var array $pending */ $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { @@ -425,6 +431,7 @@ function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -439,7 +446,7 @@ function series(iterable $tasks): PromiseInterface $taskCallback = function ($result) use (&$results, &$next) { $results[] = $result; - assert($next instanceof \Closure); + /** @var \Closure $next */ $next(); }; @@ -453,9 +460,11 @@ function series(iterable $tasks): PromiseInterface $task = $tasks->current(); $tasks->next(); } else { + assert(\is_array($tasks)); $task = \array_shift($tasks); } + assert(\is_callable($task)); $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending = $promise; @@ -469,13 +478,14 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks + * @param iterable<(callable():PromiseInterface)|(callable(mixed):PromiseInterface)> $tasks * @return PromiseInterface */ function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -498,9 +508,11 @@ function waterfall(iterable $tasks): PromiseInterface $task = $tasks->current(); $tasks->next(); } else { + assert(\is_array($tasks)); $task = \array_shift($tasks); } + assert(\is_callable($task)); $promise = \call_user_func_array($task, func_get_args()); assert($promise instanceof PromiseInterface); $pending = $promise; diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index da9079a..397a0fa 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -8,7 +8,7 @@ class AwaitTest extends TestCase { - public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException() + public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException(): void { $promise = new Promise(function () { throw new \Exception('test'); @@ -19,7 +19,7 @@ public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException() React\Async\await($promise); } - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse() + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse(): void { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -34,7 +34,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith React\Async\await($promise); } - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull() + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull(): void { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -49,7 +49,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith React\Async\await($promise); } - public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() + public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError(): void { $promise = new Promise(function ($_, $reject) { throw new \Error('Test', 42); @@ -61,7 +61,7 @@ public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() React\Async\await($promise); } - public function testAwaitReturnsValueWhenPromiseIsFullfilled() + public function testAwaitReturnsValueWhenPromiseIsFullfilled(): void { $promise = new Promise(function ($resolve) { $resolve(42); @@ -70,7 +70,7 @@ public function testAwaitReturnsValueWhenPromiseIsFullfilled() $this->assertEquals(42, React\Async\await($promise)); } - public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerStopsLoop() + public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerStopsLoop(): void { $promise = new Promise(function ($resolve) { Loop::addTimer(0.02, function () use ($resolve) { @@ -84,7 +84,7 @@ public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerSto $this->assertEquals(2, React\Async\await($promise)); } - public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutRunningLoop() + public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutRunningLoop(): void { $now = true; @@ -100,7 +100,7 @@ public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutRunningLoop $this->assertTrue($now); } - public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutStoppingLoop() + public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutStoppingLoop(): void { $ticks = 0; @@ -128,7 +128,7 @@ public function testAwaitWithAlreadyFulfilledPromiseWillReturnWithoutStoppingLoo $this->assertEquals(2, $ticks); } - public function testAwaitWithPendingPromiseThatWillResolveWillStopLoopBeforeLastTimerFinishes() + public function testAwaitWithPendingPromiseThatWillResolveWillStopLoopBeforeLastTimerFinishes(): void { $promise = new Promise(function ($resolve) { Loop::addTimer(0.02, function () use ($resolve) { @@ -159,7 +159,7 @@ public function testAwaitWithPendingPromiseThatWillResolveWillStopLoopBeforeLast $this->assertEquals(1, $ticks); } - public function testAwaitWithAlreadyRejectedPromiseWillReturnWithoutStoppingLoop() + public function testAwaitWithAlreadyRejectedPromiseWillReturnWithoutStoppingLoop(): void { $ticks = 0; @@ -191,7 +191,7 @@ public function testAwaitWithAlreadyRejectedPromiseWillReturnWithoutStoppingLoop $this->assertEquals(2, $ticks); } - public function testAwaitWithPendingPromiseThatWillRejectWillStopLoopBeforeLastTimerFinishes() + public function testAwaitWithPendingPromiseThatWillRejectWillStopLoopBeforeLastTimerFinishes(): void { $promise = new Promise(function ($_, $reject) { Loop::addTimer(0.02, function () use (&$reject) { @@ -227,7 +227,7 @@ public function testAwaitWithPendingPromiseThatWillRejectWillStopLoopBeforeLastT $this->assertEquals(1, $ticks); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -244,7 +244,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() + public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -265,7 +265,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue() + public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue(): void { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 5ec4cde..c9b7439 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -9,7 +9,7 @@ class CoroutineTest extends TestCase { - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGenerator() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGenerator(): void { $promise = coroutine(function () { return 42; @@ -18,10 +18,10 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGene $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately(): void { $promise = coroutine(function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return 42; @@ -30,7 +30,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise(): void { $promise = coroutine(function () { $value = yield resolve(42); @@ -40,7 +40,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator(): void { $promise = coroutine(function () { throw new \RuntimeException('Foo'); @@ -49,10 +49,10 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenera $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately(): void { $promise = coroutine(function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } throw new \RuntimeException('Foo'); @@ -61,7 +61,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise(): void { $promise = coroutine(function () { $reason = yield resolve('Foo'); @@ -71,7 +71,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYielding $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise(): void { $promise = coroutine(function () { try { @@ -84,7 +84,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYielding $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise(): void { $promise = coroutine(function () { try { @@ -97,7 +97,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue() + public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void { $promise = coroutine(function () { yield 42; @@ -106,7 +106,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue( $promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer'))); } - public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects() + public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects(): void { $promise = coroutine(function () { yield new Promise(function () { }, function () { @@ -120,7 +120,7 @@ public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendin $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); } - public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue() + public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue(): void { $promise = coroutine(function () { try { @@ -138,7 +138,7 @@ public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendi $promise->then($this->expectCallableOnceWith(42)); } - public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatYieldsSecondPromise() + public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatYieldsSecondPromise(): void { $promise = coroutine(function () { try { @@ -158,7 +158,7 @@ public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPro $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -168,7 +168,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet gc_collect_cycles(); $promise = coroutine(function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return 42; @@ -179,7 +179,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately() + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -198,7 +198,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation() + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -219,7 +219,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -229,7 +229,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThr $promise = coroutine(function () { throw new \RuntimeException('Failed', 42); - yield; + yield; // @phpstan-ignore-line }); unset($promise); @@ -237,7 +237,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThr $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); diff --git a/tests/DelayTest.php b/tests/DelayTest.php index d34c16e..0091943 100644 --- a/tests/DelayTest.php +++ b/tests/DelayTest.php @@ -8,7 +8,7 @@ class DelayTest extends TestCase { - public function testDelayBlocksForGivenPeriod() + public function testDelayBlocksForGivenPeriod(): void { $time = microtime(true); delay(0.02); @@ -17,7 +17,7 @@ public function testDelayBlocksForGivenPeriod() $this->assertEqualsWithDelta(0.02, $time, 0.01); } - public function testDelaySmallPeriodBlocksForCloseToZeroSeconds() + public function testDelaySmallPeriodBlocksForCloseToZeroSeconds(): void { $time = microtime(true); delay(0.000001); @@ -26,7 +26,7 @@ public function testDelaySmallPeriodBlocksForCloseToZeroSeconds() $this->assertLessThan(0.01, $time); } - public function testDelayNegativePeriodBlocksForCloseToZeroSeconds() + public function testDelayNegativePeriodBlocksForCloseToZeroSeconds(): void { $time = microtime(true); delay(-1); @@ -35,7 +35,7 @@ public function testDelayNegativePeriodBlocksForCloseToZeroSeconds() $this->assertLessThan(0.01, $time); } - public function testDelayRunsOtherEventsWhileWaiting() + public function testDelayRunsOtherEventsWhileWaiting(): void { $buffer = 'a'; Loop::addTimer(0.001, function () use (&$buffer) { diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 98bbce2..c1ed553 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -6,10 +6,11 @@ use React\EventLoop\Loop; use React\Promise\Promise; use function React\Promise\reject; +use function React\Promise\resolve; class ParallelTest extends TestCase { - public function testParallelWithoutTasks() + public function testParallelWithoutTasks(): void { $tasks = array(); @@ -18,11 +19,11 @@ public function testParallelWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } - public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray(): void { $tasks = (function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield function () { return resolve(null); }; } })(); @@ -31,7 +32,7 @@ public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray $promise->then($this->expectCallableOnceWith([])); } - public function testParallelWithTasks() + public function testParallelWithTasks(): void { $tasks = array( function () { @@ -63,7 +64,7 @@ function () { $timer->assertInRange(0.1, 0.2); } - public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues(): void { $tasks = (function () { yield function () { @@ -95,7 +96,7 @@ public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillment $timer->assertInRange(0.1, 0.2); } - public function testParallelWithErrorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testParallelWithErrorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; @@ -127,12 +128,12 @@ function () use (&$called) { $this->assertSame(2, $called); } - public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; $tasks = (function () use (&$called) { - while (true) { + while (true) { // @phpstan-ignore-line yield function () use (&$called) { return reject(new \RuntimeException('Rejected ' . ++$called)); }; @@ -146,7 +147,7 @@ public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejected $this->assertSame(1, $called); } - public function testParallelWithErrorWillCancelPendingPromises() + public function testParallelWithErrorWillCancelPendingPromises(): void { $cancelled = 0; @@ -175,7 +176,7 @@ function () use (&$cancelled) { $this->assertSame(1, $cancelled); } - public function testParallelWillCancelPendingPromisesWhenCallingCancelOnResultingPromise() + public function testParallelWillCancelPendingPromisesWhenCallingCancelOnResultingPromise(): void { $cancelled = 0; @@ -199,7 +200,7 @@ function () use (&$cancelled) { $this->assertSame(2, $cancelled); } - public function testParallelWithDelayedErrorReturnsPromiseRejectedWithExceptionFromTask() + public function testParallelWithDelayedErrorReturnsPromiseRejectedWithExceptionFromTask(): void { $called = 0; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 0bc5017..25aa104 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -6,10 +6,11 @@ use React\EventLoop\Loop; use React\Promise\Promise; use function React\Promise\reject; +use function React\Promise\resolve; class SeriesTest extends TestCase { - public function testSeriesWithoutTasks() + public function testSeriesWithoutTasks(): void { $tasks = array(); @@ -18,11 +19,11 @@ public function testSeriesWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } - public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray(): void { $tasks = (function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield function () { return resolve(null); }; } })(); @@ -31,7 +32,7 @@ public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() $promise->then($this->expectCallableOnceWith([])); } - public function testSeriesWithTasks() + public function testSeriesWithTasks(): void { $tasks = array( function () { @@ -63,7 +64,7 @@ function () { $timer->assertInRange(0.10, 0.20); } - public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues(): void { $tasks = (function () { yield function () { @@ -95,7 +96,7 @@ public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentVa $timer->assertInRange(0.10, 0.20); } - public function testSeriesWithError() + public function testSeriesWithError(): void { $called = 0; @@ -126,12 +127,12 @@ function () use (&$called) { $this->assertSame(1, $called); } - public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; $tasks = (function () use (&$called) { - while (true) { + while (true) { // @phpstan-ignore-line yield function () use (&$called) { return reject(new \RuntimeException('Rejected ' . ++$called)); }; @@ -145,14 +146,15 @@ public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWi $this->assertSame(1, $called); } - public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $tasks = new class() implements \IteratorAggregate { + /** @var int */ public $called = 0; public function getIterator(): \Iterator { - while (true) { + while (true) { // @phpstan-ignore-line yield function () { return reject(new \RuntimeException('Rejected ' . ++$this->called)); }; @@ -167,7 +169,7 @@ public function getIterator(): \Iterator $this->assertSame(1, $tasks->called); } - public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() + public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise(): void { $cancelled = 0; diff --git a/tests/TestCase.php b/tests/TestCase.php index ee0f476..c7bf729 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,45 +3,43 @@ namespace React\Tests\Async; use PHPUnit\Framework\MockObject\MockBuilder; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase as BaseTestCase; class TestCase extends BaseTestCase { - protected function expectCallableOnce() + protected function expectCallableOnce(): callable { $mock = $this->createCallableMock(); - $mock - ->expects($this->once()) - ->method('__invoke'); + $mock->expects($this->once())->method('__invoke'); + assert(is_callable($mock)); return $mock; } - protected function expectCallableOnceWith($value) + /** @param mixed $value */ + protected function expectCallableOnceWith($value): callable { $mock = $this->createCallableMock(); - $mock - ->expects($this->once()) - ->method('__invoke') - ->with($value); + $mock->expects($this->once())->method('__invoke')->with($value); + assert(is_callable($mock)); return $mock; } - protected function expectCallableNever() + protected function expectCallableNever(): callable { $mock = $this->createCallableMock(); - $mock - ->expects($this->never()) - ->method('__invoke'); + $mock->expects($this->never())->method('__invoke'); + assert(is_callable($mock)); return $mock; } - protected function createCallableMock() + protected function createCallableMock(): MockObject { if (method_exists(MockBuilder::class, 'addMethods')) { - // PHPUnit 9+ + // @phpstan-ignore-next-line PHPUnit 9+ return $this->getMockBuilder(\stdClass::class)->addMethods(['__invoke'])->getMock(); } else { // PHPUnit < 9 diff --git a/tests/Timer.php b/tests/Timer.php index 0a37a73..3e381cc 100644 --- a/tests/Timer.php +++ b/tests/Timer.php @@ -4,8 +4,13 @@ class Timer { + /** @var TestCase */ private $testCase; + + /** @var float */ private $start; + + /** @var float */ private $stop; public function __construct(TestCase $testCase) @@ -13,32 +18,32 @@ public function __construct(TestCase $testCase) $this->testCase = $testCase; } - public function start() + public function start(): void { $this->start = microtime(true); } - public function stop() + public function stop(): void { $this->stop = microtime(true); } - public function getInterval() + public function getInterval(): float { return $this->stop - $this->start; } - public function assertLessThan($milliseconds) + public function assertLessThan(float $milliseconds): void { $this->testCase->assertLessThan($milliseconds, $this->getInterval()); } - public function assertGreaterThan($milliseconds) + public function assertGreaterThan(float $milliseconds): void { $this->testCase->assertGreaterThan($milliseconds, $this->getInterval()); } - public function assertInRange($minMs, $maxMs) + public function assertInRange(float $minMs, float $maxMs): void { $this->assertGreaterThan($minMs); $this->assertLessThan($maxMs); diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index d2f947f..8aa6c6f 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -6,10 +6,11 @@ use React\EventLoop\Loop; use React\Promise\Promise; use function React\Promise\reject; +use function React\Promise\resolve; class WaterfallTest extends TestCase { - public function testWaterfallWithoutTasks() + public function testWaterfallWithoutTasks(): void { $tasks = array(); @@ -18,11 +19,11 @@ public function testWaterfallWithoutTasks() $promise->then($this->expectCallableOnceWith(null)); } - public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull() + public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull(): void { $tasks = (function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield function () { return resolve(null); }; } })(); @@ -31,7 +32,7 @@ public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull() $promise->then($this->expectCallableOnceWith(null)); } - public function testWaterfallWithTasks() + public function testWaterfallWithTasks(): void { $tasks = array( function ($foo = 'foo') { @@ -70,7 +71,7 @@ function ($bar) { $timer->assertInRange(0.15, 0.30); } - public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentValue() + public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentValue(): void { $tasks = (function () { yield function ($foo = 'foo') { @@ -109,7 +110,7 @@ public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentV $timer->assertInRange(0.15, 0.30); } - public function testWaterfallWithError() + public function testWaterfallWithError(): void { $called = 0; @@ -140,12 +141,12 @@ function () use (&$called) { $this->assertSame(1, $called); } - public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; $tasks = (function () use (&$called) { - while (true) { + while (true) { // @phpstan-ignore-line yield function () use (&$called) { return reject(new \RuntimeException('Rejected ' . ++$called)); }; @@ -159,14 +160,15 @@ public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejecte $this->assertSame(1, $called); } - public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $tasks = new class() implements \IteratorAggregate { + /** @var int */ public $called = 0; public function getIterator(): \Iterator { - while (true) { + while (true) { // @phpstan-ignore-line yield function () { return reject(new \RuntimeException('Rejected ' . ++$this->called)); }; @@ -181,7 +183,7 @@ public function getIterator(): \Iterator $this->assertSame(1, $tasks->called); } - public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() + public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise(): void { $cancelled = 0; From b51cc55b4004fe0e69da95d178d1cfb0334cd6ce Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Wed, 21 Jun 2023 11:29:01 +0200 Subject: [PATCH 15/20] Prepare v3.1.0 release --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f544d..8e839e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 3.1.0 (2023-06-22) + +* Feature: Add new `delay()` function to delay program execution. + (#71 by @clue) + + ```php + echo 'a'; + Loop::addTimer(1.0, function () { + echo 'b'; + }); + React\Async\delay(3.0); + echo 'c'; + + // prints "a" at t=0.0s + // prints "b" at t=1.0s + // prints "c" at t=3.0s + ``` + +* Update test suite, add PHPStan with `max` level and report failed assertions. + (#67 and #77 by @clue and #60 and #74 by @WyriHaximus) + ## 3.0.0 (2022-07-11) A major new feature release, see [**release announcement**](https://clue.engineering/2022/announcing-reactphp-async). diff --git a/README.md b/README.md index 1453a09..8860737 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version from this branch: ```bash -composer require react/async:^3 +composer require react/async:^3.1 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 0b30992fbc59eb459cc5c8d62f7b7bfed321d472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 6 Jul 2023 09:58:33 +0200 Subject: [PATCH 16/20] Update test suite to avoid unhandled promise rejections --- tests/CoroutineTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index c9b7439..2c674c5 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -193,6 +193,8 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject }); }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); @@ -232,6 +234,8 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThr yield; // @phpstan-ignore-line }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); @@ -249,6 +253,8 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYie yield 42; }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); From d1c960680672af155c54f728522b48fb9da608cd Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Thu, 24 Mar 2022 08:03:54 +0100 Subject: [PATCH 17/20] Add template annotations These annotations will aid static analyses like PHPStan and Psalm to enhance type-safety for this project and projects depending on it These changes make the following example understandable by PHPStan: ```php final readonly class User { public function __construct( public string $name, ) } /** * \React\Promise\PromiseInterface */ function getCurrentUserFromDatabase(): \React\Promise\PromiseInterface { // The following line would do the database query and fetch the result from it // but keeping it simple for the sake of the example. return \React\Promise\resolve(new User('WyriHaximus')); } // For the sake of this example we're going to assume the following code runs // in \React\Async\async call echo await(getCurrentUserFromDatabase())->name; // This echos: WyriHaximus ``` --- .github/workflows/ci.yml | 1 - README.md | 10 +++---- src/functions.php | 44 +++++++++++++++++----------- tests/CoroutineTest.php | 10 +++---- tests/ParallelTest.php | 3 ++ tests/SeriesTest.php | 6 ++++ tests/WaterfallTest.php | 6 ++++ tests/types/await.php | 18 ++++++++++++ tests/types/coroutine.php | 60 +++++++++++++++++++++++++++++++++++++++ tests/types/parallel.php | 33 +++++++++++++++++++++ tests/types/series.php | 33 +++++++++++++++++++++ tests/types/waterfall.php | 42 +++++++++++++++++++++++++++ 12 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 tests/types/await.php create mode 100644 tests/types/coroutine.php create mode 100644 tests/types/parallel.php create mode 100644 tests/types/series.php create mode 100644 tests/types/waterfall.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43c9cb3..40fdcf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,6 @@ jobs: - 7.4 - 7.3 - 7.2 - - 7.1 steps: - uses: actions/checkout@v3 - uses: shivammathur/setup-php@v2 diff --git a/README.md b/README.md index 8860737..dc2d306 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Async\await(…); ### await() -The `await(PromiseInterface $promise): mixed` function can be used to +The `await(PromiseInterface $promise): T` function can be used to block waiting for the given `$promise` to be fulfilled. ```php @@ -94,7 +94,7 @@ try { ### coroutine() -The `coroutine(callable $function, mixed ...$args): PromiseInterface` function can be used to +The `coroutine(callable(mixed ...$args):(\Generator|PromiseInterface|T) $function, mixed ...$args): PromiseInterface` function can be used to execute a Generator-based coroutine to "await" promises. ```php @@ -277,7 +277,7 @@ trigger at the earliest possible time in the future. ### parallel() -The `parallel(iterable> $tasks): PromiseInterface>` function can be used +The `parallel(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -319,7 +319,7 @@ React\Async\parallel([ ### series() -The `series(iterable> $tasks): PromiseInterface>` function can be used +The `series(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -361,7 +361,7 @@ React\Async\series([ ### waterfall() -The `waterfall(iterable> $tasks): PromiseInterface` function can be used +The `waterfall(iterable> $tasks): PromiseInterface` function can be used like this: ```php diff --git a/src/functions.php b/src/functions.php index 0096496..3ef0e45 100644 --- a/src/functions.php +++ b/src/functions.php @@ -44,8 +44,9 @@ * } * ``` * - * @param PromiseInterface $promise - * @return mixed returns whatever the promise resolves to + * @template T + * @param PromiseInterface $promise + * @return T returns whatever the promise resolves to * @throws \Exception when the promise is rejected with an `Exception` * @throws \Throwable when the promise is rejected with a `Throwable` * @throws \UnexpectedValueException when the promise is rejected with an unexpected value (Promise API v1 or v2 only) @@ -93,13 +94,14 @@ function ($error) use (&$exception, &$rejected, &$wait, &$loopStarted) { // promise is rejected with an unexpected value (Promise API v1 or v2 only) if (!$exception instanceof \Throwable) { $exception = new \UnexpectedValueException( - 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) + 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) // @phpstan-ignore-line ); } throw $exception; } + /** @var T $resolved */ return $resolved; } @@ -296,9 +298,16 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(mixed ...$args):(\Generator|mixed) $function + * @template T + * @template TYield + * @template A1 (any number of function arguments, see https://github.com/phpstan/phpstan/issues/8214) + * @template A2 + * @template A3 + * @template A4 + * @template A5 + * @param callable(A1, A2, A3, A4, A5):(\Generator, TYield, PromiseInterface|T>|PromiseInterface|T) $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is - * @return PromiseInterface + * @return PromiseInterface * @since 3.0.0 */ function coroutine(callable $function, ...$args): PromiseInterface @@ -315,7 +324,7 @@ function coroutine(callable $function, ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { - /** @var ?PromiseInterface $promise */ + /** @var ?PromiseInterface $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } @@ -336,7 +345,6 @@ function coroutine(callable $function, ...$args): PromiseInterface return; } - /** @var mixed $promise */ $promise = $generator->current(); if (!$promise instanceof PromiseInterface) { $next = null; @@ -346,6 +354,7 @@ function coroutine(callable $function, ...$args): PromiseInterface return; } + /** @var PromiseInterface $promise */ assert($next instanceof \Closure); $promise->then(function ($value) use ($generator, $next) { $generator->send($value); @@ -364,12 +373,13 @@ function coroutine(callable $function, ...$args): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ function parallel(iterable $tasks): PromiseInterface { - /** @var array $pending */ + /** @var array> $pending */ $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { @@ -424,14 +434,15 @@ function parallel(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -478,14 +489,15 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable<(callable():PromiseInterface)|(callable(mixed):PromiseInterface)> $tasks - * @return PromiseInterface + * @template T + * @param iterable<(callable():(PromiseInterface|T))|(callable(mixed):(PromiseInterface|T))> $tasks + * @return PromiseInterface<($tasks is non-empty-array|\Traversable ? T : null)> */ function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 2c674c5..1df4cdc 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -22,7 +22,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -53,7 +53,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } throw new \RuntimeException('Foo'); }); @@ -99,7 +99,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void { - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); @@ -169,7 +169,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -249,7 +249,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYie gc_collect_cycles(); - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index c1ed553..57e1604 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -12,6 +12,9 @@ class ParallelTest extends TestCase { public function testParallelWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\parallel($tasks); diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 25aa104..99dfd0c 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -12,6 +12,9 @@ class SeriesTest extends TestCase { public function testSeriesWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\series($tasks); @@ -152,6 +155,9 @@ public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRe /** @var int */ public $called = 0; + /** + * @return \Iterator> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 8aa6c6f..c7140d4 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -12,6 +12,9 @@ class WaterfallTest extends TestCase { public function testWaterfallWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\waterfall($tasks); @@ -166,6 +169,9 @@ public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromis /** @var int */ public $called = 0; + /** + * @return \Iterator> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/types/await.php b/tests/types/await.php new file mode 100644 index 0000000..462d99a --- /dev/null +++ b/tests/types/await.php @@ -0,0 +1,18 @@ +name = $name; + } +} + +assertType('string', await(resolve(new AwaitExampleUser('WyriHaximus')))->name); diff --git a/tests/types/coroutine.php b/tests/types/coroutine.php new file mode 100644 index 0000000..68bebd4 --- /dev/null +++ b/tests/types/coroutine.php @@ -0,0 +1,60 @@ +', coroutine(static function () { + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + return resolve(true); +})); + +// assertType('React\Promise\PromiseInterface', coroutine(static function () { +// return (yield resolve(true)); +// })); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + + return time(); +})); + +// assertType('React\Promise\PromiseInterface', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + +// return $bool; +// })); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + yield resolve(time()); + + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + for ($i = 0; $i <= 10; $i++) { + yield resolve($i); + } + + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static function (int $a): int { return $a; }, 42)); +assertType('React\Promise\PromiseInterface', coroutine(static function (int $a, int $b): int { return $a + $b; }, 10, 32)); +assertType('React\Promise\PromiseInterface', coroutine(static function (int $a, int $b, int $c): int { return $a + $b + $c; }, 10, 22, 10)); +assertType('React\Promise\PromiseInterface', coroutine(static function (int $a, int $b, int $c, int $d): int { return $a + $b + $c + $d; }, 10, 22, 5, 5)); +assertType('React\Promise\PromiseInterface', coroutine(static function (int $a, int $b, int $c, int $d, int $e): int { return $a + $b + $c + $d + $e; }, 10, 12, 10, 5, 5)); + +assertType('bool', await(coroutine(static function () { + return true; +}))); + +// assertType('bool', await(coroutine(static function () { +// return (yield resolve(true)); +// }))); diff --git a/tests/types/parallel.php b/tests/types/parallel.php new file mode 100644 index 0000000..02eae2f --- /dev/null +++ b/tests/types/parallel.php @@ -0,0 +1,33 @@ +', parallel([])); + +assertType('React\Promise\PromiseInterface>', parallel([ + static function (): PromiseInterface { return resolve(true); }, + static function (): PromiseInterface { return resolve(time()); }, + static function (): PromiseInterface { return resolve(microtime(true)); }, +])); + +assertType('React\Promise\PromiseInterface>', parallel([ + static function (): bool { return true; }, + static function (): int { return time(); }, + static function (): float { return microtime(true); }, +])); + +assertType('array', await(parallel([ + static function (): PromiseInterface { return resolve(true); }, + static function (): PromiseInterface { return resolve(time()); }, + static function (): PromiseInterface { return resolve(microtime(true)); }, +]))); + +assertType('array', await(parallel([ + static function (): bool { return true; }, + static function (): int { return time(); }, + static function (): float { return microtime(true); }, +]))); diff --git a/tests/types/series.php b/tests/types/series.php new file mode 100644 index 0000000..aa68223 --- /dev/null +++ b/tests/types/series.php @@ -0,0 +1,33 @@ +', series([])); + +assertType('React\Promise\PromiseInterface>', series([ + static function (): PromiseInterface { return resolve(true); }, + static function (): PromiseInterface { return resolve(time()); }, + static function (): PromiseInterface { return resolve(microtime(true)); }, +])); + +assertType('React\Promise\PromiseInterface>', series([ + static function (): bool { return true; }, + static function (): int { return time(); }, + static function (): float { return microtime(true); }, +])); + +assertType('array', await(series([ + static function (): PromiseInterface { return resolve(true); }, + static function (): PromiseInterface { return resolve(time()); }, + static function (): PromiseInterface { return resolve(microtime(true)); }, +]))); + +assertType('array', await(series([ + static function (): bool { return true; }, + static function (): int { return time(); }, + static function (): float { return microtime(true); }, +]))); diff --git a/tests/types/waterfall.php b/tests/types/waterfall.php new file mode 100644 index 0000000..00acb13 --- /dev/null +++ b/tests/types/waterfall.php @@ -0,0 +1,42 @@ +', waterfall([])); + +assertType('React\Promise\PromiseInterface', waterfall([ + static function (): PromiseInterface { return resolve(microtime(true)); }, +])); + +assertType('React\Promise\PromiseInterface', waterfall([ + static function (): float { return microtime(true); }, +])); + +// Desired, but currently unsupported with the current set of templates +//assertType('React\Promise\PromiseInterface', waterfall([ +// static function (): PromiseInterface { return resolve(true); }, +// static function (bool $bool): PromiseInterface { return resolve(time()); }, +// static function (int $int): PromiseInterface { return resolve(microtime(true)); }, +//])); + +assertType('float', await(waterfall([ + static function (): PromiseInterface { return resolve(microtime(true)); }, +]))); + +// Desired, but currently unsupported with the current set of templates +//assertType('float', await(waterfall([ +// static function (): PromiseInterface { return resolve(true); }, +// static function (bool $bool): PromiseInterface { return resolve(time()); }, +// static function (int $int): PromiseInterface { return resolve(microtime(true)); }, +//]))); + +// assertType('React\Promise\PromiseInterface', waterfall(new EmptyIterator())); + +$iterator = new ArrayIterator([ + static function (): PromiseInterface { return resolve(true); }, +]); +assertType('React\Promise\PromiseInterface', waterfall($iterator)); From b10ce79a2b517cb4eb31306762fe90ad340ad283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 21 Oct 2023 10:40:11 +0200 Subject: [PATCH 18/20] Run tests on PHP 8.3 and update test suite --- .github/workflows/ci.yml | 6 ++++-- composer.json | 8 +++++--- phpunit.xml.dist | 6 +++--- phpunit.xml.legacy | 4 ++-- src/functions_include.php | 3 ++- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40fdcf9..a398ff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: strategy: matrix: php: + - 8.3 - 8.2 - 8.1 - 8.0 @@ -19,7 +20,7 @@ jobs: - 7.2 - 7.1 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} @@ -37,6 +38,7 @@ jobs: strategy: matrix: php: + - 8.3 - 8.2 - 8.1 - 8.0 @@ -44,7 +46,7 @@ jobs: - 7.3 - 7.2 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} diff --git a/composer.json b/composer.json index e208dd3..162bf60 100644 --- a/composer.json +++ b/composer.json @@ -31,8 +31,8 @@ "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { - "phpstan/phpstan": "1.10.18 || 1.4.10", - "phpunit/phpunit": "^9.5 || ^7.5" + "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" }, "autoload": { "files": [ @@ -40,6 +40,8 @@ ] }, "autoload-dev": { - "psr-4": { "React\\Tests\\Async\\": "tests/" } + "psr-4": { + "React\\Tests\\Async\\": "tests/" + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7a9577e..ac542e7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,8 +1,8 @@ - + - + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy index 232aae2..0086860 100644 --- a/phpunit.xml.legacy +++ b/phpunit.xml.legacy @@ -1,6 +1,6 @@ - + - + diff --git a/src/functions_include.php b/src/functions_include.php index 92a7439..05c78fa 100644 --- a/src/functions_include.php +++ b/src/functions_include.php @@ -3,6 +3,7 @@ namespace React\Async; // @codeCoverageIgnoreStart -if (!function_exists(__NAMESPACE__ . '\\parallel')) { +if (!\function_exists(__NAMESPACE__ . '\\parallel')) { require __DIR__ . '/functions.php'; } +// @codeCoverageIgnoreEnd From bc3ef672b33e95bf814fe8377731e46888ed4b54 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Wed, 22 Nov 2023 17:21:11 +0100 Subject: [PATCH 19/20] Prepare v3.2.0 release --- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e839e9..d89e20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 3.2.0 (2023-11-22) + +This release contains backported features from the Async v4.2.0 release for those +not yet on PHP 8.1+. Async v3 provides a compatible API, but may not take advantage +of newer language features. We encourage upgrading to the latest version when possible. + +* Feature: Add Promise v3 template types for all public functions. + (#82 by @clue) + + All our public APIs now use Promise v3 template types to guide IDEs and static + analysis tools (like PHPStan), helping with proper type usage and improving + code quality: + + ```php + assertType('bool', await(resolve(true))); + assertType('PromiseInterface', coroutine(fn(): bool => true)); + ``` + +* Feature: Full PHP 8.3 compatibility. + (#83 by @clue) + +* Update test suite to avoid unhandled promise rejections. + (#80 by @clue) + ## 3.1.0 (2023-06-22) * Feature: Add new `delay()` function to delay program execution. diff --git a/README.md b/README.md index dc2d306..82cf71c 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version from this branch: ```bash -composer require react/async:^3.1 +composer require react/async:^3.2 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 3404f6392eff6a685ede90235b86fa1a0f27cdfa Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Thu, 2 May 2024 14:24:44 +0200 Subject: [PATCH 20/20] Use Promise v3 template types --- src/functions.php | 6 ++++++ tests/AwaitTest.php | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/functions.php b/src/functions.php index 3ef0e45..611e827 100644 --- a/src/functions.php +++ b/src/functions.php @@ -323,6 +323,7 @@ function coroutine(callable $function, ...$args): PromiseInterface } $promise = null; + /** @var Deferred $deferred*/ $deferred = new Deferred(function () use (&$promise) { /** @var ?PromiseInterface $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { @@ -381,6 +382,7 @@ function parallel(iterable $tasks): PromiseInterface { /** @var array> $pending */ $pending = []; + /** @var Deferred> $deferred */ $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { @@ -430,6 +432,7 @@ function parallel(iterable $tasks): PromiseInterface $deferred->resolve($results); } + /** @var PromiseInterface> Remove once defining `Deferred()` above is supported by PHPStan, see https://github.com/phpstan/phpstan/issues/11032 */ return $deferred->promise(); } @@ -441,6 +444,7 @@ function parallel(iterable $tasks): PromiseInterface function series(iterable $tasks): PromiseInterface { $pending = null; + /** @var Deferred> $deferred */ $deferred = new Deferred(function () use (&$pending) { /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { @@ -485,6 +489,7 @@ function series(iterable $tasks): PromiseInterface $next(); + /** @var PromiseInterface> Remove once defining `Deferred()` above is supported by PHPStan, see https://github.com/phpstan/phpstan/issues/11032 */ return $deferred->promise(); } @@ -496,6 +501,7 @@ function series(iterable $tasks): PromiseInterface function waterfall(iterable $tasks): PromiseInterface { $pending = null; + /** @var Deferred $deferred*/ $deferred = new Deferred(function () use (&$pending) { /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 397a0fa..f0becee 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -26,7 +26,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith } $promise = new Promise(function ($_, $reject) { - $reject(false); + $reject(false); // @phpstan-ignore-line }); $this->expectException(\UnexpectedValueException::class); @@ -41,7 +41,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith } $promise = new Promise(function ($_, $reject) { - $reject(null); + $reject(null); // @phpstan-ignore-line }); $this->expectException(\UnexpectedValueException::class); @@ -278,7 +278,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi gc_collect_cycles(); $promise = new Promise(function ($_, $reject) { - $reject(null); + $reject(null); // @phpstan-ignore-line }); try { React\Async\await($promise);