From 6c9811e283cff0704d0a61a015eaf82843c9e2d3 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 14:08:38 +0100 Subject: [PATCH 1/9] fix: handle resolution within chains (new issues introduced) fixes #75 --- src/Promise.php | 112 ++++++++++++++-------------------- test/phpunit/DeferredTest.php | 62 +++++++++++++++++++ 2 files changed, 107 insertions(+), 67 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index a5bc2bb..152c474 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -8,21 +8,15 @@ use Gt\Promise\Chain\ThenChain; use Throwable; -/** - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ class Promise implements PromiseInterface { - private mixed $resolvedValue; - /** @var bool This is required due to the ability to set `null` as a resolved value. */ private bool $resolvedValueSet = false; - private Throwable $rejectedReason; + private bool $stopChain = false; - /** @var Chainable[] */ + private mixed $resolvedValue; + private mixed $originalResolvedValue; + private Throwable $rejectedReason; private array $chain; - /** @var CatchChain[] */ private array $uncalledCatchChain; - /** @var Throwable[] */ private array $handledRejections; /** @var callable */ private $executor; @@ -31,7 +25,6 @@ public function __construct(callable $executor) { $this->chain = []; $this->uncalledCatchChain = []; $this->handledRejections = []; - $this->executor = $executor; $this->callExecutor(); } @@ -43,15 +36,11 @@ public function getState():PromiseState { elseif($this->resolvedValueSet) { return PromiseState::RESOLVED; } - return PromiseState::PENDING; } public function then(callable $onResolved):PromiseInterface { - array_push($this->chain, new ThenChain( - $onResolved, - null, - )); + array_push($this->chain, new ThenChain($onResolved, null)); $this->tryComplete(); return $this; } @@ -59,7 +48,6 @@ public function then(callable $onResolved):PromiseInterface { private function chainPromise(PromiseInterface $promise):void { $this->reset(); $futureThen = $this->getNextChainItem(); - $promise->then(function(mixed $newResolvedValue)use($futureThen) { $futureThen->callOnResolved($newResolvedValue); $this->resolve($newResolvedValue); @@ -71,22 +59,13 @@ private function chainPromise(PromiseInterface $promise):void { } public function catch(callable $onRejected):PromiseInterface { - array_push($this->chain, new CatchChain( - null, - $onRejected - )); + array_push($this->chain, new CatchChain(null, $onRejected)); $this->tryComplete(); return $this; } - public function finally( - callable $onResolvedOrRejected - ):PromiseInterface { - array_push($this->chain, new FinallyChain( - $onResolvedOrRejected, - $onResolvedOrRejected - )); - + public function finally(callable $onResolvedOrRejected):PromiseInterface { + array_push($this->chain, new FinallyChain($onResolvedOrRejected, $onResolvedOrRejected)); return $this; } @@ -111,14 +90,16 @@ function() { } private function resolve(mixed $value):void { + if($this->getState() !== PromiseState::PENDING) { + return; + } $this->reset(); if($value instanceof PromiseInterface) { $this->reject(new PromiseResolvedWithAnotherPromiseException()); $this->tryComplete(); return; } - - $this->resolvedValue = $value; + $this->resolvedValue = $this->originalResolvedValue = $value; $this->resolvedValueSet = true; } @@ -137,6 +118,10 @@ private function reset():void { } private function tryComplete():void { + if($this->stopChain) { + $this->stopChain = false; + return; + } if(empty($this->chain)) { $this->throwUnhandledRejection(); return; @@ -146,54 +131,48 @@ private function tryComplete():void { } } - // phpcs:ignore private function complete():void { usort( $this->chain, function(Chainable $a, Chainable $b) { - if($a instanceof FinallyChain) { - return 1; - } - elseif($b instanceof FinallyChain) { - return -1; - } - + if($a instanceof FinallyChain) return 1; + if($b instanceof FinallyChain) return -1; return 0; } ); - while($this->getState() !== PromiseState::PENDING) { + while ($this->getState() !== PromiseState::PENDING) { $chainItem = $this->getNextChainItem(); - if(!$chainItem) { - break; - } + if (!$chainItem) break; - if($chainItem instanceof ThenChain) { + if ($chainItem instanceof ThenChain) { try { if($this->resolvedValueSet && isset($this->resolvedValue)) { $chainItem->checkResolutionCallbackType($this->resolvedValue); } } - catch(ChainFunctionTypeError) { + catch (ChainFunctionTypeError) { continue; } - $this->handleThen($chainItem); + if ($this->handleThen($chainItem)) { + $this->emptyChain(); + } } - elseif($chainItem instanceof CatchChain) { + elseif ($chainItem instanceof CatchChain) { try { - if(isset($this->rejectedReason)) { + if (isset($this->rejectedReason)) { $chainItem->checkRejectionCallbackType($this->rejectedReason); } - if($handled = $this->handleCatch($chainItem)) { + if ($handled = $this->handleCatch($chainItem)) { array_push($this->handledRejections, $handled); } } - catch(ChainFunctionTypeError) { + catch (ChainFunctionTypeError) { continue; } } - elseif($chainItem instanceof FinallyChain) { + elseif ($chainItem instanceof FinallyChain) { $this->handleFinally($chainItem); } } @@ -205,30 +184,30 @@ private function getNextChainItem():?Chainable { return array_shift($this->chain); } - private function handleThen(ThenChain $then):void { + private function handleThen(ThenChain $then):bool { if($this->getState() !== PromiseState::RESOLVED) { - return; + return false; } - try { - $result = null; - if(isset($this->resolvedValue)) { - $result = $then->callOnResolved($this->resolvedValue); - } - + $result = $then->callOnResolved($this->resolvedValue); if($result instanceof PromiseInterface) { $this->chainPromise($result); } elseif(is_null($result)) { - $this->reset(); + $this->stopChain = true; + return true; } else { - $this->resolve($result); + $this->resolvedValue = $result; + $this->resolvedValueSet = true; + $this->tryComplete(); } } catch(Throwable $rejection) { $this->reject($rejection); } + + return false; } private function handleCatch(CatchChain $catch):?Throwable { @@ -236,10 +215,8 @@ private function handleCatch(CatchChain $catch):?Throwable { array_push($this->uncalledCatchChain, $catch); return null; } - try { $result = $catch->callOnRejected($this->rejectedReason); - if($result instanceof PromiseInterface) { $this->chainPromise($result); } @@ -249,25 +226,21 @@ private function handleCatch(CatchChain $catch):?Throwable { else { return $this->rejectedReason; } - } catch(Throwable $rejection) { $this->reject($rejection); } - return null; } private function handleFinally(FinallyChain $finally):void { $result = null; - if($this->getState() === PromiseState::RESOLVED) { $result = $finally->callOnResolved($this->resolvedValue ?? null); } elseif($this->getState() === PromiseState::REJECTED) { $result = $finally->callOnRejected($this->rejectedReason ?? null); } - if($result instanceof PromiseInterface) { $this->chainPromise($result); } @@ -283,4 +256,9 @@ protected function throwUnhandledRejection():void { } } } + + protected function emptyChain():void { + $this->resolvedValue = $this->originalResolvedValue; + $this->chain = []; + } } diff --git a/test/phpunit/DeferredTest.php b/test/phpunit/DeferredTest.php index d95932a..2d47966 100644 --- a/test/phpunit/DeferredTest.php +++ b/test/phpunit/DeferredTest.php @@ -79,4 +79,66 @@ public function testRejectPromise() { self::assertEquals(0, $numResolvedCalls); self::assertEquals(1, $numRejectedCalls); } + + public function testMultipleResolution() { + $deferred = new Deferred(); + $promise = $deferred->getPromise(); + + $received = []; + + $deferred->resolve("hello"); + + $promise->then(function(string $thing) use(&$received) { +// 0: Should resolve with "hello", from the above Deferred::resolve() call + array_push($received, $thing); + return "$thing-appended-from-1"; + })->then(function(string $thing) use(&$received) { +// 1: Should resolve with "hello-appended-from-1", due to the previous chained function returning the appended string. + array_push($received, $thing); +// This function does not return a value... + })->then(function(mixed $thing) use(&$received) { +// ... so this chained function should never be called. +// Notice the type hint for the function is mixed, in case there's an attempt at resolving with null. + array_push($received, $thing); + }); + +// 2: This function is at the start of a promise chain, which is already resolved, so it should be resolved with the original resolution of "hello". + $promise->then(function(string $thing) use(&$received) { + array_push($received, $thing); + }); + +// 3: This function is also at the start of a new promise chain, so it should also be resolved with "hello". + $promise->then(function(string $thing) use(&$received) { + array_push($received, $thing); +// but it doesn't return anything... + })->then(function(string $thing) use(&$received) { +// ... so no future promises in this chain should be resolved. + array_push($received, $thing); + })->then(function(string $thing) use(&$received) { + array_push($received, $thing); + }); + +// The Deferred is resolved with a new value, but the Promises/A+ specification +// states that a promise should only resolve once, and subsequent resolutions +// should be ignored. + $deferred->resolve("world"); + + $promise->then(function(string $thing) use(&$received) { +// 4: This promise starts a new chain, so it should resolve with the original resolved value, "hello". + array_push($received, $thing); +// but it doesn't return anything... + })->then(function(string $thing) use (&$received) { +// ... so no future promises in this chain should be resolved. + array_push($received, $thing); + }); + +// The count should match the commented behaviour above, to verify that chains +// after a non-returning handler are not invoked. + self::assertCount(5, $received); + self::assertSame("hello", $received[0], "String check 0"); + self::assertSame("hello-appended-from-1", $received[1], "String check 1"); + self::assertSame("hello", $received[2], "String check 2"); + self::assertSame("hello", $received[3], "String check 3"); + self::assertSame("hello", $received[4], "String check 4"); + } } From 5cb2522d0b5a7462b29178ca76331a836657439a Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 14:15:57 +0100 Subject: [PATCH 2/9] fix: allow rejections to resolve chain by returning static value part of #75 --- src/Promise.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Promise.php b/src/Promise.php index 152c474..cf1cec2 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -90,7 +90,7 @@ function() { } private function resolve(mixed $value):void { - if($this->getState() !== PromiseState::PENDING) { + if($this->getState() === PromiseState::RESOLVED) { return; } $this->reset(); From 6f23097ca0907f75c414f1a72a39f771350ca03d Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 16:03:43 +0100 Subject: [PATCH 3/9] fix: handle finally chains better for #75 --- src/Promise.php | 89 ++++++++++++++++++++++++++---------- test/phpunit/PromiseTest.php | 6 ++- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index cf1cec2..48eb4c7 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -135,8 +135,8 @@ private function complete():void { usort( $this->chain, function(Chainable $a, Chainable $b) { - if($a instanceof FinallyChain) return 1; - if($b instanceof FinallyChain) return -1; + if($a instanceof FinallyChain && !($b instanceof FinallyChain)) return 1; + if($b instanceof FinallyChain && !($a instanceof FinallyChain)) return -1; return 0; } ); @@ -145,7 +145,7 @@ function(Chainable $a, Chainable $b) { $chainItem = $this->getNextChainItem(); if (!$chainItem) break; - if ($chainItem instanceof ThenChain) { + if ($chainItem instanceof ThenChain || $chainItem instanceof FinallyChain) { try { if($this->resolvedValueSet && isset($this->resolvedValue)) { $chainItem->checkResolutionCallbackType($this->resolvedValue); @@ -155,8 +155,15 @@ function(Chainable $a, Chainable $b) { continue; } - if ($this->handleThen($chainItem)) { - $this->emptyChain(); + if($chainItem instanceof ThenChain) { + if ($this->handleThen($chainItem)) { + $this->emptyChain(); + } + } + elseif($chainItem instanceof FinallyChain) { + if($this->handleFinally($chainItem)) { + $this->emptyChain(); + } } } elseif ($chainItem instanceof CatchChain) { @@ -172,9 +179,9 @@ function(Chainable $a, Chainable $b) { continue; } } - elseif ($chainItem instanceof FinallyChain) { - $this->handleFinally($chainItem); - } +// elseif ($chainItem instanceof FinallyChain) { +// $this->handleFinally($chainItem); +// } } $this->throwUnhandledRejection(); @@ -210,6 +217,51 @@ private function handleThen(ThenChain $then):bool { return false; } + private function handleFinally(FinallyChain $finally):bool { + if($this->getState() === PromiseState::RESOLVED) { + try { + $result = $finally->callOnResolved($this->resolvedValue); + if($result instanceof PromiseInterface) { + $this->chainPromise($result); + } + elseif(is_null($result)) { + $this->stopChain = true; + return true; + } + else { + $this->resolvedValue = $result; + $this->resolvedValueSet = true; + $this->tryComplete(); + } + } + catch(Throwable $reason) { + $this->reject($reason); + } + } + elseif($this->getState() === PromiseState::REJECTED) { + try { + $result = $finally->callOnRejected($this->rejectedReason); + if($result instanceof PromiseInterface) { + $this->chainPromise($result); + } + elseif(is_null($result)) { + $this->stopChain = true; + return true; + } + else { + $this->resolvedValue = $result; + $this->resolvedValueSet = true; + $this->tryComplete(); + } + } + catch(Throwable $reason) { + $this->reject($reason); + } + } + + return false; + } + private function handleCatch(CatchChain $catch):?Throwable { if($this->getState() !== PromiseState::REJECTED) { array_push($this->uncalledCatchChain, $catch); @@ -233,22 +285,6 @@ private function handleCatch(CatchChain $catch):?Throwable { return null; } - private function handleFinally(FinallyChain $finally):void { - $result = null; - if($this->getState() === PromiseState::RESOLVED) { - $result = $finally->callOnResolved($this->resolvedValue ?? null); - } - elseif($this->getState() === PromiseState::REJECTED) { - $result = $finally->callOnRejected($this->rejectedReason ?? null); - } - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - else { - $this->resolve($result); - } - } - protected function throwUnhandledRejection():void { if($this->getState() === PromiseState::REJECTED) { if(!in_array($this->rejectedReason, $this->handledRejections)) { @@ -258,7 +294,10 @@ protected function throwUnhandledRejection():void { } protected function emptyChain():void { - $this->resolvedValue = $this->originalResolvedValue; + if(isset($this->originalResolvedValue)) { + $this->resolvedValue = $this->originalResolvedValue; + } + $this->chain = []; } } diff --git a/test/phpunit/PromiseTest.php b/test/phpunit/PromiseTest.php index 6c4c001..9dba9ff 100644 --- a/test/phpunit/PromiseTest.php +++ b/test/phpunit/PromiseTest.php @@ -459,15 +459,19 @@ public function testFinallyCanReturnPromise() { $promiseContainer = $this->getTestPromiseContainer(); $sut = $promiseContainer->getPromise(); $sut->finally(function(mixed $resolvedValueOrRejectedReason) use($otherPromise, &$finallyLog) { + array_push($finallyLog, $resolvedValueOrRejectedReason); + return "First return"; + })->finally(function(mixed $resolvedValueOrRejectedReason) use($otherPromise, &$finallyLog) { array_push($finallyLog, $resolvedValueOrRejectedReason); return $otherPromise; })->finally(function(mixed $resolvedValueOrRejectedReason) use($otherPromise, &$finallyLog) { array_push($finallyLog, $resolvedValueOrRejectedReason); }); + $promiseContainer->resolve("test"); self::assertCount(2, $finallyLog); self::assertSame("test", $finallyLog[0]); - self::assertNull($finallyLog[1]); + self::assertSame("First return", $finallyLog[1]); } public function testOnRejectedCalledWhenFinallyThrows() { From 82b588c9bd2168f5dd11ffce4518ac0fa4374b70 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 20:56:44 +0100 Subject: [PATCH 4/9] wip: test unhandled rejections --- src/Promise.php | 55 +++++++++++++++--------------------- test/phpunit/PromiseTest.php | 15 ++++++---- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index 48eb4c7..4ec46bb 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -219,43 +219,34 @@ private function handleThen(ThenChain $then):bool { private function handleFinally(FinallyChain $finally):bool { if($this->getState() === PromiseState::RESOLVED) { - try { - $result = $finally->callOnResolved($this->resolvedValue); - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - elseif(is_null($result)) { - $this->stopChain = true; - return true; - } - else { - $this->resolvedValue = $result; - $this->resolvedValueSet = true; - $this->tryComplete(); - } + $result = $finally->callOnResolved($this->resolvedValue); + if($result instanceof PromiseInterface) { + $this->chainPromise($result); } - catch(Throwable $reason) { - $this->reject($reason); + elseif(is_null($result)) { + $this->stopChain = true; + return true; } + else { + $this->resolvedValue = $result; + $this->resolvedValueSet = true; + $this->tryComplete(); + } + } elseif($this->getState() === PromiseState::REJECTED) { - try { - $result = $finally->callOnRejected($this->rejectedReason); - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - elseif(is_null($result)) { - $this->stopChain = true; - return true; - } - else { - $this->resolvedValue = $result; - $this->resolvedValueSet = true; - $this->tryComplete(); - } + $result = $finally->callOnRejected($this->rejectedReason); + if($result instanceof PromiseInterface) { + $this->chainPromise($result); } - catch(Throwable $reason) { - $this->reject($reason); + elseif(is_null($result)) { + $this->stopChain = true; + return true; + } + else { + $this->resolvedValue = $result; + $this->resolvedValueSet = true; + $this->tryComplete(); } } diff --git a/test/phpunit/PromiseTest.php b/test/phpunit/PromiseTest.php index 9dba9ff..b4776b7 100644 --- a/test/phpunit/PromiseTest.php +++ b/test/phpunit/PromiseTest.php @@ -474,20 +474,23 @@ public function testFinallyCanReturnPromise() { self::assertSame("First return", $finallyLog[1]); } - public function testOnRejectedCalledWhenFinallyThrows() { + public function testOnRejectedNotCalledWhenFinallyThrows() { $exception = new PromiseException("Oh dear, oh dear"); + $actualException = null; $promiseContainer = $this->getTestPromiseContainer(); self::expectException(PromiseException::class); self::expectExceptionMessage("Oh dear, oh dear"); $sut = $promiseContainer->getPromise(); - $sut->finally(function() use ($exception) { + $sut->finally(function(string $message) use ($exception) { throw $exception; - })->then( - self::mockCallable(1, "Example resolution"), - self::mockCallable(0) - ); + })->then(function(string $message) { + return "$message-appended"; + })->catch(function(Throwable $reason) use(&$actualException) { + $actualException = $reason; + }); $promiseContainer->resolve("Example resolution"); + self::assertNull($actualException); } public function testGetStatePending() { From 3eecc8d49041ee887762fae43564180fec3a92f3 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 22:02:29 +0100 Subject: [PATCH 5/9] test: assert that exception is thrown when catch not configured --- test/phpunit/PromiseTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/phpunit/PromiseTest.php b/test/phpunit/PromiseTest.php index b4776b7..7d95d8d 100644 --- a/test/phpunit/PromiseTest.php +++ b/test/phpunit/PromiseTest.php @@ -489,6 +489,9 @@ public function testOnRejectedNotCalledWhenFinallyThrows() { })->catch(function(Throwable $reason) use(&$actualException) { $actualException = $reason; }); + + self::expectException(PromiseException::class); + self::expectExceptionMessage("Oh dear, oh dear"); $promiseContainer->resolve("Example resolution"); self::assertNull($actualException); } From f5af9ab041ffe347318219e09ffecf659611cca0 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 22:06:31 +0100 Subject: [PATCH 6/9] tidy: refactor duplicated code snippet --- src/Promise.php | 61 +++++++++++++++++-------------------------------- 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index 4ec46bb..cb1ce0e 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -179,9 +179,6 @@ function(Chainable $a, Chainable $b) { continue; } } -// elseif ($chainItem instanceof FinallyChain) { -// $this->handleFinally($chainItem); -// } } $this->throwUnhandledRejection(); @@ -195,20 +192,10 @@ private function handleThen(ThenChain $then):bool { if($this->getState() !== PromiseState::RESOLVED) { return false; } + try { $result = $then->callOnResolved($this->resolvedValue); - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - elseif(is_null($result)) { - $this->stopChain = true; - return true; - } - else { - $this->resolvedValue = $result; - $this->resolvedValueSet = true; - $this->tryComplete(); - } + return $this->handleResolvedResult($result); } catch(Throwable $rejection) { $this->reject($rejection); @@ -220,34 +207,28 @@ private function handleThen(ThenChain $then):bool { private function handleFinally(FinallyChain $finally):bool { if($this->getState() === PromiseState::RESOLVED) { $result = $finally->callOnResolved($this->resolvedValue); - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - elseif(is_null($result)) { - $this->stopChain = true; - return true; - } - else { - $this->resolvedValue = $result; - $this->resolvedValueSet = true; - $this->tryComplete(); - } - + return $this->handleResolvedResult($result); } elseif($this->getState() === PromiseState::REJECTED) { $result = $finally->callOnRejected($this->rejectedReason); - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - elseif(is_null($result)) { - $this->stopChain = true; - return true; - } - else { - $this->resolvedValue = $result; - $this->resolvedValueSet = true; - $this->tryComplete(); - } + return $this->handleResolvedResult($result); + } + + return false; + } + + private function handleResolvedResult(mixed $result):bool { + if($result instanceof PromiseInterface) { + $this->chainPromise($result); + } + elseif(is_null($result)) { + $this->stopChain = true; + return true; + } + else { + $this->resolvedValue = $result; + $this->resolvedValueSet = true; + $this->tryComplete(); } return false; From 53f636ffa01a2c03201256eb4b626c4d5b9d97c4 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 22:25:16 +0100 Subject: [PATCH 7/9] tidy: refactor away complexity --- src/ExecutesPromiseChain.php | 89 ++++++++++++++++++++++++++++++++++++ src/Promise.php | 66 +++----------------------- 2 files changed, 96 insertions(+), 59 deletions(-) create mode 100644 src/ExecutesPromiseChain.php diff --git a/src/ExecutesPromiseChain.php b/src/ExecutesPromiseChain.php new file mode 100644 index 0000000..3772b89 --- /dev/null +++ b/src/ExecutesPromiseChain.php @@ -0,0 +1,89 @@ +chain, $this->sortChainItems(...)); + + while($this->getState() !== PromiseState::PENDING) { + $chainItem = $this->getNextChainItem(); + if(!$chainItem) { + break; + } + + if($this->shouldSkipResolution($chainItem)) { + continue; + } + + if($chainItem instanceof ThenChain) { + $this->executeThen($chainItem); + } + elseif($chainItem instanceof FinallyChain) { + $this->executeFinally($chainItem); + } + elseif($chainItem instanceof CatchChain) { + $this->executeCatch($chainItem); + } + } + + $this->throwUnhandledRejection(); + } + + private function shouldSkipResolution(Chainable $chainItem):bool { + if($chainItem instanceof ThenChain || $chainItem instanceof FinallyChain) { + try { + if($this->resolvedValueSet && isset($this->resolvedValue)) { + $chainItem->checkResolutionCallbackType($this->resolvedValue); + } + } + catch(ChainFunctionTypeError) { + return true; + } + } + elseif($chainItem instanceof CatchChain) { + try { + if(isset($this->rejectedReason)) { + $chainItem->checkRejectionCallbackType($this->rejectedReason); + } + } + catch(ChainFunctionTypeError) { + return true; + } + } + return false; + } + + private function executeThen(ThenChain $chainItem):void { + if($this->handleThen($chainItem)) { + $this->emptyChain(); + } + } + + private function executeFinally(FinallyChain $chainItem):void { + if($this->handleFinally($chainItem)) { + $this->emptyChain(); + } + } + + private function executeCatch(CatchChain $chainItem):void { + if($handled = $this->handleCatch($chainItem)) { + array_push($this->handledRejections, $handled); + } + } + + private function sortChainItems(Chainable $a, Chainable $b):int { + if($a instanceof FinallyChain && !($b instanceof FinallyChain)) { + return 1; + } + if($b instanceof FinallyChain && !($a instanceof FinallyChain)) { + return -1; + } + return 0; + } +} diff --git a/src/Promise.php b/src/Promise.php index cb1ce0e..7bfd09f 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -9,21 +9,23 @@ use Throwable; class Promise implements PromiseInterface { + use ExecutesPromiseChain; + private bool $resolvedValueSet = false; private bool $stopChain = false; private mixed $resolvedValue; private mixed $originalResolvedValue; private Throwable $rejectedReason; + /** @var array */ private array $chain; - private array $uncalledCatchChain; + /** @var array */ private array $handledRejections; /** @var callable */ private $executor; public function __construct(callable $executor) { $this->chain = []; - $this->uncalledCatchChain = []; $this->handledRejections = []; $this->executor = $executor; $this->callExecutor(); @@ -131,64 +133,11 @@ private function tryComplete():void { } } - private function complete():void { - usort( - $this->chain, - function(Chainable $a, Chainable $b) { - if($a instanceof FinallyChain && !($b instanceof FinallyChain)) return 1; - if($b instanceof FinallyChain && !($a instanceof FinallyChain)) return -1; - return 0; - } - ); - - while ($this->getState() !== PromiseState::PENDING) { - $chainItem = $this->getNextChainItem(); - if (!$chainItem) break; - - if ($chainItem instanceof ThenChain || $chainItem instanceof FinallyChain) { - try { - if($this->resolvedValueSet && isset($this->resolvedValue)) { - $chainItem->checkResolutionCallbackType($this->resolvedValue); - } - } - catch (ChainFunctionTypeError) { - continue; - } - - if($chainItem instanceof ThenChain) { - if ($this->handleThen($chainItem)) { - $this->emptyChain(); - } - } - elseif($chainItem instanceof FinallyChain) { - if($this->handleFinally($chainItem)) { - $this->emptyChain(); - } - } - } - elseif ($chainItem instanceof CatchChain) { - try { - if (isset($this->rejectedReason)) { - $chainItem->checkRejectionCallbackType($this->rejectedReason); - } - if ($handled = $this->handleCatch($chainItem)) { - array_push($this->handledRejections, $handled); - } - } - catch (ChainFunctionTypeError) { - continue; - } - } - } - - $this->throwUnhandledRejection(); - } - private function getNextChainItem():?Chainable { return array_shift($this->chain); } - private function handleThen(ThenChain $then):bool { + protected function handleThen(ThenChain $then):bool { if($this->getState() !== PromiseState::RESOLVED) { return false; } @@ -204,7 +153,7 @@ private function handleThen(ThenChain $then):bool { return false; } - private function handleFinally(FinallyChain $finally):bool { + protected function handleFinally(FinallyChain $finally):bool { if($this->getState() === PromiseState::RESOLVED) { $result = $finally->callOnResolved($this->resolvedValue); return $this->handleResolvedResult($result); @@ -234,9 +183,8 @@ private function handleResolvedResult(mixed $result):bool { return false; } - private function handleCatch(CatchChain $catch):?Throwable { + protected function handleCatch(CatchChain $catch):?Throwable { if($this->getState() !== PromiseState::REJECTED) { - array_push($this->uncalledCatchChain, $catch); return null; } try { From 3c7dfd6efc5d9da873ef259376184afefb1b8bf2 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 22:30:59 +0100 Subject: [PATCH 8/9] docs: restyle org name --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77b0202..92f8116 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ There are many implementations of the concept of a `Promise`. This library aims *** - + Build status @@ -18,14 +18,14 @@ There are many implementations of the concept of a `Promise`. This library aims Current version - PHP.Gt/Promise documentation + PHP.GT/Promise documentation In computer science, a `Promise` is a mechanism that provides a simple and direct relationship between procedural code and asynchronous callbacks. Functions within procedural languages, like plain old PHP, have two ways they can affect your program's flow: either by returning values or throwing exceptions. When working with functions that execute asynchronously, we can't return values because they might not be ready yet, and we can't throw exceptions because that's a procedural concept (where should we catch them?). That's where promises come in: instead of returning a value or throwing an exception, your functions can return a `Promise`, which is an object that can be _fulfilled_ with a value, or _rejected_ with an exception, but not necessarily at the point that they are returned. -With this concept, the actual work that calculates or loads the value required by your code can be _deferred_ to a task that executes asynchronously. Behind the scenes of PHP.Gt/Promise is a `Deferred` class that is used for exactly this. +With this concept, the actual work that calculates or loads the value required by your code can be _deferred_ to a task that executes asynchronously. Behind the scenes of PHP.GT/Promise is a `Deferred` class that is used for exactly this. Example usage ------------- @@ -120,7 +120,7 @@ Event loop In order for this Promise library to be useful, some code has got to act as an event loop to call the deferred processes. This could be a simple while loop, but for real world tasks a more comprehensive loop system should be used. -The implementation of a Promise-based architecture is complex enough on its own, so the responsibility of an event loop library is maintained separately in [PHP.Gt/Async][gt-async]. +The implementation of a Promise-based architecture is complex enough on its own, so the responsibility of an event loop library is maintained separately in [PHP.GT/Async][gt-async]. Special thanks -------------- From 52a7828e2c0527d8ae5d47f3867f21f2d3b7abe6 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Wed, 30 Apr 2025 22:39:09 +0100 Subject: [PATCH 9/9] tweak: remove deprecations from unit tests --- phpunit.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 3007930..5e1b5d9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,7 +5,6 @@ colors="true" cacheDirectory="test/phpunit/.phpunit.cache" bootstrap="vendor/autoload.php" - displayDetailsOnTestsThatTriggerDeprecations="true" >