diff --git a/src/auth/composer.json b/src/auth/composer.json index e415d300b1..822d360f4f 100644 --- a/src/auth/composer.json +++ b/src/auth/composer.json @@ -32,6 +32,7 @@ "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/core": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/hashing": "^0.4", "hypervel/http": "^0.4", diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index c1c636b9ae..926234d8ec 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -13,6 +13,7 @@ use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Hashing\Hasher as HasherContract; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use InvalidArgumentException; @@ -562,7 +563,7 @@ protected function registerCacheInvalidationEvents(): void $coordinator = $container->make(ModelCacheCoordinator::class); $exception = null; - // Every configured store must be attempted even if another store is unavailable. + // Ordinary store failures must not prevent invalidation of the remaining stores. foreach (static::$cachedProviders[$modelClass] ?? [] as $descriptor) { try { $coordinator->invalidate( @@ -570,6 +571,10 @@ protected function registerCacheInvalidationEvents(): void $descriptor['prefix'] . ':' . $modelClass . ':' . $identifierSegment, ); } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php index 21f8be2a7e..9aa10ad6bf 100644 --- a/src/bus/src/Batch.php +++ b/src/bus/src/Batch.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\Factory as QueueFactory; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Support\Arr; @@ -408,6 +409,10 @@ protected function invokeHandlerCallback(callable $handler, Batch $batch, ?Throw try { $handler($batch, $e); } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + if (function_exists('report')) { report($e); } diff --git a/src/bus/src/DatabaseBatchRepository.php b/src/bus/src/DatabaseBatchRepository.php index 2e6632528d..4518630460 100644 --- a/src/bus/src/DatabaseBatchRepository.php +++ b/src/bus/src/DatabaseBatchRepository.php @@ -6,6 +6,7 @@ use Closure; use DateTimeInterface; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\PostgresConnection; @@ -289,7 +290,11 @@ protected function unserialize(string $serialized): mixed try { return unserialize($serialized); - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return []; } } diff --git a/src/bus/src/DebounceLock.php b/src/bus/src/DebounceLock.php index 5d4822e3f9..70591dc113 100644 --- a/src/bus/src/DebounceLock.php +++ b/src/bus/src/DebounceLock.php @@ -5,6 +5,7 @@ namespace Hypervel\Bus; use Hypervel\Contracts\Cache\Repository as Cache; +use Hypervel\Coroutine\Coroutine; use Hypervel\Queue\Attributes\DebounceFor; use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Support\CarbonImmutable; @@ -89,8 +90,8 @@ protected function acquireResolvedLock(Cache $cache, string $key, int $ttl, ?int } catch (Throwable $exception) { try { static::releaseOwned($cache, $key, $owner); - } catch (Throwable) { - // A cleanup failure must not replace the acquisition failure. + } catch (Throwable $cleanupException) { + throw Coroutine::preferCancellation($exception, $cleanupException); } throw $exception; @@ -182,7 +183,7 @@ public static function releaseOwned(Cache $cache, string $key, string $owner = ' try { $cache->forget($key . ':first_dispatched_at'); } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/bus/src/DispatchLockContext.php b/src/bus/src/DispatchLockContext.php index d8e28d5aaa..5896ca77d8 100644 --- a/src/bus/src/DispatchLockContext.php +++ b/src/bus/src/DispatchLockContext.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Contracts\Cache\Repository as Cache; +use Hypervel\Coroutine\Coroutine; use Throwable; use WeakMap; @@ -311,7 +312,7 @@ protected static function releaseLocks(array $snapshot): void ); } } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/src/bus/src/PendingBatch.php b/src/bus/src/PendingBatch.php index dd00e2d17c..74d9495ba3 100644 --- a/src/bus/src/PendingBatch.php +++ b/src/bus/src/PendingBatch.php @@ -347,11 +347,14 @@ public function dispatch(): Batch $batch = $batch->add($this->jobs); } catch (Throwable $e) { - if (isset($batch)) { - $repository->delete($batch->id); + // Keep the original failure in flight so cleanup errors retain its cancellation chain. + try { + throw $e; + } finally { + if (isset($batch)) { + $repository->delete($batch->id); + } } - - throw $e; } $events = $this->container->make(EventDispatcher::class); @@ -387,9 +390,12 @@ protected function dispatchExistingBatch(Batch $batch): void try { $batch = $batch->add($this->jobs); } catch (Throwable $e) { - $batch->delete(); - - throw $e; + // Keep the original failure in flight so cleanup errors retain its cancellation chain. + try { + throw $e; + } finally { + $batch->delete(); + } } $events = $this->container->make(EventDispatcher::class); @@ -422,15 +428,28 @@ protected function store(BatchRepository $repository): Batch { $batch = $repository->store($this); - (new Collection($this->beforeCallbacks()))->each(function ($handler) use ($batch) { - try { - return $handler($batch); - } catch (Throwable $e) { - if (function_exists('report')) { - report($e); + try { + (new Collection($this->beforeCallbacks()))->each(function ($handler) use ($batch) { + try { + return $handler($batch); + } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + if (function_exists('report')) { + report($e); + } } + }); + } catch (Throwable $e) { + // Dispatch cannot own cleanup until store returns the saved batch. + try { + throw $e; + } finally { + $repository->delete($batch->id); } - }); + } return $batch; } diff --git a/src/cache/src/AnyModeTaggedCache.php b/src/cache/src/AnyModeTaggedCache.php index 16570e1466..91df4c8c65 100644 --- a/src/cache/src/AnyModeTaggedCache.php +++ b/src/cache/src/AnyModeTaggedCache.php @@ -14,7 +14,7 @@ use Hypervel\Cache\Events\KeyForgotten; use Hypervel\Cache\Events\KeyRetrievalFailed; use Hypervel\Cache\Events\RetrievingKey; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; use UnitEnum; @@ -160,10 +160,8 @@ protected function getPlainRaw(UnitEnum|string $key): mixed try { $value = $this->handleIncompleteClass($key, $this->store->get($key)); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyRetrievalFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyRetrievalFailed::class)) { $this->event(new KeyRetrievalFailed($this->getName(), $key, $exception)); } @@ -202,10 +200,8 @@ protected function forgetPlainKey(UnitEnum|string $key): bool try { $result = $this->store->forget($key); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyForgetFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyForgetFailed::class)) { $this->event(new KeyForgetFailed($this->getName(), $key, exception: $exception)); } diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 1c40928dd9..8c2e8aa53f 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -596,9 +596,10 @@ public function setApplication(Container $app): static * Register classes that cache stores may unserialize. * * Boot-only. The resolver contributes to the worker-lifetime cache policy - * and is evaluated at application boot completion in console processes or - * after the worker configuration is rebuilt in Swoole workers. An earlier - * cache read evaluates the current contributions without memoizing them. + * and is evaluated when the application finishes booting in console processes, + * after configuration reload in Swoole workers, or before a custom server + * process starts handling work. An earlier cache read evaluates the current + * contributions without memoizing them. * * @param Closure(): array $resolver * diff --git a/src/cache/src/CacheServiceProvider.php b/src/cache/src/CacheServiceProvider.php index ab4635f311..c01a61d225 100644 --- a/src/cache/src/CacheServiceProvider.php +++ b/src/cache/src/CacheServiceProvider.php @@ -15,6 +15,7 @@ use Hypervel\Cache\Redis\Console\DoctorCommand; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\BeforeServerStart; +use Hypervel\ServerProcess\Events\BeforeProcessHandle; use Hypervel\Support\ServiceProvider; class CacheServiceProvider extends ServiceProvider @@ -64,8 +65,8 @@ public function boot(): void return; } - // Finalize after BeforeWorkerStart rebuilds the worker configuration. - $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event): void { + // Workers finalize after configuration reload; custom processes inherit the master's configuration. + $events->listen([AfterWorkerStart::class, BeforeProcessHandle::class], function (): void { $this->app->make(CacheManager::class)->finalizeSerializableClasses(); }); } diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 49d79caeac..662f57e021 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Filesystem\LockTimeoutException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Filesystem\LockableFile; use Hypervel\Support\InteractsWithTime; @@ -192,9 +193,11 @@ public function getLockOwner(string $key): ?string } return $this->unserialize(substr($contents, 10)); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return null; } } @@ -540,9 +543,11 @@ protected function getPayload(string $key): array try { $data = $this->unserialize(substr($contents, 10)); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->forget($key); return $this->emptyPayload(); diff --git a/src/cache/src/Limiters/ConcurrencyLimiter.php b/src/cache/src/Limiters/ConcurrencyLimiter.php index 4072201e42..963175df06 100644 --- a/src/cache/src/Limiters/ConcurrencyLimiter.php +++ b/src/cache/src/Limiters/ConcurrencyLimiter.php @@ -9,10 +9,10 @@ use Hypervel\Contracts\Cache\RefreshableLock; use Hypervel\Contracts\Limiters\Lease; use Hypervel\Contracts\Limiters\LimiterTimeoutException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Support\Sleep; use Hypervel\Support\Str; -use Swoole\Coroutine\CanceledException; use Throwable; use function Hypervel\Support\now; @@ -93,12 +93,8 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 250 } catch (Throwable $throwable) { try { $lease->release(); - } catch (CanceledException $cleanupCancellation) { - if (! $throwable instanceof CanceledException) { - throw $cleanupCancellation; - } - } catch (Throwable) { - // Preserve the callback failure as primary. + } catch (Throwable $cleanupFailure) { + throw Coroutine::preferCancellation($throwable, $cleanupFailure); } throw $throwable; diff --git a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php index aa3d79d8f9..da8de9a7fc 100644 --- a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php @@ -12,8 +12,8 @@ use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Limiters\Lease; use Hypervel\Contracts\Limiters\LimiterTimeoutException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\InteractsWithTime; -use Swoole\Coroutine\CanceledException; use Throwable; class ConcurrencyLimiterBuilder @@ -124,12 +124,8 @@ public function then(callable $callback, ?callable $failure = null): mixed } catch (Throwable $throwable) { try { $lease->release(); - } catch (CanceledException $cleanupCancellation) { - if (! $throwable instanceof CanceledException) { - throw $cleanupCancellation; - } - } catch (Throwable) { - // Preserve the callback failure as primary. + } catch (Throwable $cleanupFailure) { + throw Coroutine::preferCancellation($throwable, $cleanupFailure); } throw $throwable; diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php index 863b9221e7..b626ef017c 100644 --- a/src/cache/src/Lock.php +++ b/src/cache/src/Lock.php @@ -6,13 +6,13 @@ use Hypervel\Contracts\Cache\Lock as LockContract; use Hypervel\Contracts\Cache\LockTimeoutException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Sleep; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; abstract class Lock implements LockContract @@ -131,12 +131,8 @@ protected function releaseAfterFailure(Throwable $failure): never { try { $this->release(); - } catch (CanceledException $cleanupCancellation) { - if (! $failure instanceof CanceledException) { - throw $cleanupCancellation; - } - } catch (Throwable) { - // Preserve the callback failure as primary. + } catch (Throwable $cleanupFailure) { + throw Coroutine::preferCancellation($failure, $cleanupFailure); } throw $failure; diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index 0f8a0cfbd9..0cb09fd36d 100644 --- a/src/cache/src/Redis/AllTaggedCache.php +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -18,7 +18,7 @@ use Hypervel\Cache\RedisStore; use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; use UnitEnum; @@ -110,10 +110,8 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT $seconds, $this->tags->tagIds() ); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, @@ -172,10 +170,8 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ $this->tags->tagIds(), $this->taggedItemKeyPrefix() ); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { foreach ($values as $key => $value) { $this->event(new KeyWriteFailed( $this->getName(), @@ -274,10 +270,8 @@ public function forever(UnitEnum|string $key, mixed $value): bool $value, $this->tags->tagIds() ); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, @@ -311,10 +305,8 @@ public function flush(): bool try { $this->store->allTagOps()->flush()->execute($this->tags->tagIds()); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(CacheFlushFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(CacheFlushFailed::class)) { $this->event(new CacheFlushFailed($this->getName(), exception: $exception)); } diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php index 07638ac54c..4529ddc196 100644 --- a/src/cache/src/Redis/AnyTaggedCache.php +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -16,7 +16,7 @@ use Hypervel\Cache\RedisStore; use Hypervel\Cache\TagSet; use Hypervel\Contracts\Cache\Store; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; use UnitEnum; @@ -80,10 +80,8 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT try { $result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames()); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, @@ -145,10 +143,8 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ try { $result = $this->store->anyTagOps()->putMany()->execute($values, $seconds, $this->tags->getNames()); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { foreach ($values as $key => $value) { $this->event(new KeyWriteFailed( $this->getName(), @@ -215,10 +211,8 @@ public function forever(UnitEnum|string $key, mixed $value): bool try { $result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames()); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 304bc805aa..2c467f2df8 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -39,12 +39,12 @@ use Hypervel\Contracts\Cache\Repository as CacheContract; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -339,10 +339,8 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT try { $result = $this->store->put($this->itemKey($key), $value, $seconds); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, @@ -404,10 +402,8 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ try { $result = $this->store->putMany($values, $seconds); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { foreach ($values as $key => $value) { $this->event(new KeyWriteFailed( $this->getName(), @@ -522,10 +518,8 @@ public function forever(UnitEnum|string $key, mixed $value): bool try { $result = $this->store->forever($this->itemKey($key), $value); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, @@ -837,10 +831,8 @@ public function forget(array|UnitEnum|string $key): bool try { $result = $this->store->forget($this->itemKey($key)); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyForgetFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyForgetFailed::class)) { $this->event(new KeyForgetFailed($this->getName(), $key, exception: $exception)); } @@ -895,10 +887,8 @@ public function clear(): bool try { $result = $this->store->flush(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(CacheFlushFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(CacheFlushFailed::class)) { $this->event(new CacheFlushFailed($this->getName(), exception: $exception)); } @@ -935,10 +925,8 @@ public function flushLocks(): bool try { $result = $store->flushLocks(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(CacheLocksFlushFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(CacheLocksFlushFailed::class)) { $this->event(new CacheLocksFlushFailed($this->getName(), exception: $exception)); } @@ -1265,10 +1253,8 @@ public function getRaw(UnitEnum|string $key): mixed $this->store->get($this->itemKey($key)) ); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyRetrievalFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyRetrievalFailed::class)) { $this->event(new KeyRetrievalFailed($this->getName(), $key, $exception)); } @@ -1309,10 +1295,8 @@ public function getAuthoritativeRaw(UnitEnum|string $key): mixed try { $value = $store->getAuthoritativeRaw($this->itemKey($key)); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyRetrievalFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyRetrievalFailed::class)) { $this->event(new KeyRetrievalFailed($this->getName(), $key, $exception)); } @@ -1390,10 +1374,8 @@ public function manyRaw(array $keys): array $result[$key] = $value; } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(ManyKeysRetrievalFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(ManyKeysRetrievalFailed::class)) { $this->event(new ManyKeysRetrievalFailed($this->getName(), $keys, $exception)); } diff --git a/src/cache/src/StackTaggedCache.php b/src/cache/src/StackTaggedCache.php index 1586c45d74..1271872fe6 100644 --- a/src/cache/src/StackTaggedCache.php +++ b/src/cache/src/StackTaggedCache.php @@ -10,7 +10,7 @@ use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\Events\WritingKey; use Hypervel\Contracts\Cache\Store; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; use UnitEnum; @@ -77,10 +77,8 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT 'value' => $value, 'ttl' => $seconds, ]); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, @@ -135,10 +133,8 @@ public function forever(UnitEnum|string $key, mixed $value): bool try { $result = $this->store->putRecordTagged($this->tags->getNames(), $key, ['value' => $value]); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(KeyWriteFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(KeyWriteFailed::class)) { $this->event(new KeyWriteFailed( $this->getName(), $key, diff --git a/src/cache/src/StorageStore.php b/src/cache/src/StorageStore.php index 733baf9a31..a87efd780c 100644 --- a/src/cache/src/StorageStore.php +++ b/src/cache/src/StorageStore.php @@ -7,8 +7,8 @@ use Exception; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Filesystem\Filesystem; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\InteractsWithTime; -use Swoole\Coroutine\CanceledException; class StorageStore implements Store { @@ -194,9 +194,11 @@ protected function getPayload(string $key): array } $expiresAt = (int) substr($contents, 0, 10); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return $this->emptyPayload(); } $currentTime = $this->currentTime(); @@ -209,9 +211,11 @@ protected function getPayload(string $key): array try { $data = $this->unserialize(substr($contents, 10)); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->forget($key); return $this->emptyPayload(); diff --git a/src/cache/src/TagSet.php b/src/cache/src/TagSet.php index 52b1bff58e..fb627f139a 100644 --- a/src/cache/src/TagSet.php +++ b/src/cache/src/TagSet.php @@ -6,7 +6,7 @@ use Closure; use Hypervel\Contracts\Cache\Store; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; abstract class TagSet @@ -58,9 +58,11 @@ protected function attemptEach(iterable $items, Closure $operation): bool if (! $operation($item)) { $result = false; } - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } diff --git a/src/cache/src/TaggedCache.php b/src/cache/src/TaggedCache.php index 6649335674..26846277a4 100644 --- a/src/cache/src/TaggedCache.php +++ b/src/cache/src/TaggedCache.php @@ -10,7 +10,7 @@ use Hypervel\Cache\Events\CacheFlushFailed; use Hypervel\Cache\Events\CacheFlushing; use Hypervel\Contracts\Cache\Store; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; use UnitEnum; @@ -82,10 +82,8 @@ public function flush(): bool try { $result = $this->tags->reset(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($this->events?->hasListeners(CacheFlushFailed::class)) { + if (! Coroutine::causedByCancellation($exception) && $this->events?->hasListeners(CacheFlushFailed::class)) { $this->event(new CacheFlushFailed($this->getName(), exception: $exception)); } diff --git a/src/core/src/Logger/StdoutLogger.php b/src/core/src/Logger/StdoutLogger.php index b4e4280c1f..391f039539 100644 --- a/src/core/src/Logger/StdoutLogger.php +++ b/src/core/src/Logger/StdoutLogger.php @@ -7,12 +7,12 @@ use DateTimeInterface; use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Log\StdoutLoggerInterface; +use Hypervel\Coroutine\Coroutine; use InvalidArgumentException; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LoggerTrait; use Psr\Log\LogLevel; use Stringable; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -193,9 +193,11 @@ protected function encodeJson(array $entry): ?string $json = json_encode($entry, self::JSON_FLAGS); return is_string($json) ? $json : null; - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return null; } } @@ -254,9 +256,11 @@ protected function stringifyStringable(Stringable $value): string { try { return (string) $value; - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return ' ' . $value::class; } } diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index 2e78fdc430..d2d57e2cd5 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -5,6 +5,7 @@ namespace Hypervel\Coroutine; use Closure; +use Google\Cloud\Core\Exception\ServiceException; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; @@ -255,11 +256,30 @@ public static function causedByCancellation(Throwable $exception): bool if ($exception instanceof CanceledException) { return true; } - } while (($exception = $exception->getPrevious()) !== null); + + // Google's service cause is separate from the exception PHP chains during cleanup. + if ($exception instanceof ServiceException + && ($cause = $exception->getServiceException()) !== null + && static::causedByCancellation($cause)) { + return true; + } + + $exception = $exception->getPrevious(); + } while ($exception !== null); return false; } + /** + * Keep the first failure unless a later failure carries this coroutine's cancellation. + */ + public static function preferCancellation(?Throwable $kept, Throwable $next): Throwable + { + return $kept === null || (static::causedByCancellation($next) && ! static::causedByCancellation($kept)) + ? $next + : $kept; + } + /** * Enable or disable exception reporting in coroutines. * diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index 5ee4bdc216..034f8ec998 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -451,8 +451,7 @@ public function recordDateFormat(): void */ public function release(): void { - $cancellationFailure = null; - $ordinaryFailure = null; + $failure = null; try { if ($this->lease !== null) { @@ -467,14 +466,12 @@ public function release(): void $this->markInvalid(); if (Coroutine::causedByCancellation($exception)) { - $cancellationFailure = $exception; + $failure = $exception; } else { try { $this->logger->error('Release connection failed: ' . $exception); - } catch (CanceledException $loggingCancellation) { - $cancellationFailure = $loggingCancellation; } catch (Throwable $loggingException) { - $ordinaryFailure = $loggingException; + $failure = $loggingException; } } } finally { @@ -490,27 +487,22 @@ public function release(): void $discard = true; $this->logger->error('Database transaction was not committed or rolled back before release.'); } - } catch (CanceledException $transactionCancellation) { - $discard = true; - $cancellationFailure ??= $transactionCancellation; } catch (Throwable $exception) { $discard = true; - $ordinaryFailure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } try { if (! $discard - && $cancellationFailure === null + && ($failure === null || ! Coroutine::causedByCancellation($failure)) && $this->connection !== null && ! $this->connection->isReusable() ) { $this->markInvalid(); $this->logger->warning('Database connection is not reusable, marking it as stale.'); } - } catch (CanceledException $stateCancellation) { - $cancellationFailure = $stateCancellation; } catch (Throwable $exception) { - $ordinaryFailure ??= $exception; + $failure = Coroutine::preferCancellation($failure, $exception); } $this->availableForReuse = ! $discard; @@ -521,18 +513,12 @@ public function release(): void } else { $this->pool->release($this); } - } catch (CanceledException $releaseCancellation) { - $cancellationFailure ??= $releaseCancellation; } catch (Throwable $exception) { - $ordinaryFailure ??= $exception; - } - - if ($cancellationFailure !== null) { - throw $cancellationFailure; + $failure = Coroutine::preferCancellation($failure, $exception); } - if ($ordinaryFailure !== null) { - throw $ordinaryFailure; + if ($failure !== null) { + throw $failure; } } diff --git a/src/docs/coroutines.md b/src/docs/coroutines.md index ffcc1fda2e..b1e7d2ded5 100644 --- a/src/docs/coroutines.md +++ b/src/docs/coroutines.md @@ -374,6 +374,8 @@ try { `Coroutine::causedByCancellation` also recognizes cancellation when a `finally` block throws another exception while unwinding it. Rethrowing preserves any cleanup error for the caller to report. A cancellation wrapped in `Hypervel\Coroutine\Exceptions\ForeignCancellationException` came from another coroutine, such as the owner of a shared operation this code was waiting for. If the chain reaches that exception before it reaches a cancellation, `causedByCancellation` returns `false`, so you can handle it as an ordinary failure. `ChildCancellationException` extends it for independently canceled children. +When collecting failures from several cleanup steps, `Coroutine::preferCancellation($kept, $next)` keeps the first failure, replacing an ordinary failure when a later one carries this coroutine's cancellation. + Cleanup that does not wait should remain in `finally` as usual. Code that owns child coroutines should use `wait`, `parallel`, or `WaitConcurrent` so Hypervel can cancel active children when their parent is canceled. diff --git a/src/filesystem/src/AwsS3V3Adapter.php b/src/filesystem/src/AwsS3V3Adapter.php index 11a279d5db..311e7bddbd 100644 --- a/src/filesystem/src/AwsS3V3Adapter.php +++ b/src/filesystem/src/AwsS3V3Adapter.php @@ -6,12 +6,13 @@ use Aws\S3\S3Client; use DateTimeInterface; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Http\StreamWrapper; use Hypervel\Support\Traits\Conditionable; use League\Flysystem\FilesystemAdapter as FlysystemAdapter; use League\Flysystem\FilesystemOperator; use League\Flysystem\UnableToReadFile; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class AwsS3V3Adapter extends FilesystemAdapter @@ -204,21 +205,15 @@ private function readStreamWithOptions(string $path, array $operationOptions = [ } $command = $this->client->getCommand('GetObject', $options); - $stream = $this->client->execute($command)->get('Body')->detach(); - } catch (CanceledException $exception) { - throw $exception; + // Decoder streams must retain cancellation and close their body with the resource. + return StreamWrapper::detach($this->client->execute($command)->get('Body')); } catch (Throwable $exception) { - throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); - } + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } - if (! is_resource($stream)) { - throw UnableToReadFile::fromLocation( - $path, - 'Downloaded object does not contain a file resource.', - ); + throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } - - return $stream; } /** diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index ec8c3bdbf3..814b65e61b 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -8,15 +8,17 @@ use Closure; use DateTimeInterface; use GuzzleHttp\Psr7\LimitStream; -use GuzzleHttp\Psr7\StreamWrapper; +use GuzzleHttp\Psr7\Stream; use GuzzleHttp\Psr7\Utils; use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Filesystem\Cloud as CloudFilesystemContract; use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\Concerns\TransfersFiles; use Hypervel\Http\File; use Hypervel\Http\Request; +use Hypervel\Http\StreamWrapper; use Hypervel\Http\UploadedFile; use Hypervel\Image\Image; use Hypervel\Image\ImageException; @@ -51,7 +53,6 @@ use Psr\Http\Message\UriInterface; use ReflectionFunction; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Throwable; @@ -276,7 +277,7 @@ public function get(string $path): ?string try { return $this->driver->read($path); } catch (UnableToReadFile $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -396,7 +397,16 @@ public function put(string $path, mixed $contents, mixed $options = []): bool|st try { if ($contents instanceof StreamInterface) { - $this->driver->writeStream($path, $contents->detach(), $options); + $stream = StreamWrapper::detach($contents); + + try { + $this->driver->writeStream($path, $stream, $options); + } finally { + // Laravel leaves detached native handles open; only our owned bridge needs closing here. + if ($contents::class !== Stream::class && is_resource($stream)) { + fclose($stream); + } + } return true; } @@ -405,7 +415,7 @@ public function put(string $path, mixed $contents, mixed $options = []): bool|st ? $this->driver->writeStream($path, $contents, $options) : $this->driver->write($path, $contents, $options); } catch (UnableToWriteFile|UnableToSetVisibility $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -488,7 +498,7 @@ public function setVisibility(string $path, string $visibility): bool try { $this->driver->setVisibility($path, $this->parseVisibility($visibility)); } catch (UnableToSetVisibility $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -549,7 +559,7 @@ public function delete(array|string $paths): bool try { $this->driver->delete($path); } catch (UnableToDeleteFile $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -570,7 +580,7 @@ public function copy(string $from, string $to): bool try { $this->driver->copy($from, $to); } catch (UnableToCopyFile $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -590,7 +600,7 @@ public function move(string $from, string $to): bool try { $this->driver->move($from, $to); } catch (UnableToMoveFile $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -620,7 +630,7 @@ public function checksum(string $path, array $options = []): false|string try { return $this->driver->checksum($path, $options); } catch (UnableToProvideChecksum $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -640,7 +650,7 @@ public function mimeType(string $path): false|string try { return $this->driver->mimeType($path); } catch (UnableToRetrieveMetadata $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -668,7 +678,7 @@ public function readStream(string $path): mixed try { return $this->driver->readStream($path); } catch (UnableToReadFile $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -742,7 +752,7 @@ public function readStreamRange(string $path, ?int $start, ?int $end): mixed $psrStream = Utils::streamFor($stream); $limitedStream = new LimitStream($psrStream, $length, $psrStream->tell()); - $range = StreamWrapper::getResource($limitedStream); + $range = StreamWrapper::detach($limitedStream); if (in_array(stream_get_meta_data($stream)['stream_type'], ['TEMP', 'MEMORY'], true)) { // Only mark this new wrapper; a driver's stream may share the default context. @@ -750,15 +760,16 @@ public function readStreamRange(string $path, ?int $start, ?int $end): mixed } return $range; - } catch (CanceledException $exception) { - if (is_resource($stream)) { - fclose($stream); - } - - throw $exception; } catch (Throwable $exception) { - if (is_resource($stream)) { - fclose($stream); + try { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + } finally { + // Closing a stream may fail too; keep any cancellation in its exception chain. + if (is_resource($stream)) { + fclose($stream); + } } $failure = UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); @@ -803,7 +814,7 @@ public function writeStream(string $path, mixed $resource, array $options = []): try { $this->driver->writeStream($path, $resource, $options); } catch (UnableToWriteFile|UnableToSetVisibility $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -1029,7 +1040,7 @@ public function makeDirectory(string $path): bool try { $this->driver->createDirectory($path); } catch (UnableToCreateDirectory|UnableToSetVisibility $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -1049,7 +1060,7 @@ public function deleteDirectory(string $directory): bool try { $this->driver->deleteDirectory($directory); } catch (UnableToDeleteDirectory $e) { - if ($this->throwsExceptions()) { + if ($this->shouldThrow($e)) { throw $e; } @@ -1181,6 +1192,14 @@ protected function throwsExceptions(): bool return (bool) ($this->config['throw'] ?? false); } + /** + * Determine whether failure policy or coroutine cancellation requires propagation. + */ + protected function shouldThrow(Throwable $exception): bool + { + return $this->throwsExceptions() || Coroutine::causedByCancellation($exception); + } + /** * Report the exception. * diff --git a/src/filesystem/src/GoogleCloudStorageAdapter.php b/src/filesystem/src/GoogleCloudStorageAdapter.php index 1615d4d7cb..7465166ade 100644 --- a/src/filesystem/src/GoogleCloudStorageAdapter.php +++ b/src/filesystem/src/GoogleCloudStorageAdapter.php @@ -7,12 +7,13 @@ use DateTimeInterface; use Google\Cloud\Storage\Bucket; use Google\Cloud\Storage\StorageClient; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Http\StreamWrapper; use Hypervel\Support\Arr; use League\Flysystem\FilesystemOperator; use League\Flysystem\GoogleCloudStorage\GoogleCloudStorageAdapter as FlysystemGoogleCloudAdapter; use League\Flysystem\UnableToReadFile; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class GoogleCloudStorageAdapter extends FilesystemAdapter @@ -140,21 +141,15 @@ private function readStreamWithOptions(string $path, array $options): mixed $prefixedPath = $this->prefixer->prefixPath($path); try { - $stream = $this->getBucket()->object($prefixedPath)->downloadAsStream($options)->detach(); - } catch (CanceledException $exception) { - throw $exception; + // Decoder streams must retain cancellation and close their body with the resource. + return StreamWrapper::detach($this->getBucket()->object($prefixedPath)->downloadAsStream($options)); } catch (Throwable $exception) { - throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); - } + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } - if (! is_resource($stream)) { - throw UnableToReadFile::fromLocation( - $path, - 'Downloaded object does not contain a file resource.', - ); + throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } - - return $stream; } private function getBucket(): Bucket diff --git a/src/filesystem/src/ReadThroughFilesystemAdapter.php b/src/filesystem/src/ReadThroughFilesystemAdapter.php index d81032a2d2..abb3050fde 100644 --- a/src/filesystem/src/ReadThroughFilesystemAdapter.php +++ b/src/filesystem/src/ReadThroughFilesystemAdapter.php @@ -4,8 +4,9 @@ namespace Hypervel\Filesystem; -use GuzzleHttp\Psr7\StreamWrapper; use GuzzleHttp\Psr7\Utils; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Http\StreamWrapper; use League\Flysystem\Config; use League\Flysystem\FileAttributes; use League\Flysystem\FilesystemAdapter; @@ -348,6 +349,10 @@ protected function readerFor(string $path): FilesystemOperator */ protected function handlePromotionFailure(string $path, FilesystemException $exception): void { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + if ($this->throwOnPromotionFailure) { throw UnableToReadFile::fromLocation($path, 'Unable to promote file to the primary filesystem.', $exception); } diff --git a/src/http/LICENSE.md b/src/http/LICENSE.md index 68de13f42d..45a72d4b3b 100644 --- a/src/http/LICENSE.md +++ b/src/http/LICENSE.md @@ -8,6 +8,13 @@ Copyright (c) Alexander Copyright (c) Barryvdh +Copyright (c) 2015 Michael Dowling +Copyright (c) 2015 Márk Sági-Kazár +Copyright (c) 2015 Graham Campbell +Copyright (c) 2016 Tobias Schultze +Copyright (c) 2016 George Mponos +Copyright (c) 2018 Tobias Nyholm + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights @@ -24,4 +31,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. \ No newline at end of file +THE SOFTWARE. diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index de52e9edb9..ad90119dbf 100644 --- a/src/http/src/Client/Response.php +++ b/src/http/src/Client/Response.php @@ -8,9 +8,9 @@ use Closure; use Generator; use GuzzleHttp\Cookie\CookieJar; -use GuzzleHttp\Psr7\StreamWrapper; use GuzzleHttp\TransferStats; use Hypervel\Http\Client\Concerns\DeterminesStatusCode; +use Hypervel\Http\StreamWrapper; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; use Hypervel\Support\Json; diff --git a/src/http/src/StreamWrapper.php b/src/http/src/StreamWrapper.php new file mode 100644 index 0000000000..5ef6fffded --- /dev/null +++ b/src/http/src/StreamWrapper.php @@ -0,0 +1,359 @@ +detach(); + + if (! is_resource($resource)) { + throw new RuntimeException('Unable to create stream resource'); + } + + return $resource; + } + + return self::createResource($stream, true); + } + + /** + * Create a stream context for a borrowed PHP stream resource. + * + * @return resource + */ + public static function createStreamContext(StreamInterface $stream): mixed + { + return stream_context_create([ + self::PROTOCOL => ['stream' => $stream], + ]); + } + + /** + * Create a PHP resource with the requested ownership. + * + * @return resource + */ + private static function createResource(StreamInterface $stream, bool $ownsStream): mixed + { + self::register(); + + if ($stream->isReadable()) { + $mode = $stream->isWritable() ? 'r+' : 'r'; + } elseif ($stream->isWritable()) { + $mode = 'w'; + } else { + throw new InvalidArgumentException('The stream must be readable, ' + . 'writable, or both.'); + } + + $context = self::createStreamContext($stream); + stream_context_set_option($context, self::PROTOCOL, 'owns_stream', $ownsStream); + $resource = @fopen(self::PROTOCOL . '://stream', $mode, false, $context); + + if ($resource === false) { + throw new RuntimeException('Unable to create stream resource'); + } + + return $resource; + } + + /** + * Register the stream wrapper protocol once per process. + */ + public static function register(): void + { + if (self::$registered) { + return; + } + + if (! stream_wrapper_register(self::PROTOCOL, self::class)) { + throw new RuntimeException('Unable to register the [' . self::PROTOCOL . '] stream wrapper protocol.'); + } + + self::$registered = true; + } + + /** + * Open the PSR-7 stream supplied through its resource context. + */ + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath = null): bool + { + $options = stream_context_get_options($this->context); + $stream = $options[self::PROTOCOL]['stream'] ?? null; + + if (! $stream instanceof StreamInterface) { + return false; + } + + $this->mode = $mode; + $this->stream = $stream; + $this->ownsStream = $options[self::PROTOCOL]['owns_stream'] ?? false; + + return true; + } + + /** + * Close an owned stream while leaving borrowed bodies with their owner. + */ + public function stream_close(): void + { + if ($this->ownsStream) { + $this->stream->close(); + } + } + + /** + * Read bytes while preserving cancellation through producer cleanup. + */ + public function stream_read(int $count): string|false + { + try { + return $this->stream->read($count); + } catch (RuntimeException $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + return false; + } + } + + /** + * Write bytes to the underlying stream. + */ + public function stream_write(string $data): int + { + try { + return $this->stream->write($data); + } catch (RuntimeException $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + return -1; + } + } + + /** + * Accept flushing for a PSR-7 stream without a separate flush operation. + */ + public function stream_flush(): bool + { + return true; + } + + /** + * Return the current stream position. + */ + public function stream_tell(): int|false + { + try { + return $this->stream->tell(); + } catch (RuntimeException $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + return false; + } + } + + /** + * Determine whether the underlying stream has ended. + */ + public function stream_eof(): bool + { + try { + return $this->stream->eof(); + } catch (RuntimeException $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + return true; + } + } + + /** + * Seek within the underlying stream. + */ + public function stream_seek(int $offset, int $whence): bool + { + try { + $this->stream->seek($offset, $whence); + } catch (RuntimeException $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + return false; + } + + return true; + } + + /** + * Expose a native descriptor without detaching the retained stream. + * + * @return false|resource + */ + public function stream_cast(int $castAs): mixed + { + // Decorator clones share their inner stream; detaching breaks the retained body, + // and its raw descriptor would bypass decoding or range limits. + if ($this->stream::class !== Stream::class) { + return false; + } + + return (clone $this->stream)->detach() ?? false; + } + + /** + * Return metadata for a stream with a known size. + * + * @return array{ + * dev: int, + * ino: int, + * mode: int, + * nlink: int, + * uid: int, + * gid: int, + * rdev: int, + * size: int, + * atime: int, + * mtime: int, + * ctime: int, + * blksize: int, + * blocks: int + * }|false + */ + public function stream_stat(): array|false + { + try { + $size = $this->stream->getSize(); + } catch (RuntimeException $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + return false; + } + + if ($size === null) { + return false; + } + + static $modeMap = [ + 'r' => 33060, + 'rb' => 33060, + 'r+' => 33206, + 'w' => 33188, + 'wb' => 33188, + ]; + + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => $modeMap[$this->mode] ?? 0, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => $size, + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => 0, + 'blocks' => 0, + ]; + } + + /** + * Return placeholder metadata for the wrapper URL. + * + * @return array{ + * dev: int, + * ino: int, + * mode: int, + * nlink: int, + * uid: int, + * gid: int, + * rdev: int, + * size: int, + * atime: int, + * mtime: int, + * ctime: int, + * blksize: int, + * blocks: int + * } + */ + public function url_stat(string $path, int $flags): array + { + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 0, + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => 0, + 'blocks' => 0, + ]; + } +} diff --git a/src/notifications/src/NotificationSender.php b/src/notifications/src/NotificationSender.php index fc0fe6b4d2..3eabaf178c 100644 --- a/src/notifications/src/NotificationSender.php +++ b/src/notifications/src/NotificationSender.php @@ -129,18 +129,21 @@ protected function sendToNotifiable(mixed $notifiable, string $id, mixed $notifi $response = $this->manager->driver($channel)->send($notifiable, $notification); } } catch (Throwable $exception) { - if (CoroutineContext::get(self::FAILED_EVENT_DISPATCHED_CONTEXT_KEY) !== true - && $this->events->hasListeners(NotificationFailed::class) - ) { - $this->events->dispatch(new NotificationFailed($notifiable, $notification, $channel, [ - // Queued listeners must serialize the event without the HTTP response. - 'exception' => $exception instanceof HttpTransportException - ? new TransportException($exception->getMessage(), $exception->getCode()) - : $exception, - ])); + // Failure observers must not hide cancellation and make a retry repeat delivery. + try { + throw $exception; + } finally { + if (CoroutineContext::get(self::FAILED_EVENT_DISPATCHED_CONTEXT_KEY) !== true + && $this->events->hasListeners(NotificationFailed::class) + ) { + $this->events->dispatch(new NotificationFailed($notifiable, $notification, $channel, [ + // Queued listeners must serialize the event without the HTTP response. + 'exception' => $exception instanceof HttpTransportException + ? new TransportException($exception->getMessage(), $exception->getCode()) + : $exception, + ])); + } } - - throw $exception; } finally { if ($previousFailureState === null) { CoroutineContext::forget(self::FAILED_EVENT_DISPATCHED_CONTEXT_KEY); diff --git a/src/passport/composer.json b/src/passport/composer.json index fcdfa73145..9f1294c96a 100644 --- a/src/passport/composer.json +++ b/src/passport/composer.json @@ -40,6 +40,7 @@ "hypervel/contracts": "^0.4", "hypervel/cookie": "^0.4", "hypervel/core": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/encryption": "^0.4", "hypervel/events": "^0.4", diff --git a/src/passport/src/Bridge/TokenIssuance.php b/src/passport/src/Bridge/TokenIssuance.php index fa5176d613..a3819445da 100644 --- a/src/passport/src/Bridge/TokenIssuance.php +++ b/src/passport/src/Bridge/TokenIssuance.php @@ -10,6 +10,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Context\NonCopyableContext; use Hypervel\Contracts\Container\Transient; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; @@ -29,7 +30,6 @@ use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; use LogicException; -use Swoole\Coroutine\CanceledException; use Throwable; class TokenIssuance implements NonCopyableContext, Transient @@ -121,12 +121,8 @@ public static function run(ServerFactory $servers, Closure $callback, array $att } catch (Throwable $exception) { try { $issuance->rollBack(); - } catch (CanceledException $cancellation) { - if (! $exception instanceof CanceledException) { - throw $cancellation; - } - } catch (Throwable) { - // A failed rollback must not replace the issuance failure. + } catch (Throwable $cleanupFailure) { + throw Coroutine::preferCancellation($exception, $cleanupFailure); } throw $exception; @@ -291,9 +287,11 @@ protected function commit(): void { try { $this->connection?->commit(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + if ($this->connection->transactionLevel() !== $this->transactionLevel) { throw $exception; } diff --git a/src/passport/src/PassportCache.php b/src/passport/src/PassportCache.php index 400a8b3b0d..eb20dab283 100644 --- a/src/passport/src/PassportCache.php +++ b/src/passport/src/PassportCache.php @@ -10,10 +10,10 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -160,9 +160,11 @@ protected function forgetMany(string $type, array $ids, Connection $connection): foreach ($ids as $id) { try { $this->coordinator->invalidate($cache, $this->getCacheKey($type, (string) $id)); - } catch (CanceledException $canceledException) { - throw $canceledException; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } diff --git a/src/pennant/composer.json b/src/pennant/composer.json index 1b4563f29a..76780fb71d 100644 --- a/src/pennant/composer.json +++ b/src/pennant/composer.json @@ -34,6 +34,7 @@ "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/events": "^0.4", "hypervel/foundation": "^0.4", diff --git a/src/pennant/src/Drivers/StoredValueCache.php b/src/pennant/src/Drivers/StoredValueCache.php index a3d3e0670a..13d018e7f7 100644 --- a/src/pennant/src/Drivers/StoredValueCache.php +++ b/src/pennant/src/Drivers/StoredValueCache.php @@ -5,11 +5,11 @@ namespace Hypervel\Pennant\Drivers; use Hypervel\Contracts\Cache\Repository; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionName; use Hypervel\Pennant\FeaturePartition; use Hypervel\Support\Str; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -174,9 +174,11 @@ protected function rotate(array $tokens): void if (! $cache->put($tokens, $ttl)) { $exception ??= new RuntimeException('Unable to invalidate cached feature values. They may be stale until they expire.'); } - } catch (CanceledException $canceledException) { - throw $canceledException; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } diff --git a/src/process/composer.json b/src/process/composer.json index 922d5f6f75..e720410441 100644 --- a/src/process/composer.json +++ b/src/process/composer.json @@ -27,6 +27,7 @@ "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/macroable": "^0.4", "hypervel/support": "^0.4", diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php index b9245e2246..ec9f5131c8 100644 --- a/src/process/src/InvokedProcess.php +++ b/src/process/src/InvokedProcess.php @@ -6,11 +6,11 @@ use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Process\Concerns\GuardsProcessOutput; use Hypervel\Process\Exceptions\ProcessTimedOutException; use Hypervel\Support\Traits\Macroable; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; use Symfony\Component\Process\Process; use Throwable; @@ -71,9 +71,7 @@ public function stop(float $timeout = 10, ?int $signal = null): ?int // A failed release or interrupted wait must not leave the child running. $this->process->stop(0, $signal); } catch (Throwable $cleanupException) { - if ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException) { - throw $cleanupException; - } + throw Coroutine::preferCancellation($exception, $cleanupException); } throw $exception; diff --git a/src/process/src/InvokedProcessPool.php b/src/process/src/InvokedProcessPool.php index 4d902851aa..1bff889147 100644 --- a/src/process/src/InvokedProcessPool.php +++ b/src/process/src/InvokedProcessPool.php @@ -7,9 +7,9 @@ use ArrayIterator; use Countable; use Hypervel\Contracts\Process\InvokedProcess; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Collection; use IteratorAggregate; -use Swoole\Coroutine\CanceledException; use Throwable; use Traversable; @@ -54,10 +54,7 @@ public function stop(float $timeout = 10, ?int $signal = null): Collection $running[] = $process; } } catch (Throwable $throwable) { - if ($exception === null - || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } if (! $shouldStop) { @@ -67,10 +64,7 @@ public function stop(float $timeout = 10, ?int $signal = null): Collection try { $process->stop($timeout, $signal); } catch (Throwable $throwable) { - if ($exception === null - || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -108,9 +102,7 @@ public function wait(): ProcessPoolResults // Stopping bounds cleanup when a sibling depends on external progress that may never arrive. $this->stop(0); } catch (Throwable $cleanupException) { - if ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException) { - throw $cleanupException; - } + throw Coroutine::preferCancellation($exception, $cleanupException); } throw $exception; diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index d65668e515..d435f57c1c 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -8,6 +8,7 @@ use Closure; use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Process\Concerns\GuardsProcessOutput; use Hypervel\Process\Exceptions\ProcessTimedOutException; @@ -17,7 +18,6 @@ use LogicException; use RuntimeException; use Stringable; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; use Symfony\Component\Process\Process; use Throwable; @@ -275,9 +275,7 @@ private function stopAfterFailure(Process $process, Throwable $exception): never try { (new InvokedProcess($process))->stop(0); } catch (Throwable $cleanupException) { - if ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException) { - throw $cleanupException; - } + throw Coroutine::preferCancellation($exception, $cleanupException); } throw $exception; diff --git a/src/process/src/Pool.php b/src/process/src/Pool.php index 252231dd59..ab5cfd7d9b 100644 --- a/src/process/src/Pool.php +++ b/src/process/src/Pool.php @@ -5,8 +5,8 @@ namespace Hypervel\Process; use Closure; +use Hypervel\Coroutine\Coroutine; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -76,9 +76,7 @@ public function start(?callable $output = null): InvokedProcessPool // Keep partial construction transactional instead of abandoning already-started children. (new InvokedProcessPool($invokedProcesses))->stop(0); } catch (Throwable $cleanupException) { - if ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException) { - throw $cleanupException; - } + throw Coroutine::preferCancellation($exception, $cleanupException); } throw $exception; diff --git a/src/redis/src/Limiters/ConcurrencyLimiter.php b/src/redis/src/Limiters/ConcurrencyLimiter.php index 49b2faa28b..89c26b39db 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiter.php +++ b/src/redis/src/Limiters/ConcurrencyLimiter.php @@ -5,6 +5,7 @@ namespace Hypervel\Redis\Limiters; use Hypervel\Contracts\Limiters\LimiterTimeoutException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Redis\LuaScripts; use Hypervel\Redis\RedisConnection; @@ -106,9 +107,7 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 250 try { $lease->release(); } catch (Throwable $exception) { - if ($callbackException === null) { - throw $exception; - } + throw Coroutine::preferCancellation($callbackException, $exception); } if ($callbackException !== null) { diff --git a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php index e74611e88c..5ed9d89b31 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php @@ -7,6 +7,7 @@ use DateInterval; use DateTimeInterface; use Hypervel\Contracts\Limiters\LimiterTimeoutException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\RedisProxy; use Hypervel\Support\InteractsWithTime; use Throwable; @@ -125,9 +126,7 @@ public function then(callable $callback, ?callable $failure = null): mixed try { $lease->release(); } catch (Throwable $exception) { - if ($callbackException === null) { - throw $exception; - } + throw Coroutine::preferCancellation($callbackException, $exception); } if ($callbackException !== null) { diff --git a/src/redis/src/RedisCancellation.php b/src/redis/src/RedisCancellation.php index 60f25ced93..20228a56d6 100644 --- a/src/redis/src/RedisCancellation.php +++ b/src/redis/src/RedisCancellation.php @@ -4,7 +4,8 @@ namespace Hypervel\Redis; -use Hypervel\Engine\Coroutine; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Engine\Coroutine as EngineCoroutine; use RedisClusterException; use RedisException; use Swoole\Coroutine\CanceledException; @@ -27,7 +28,7 @@ public static function cancellationFrom(Throwable $exception, string $message): } if (($exception instanceof RedisException || $exception instanceof RedisClusterException) - && Coroutine::isCanceled() + && EngineCoroutine::isCanceled() ) { return new CanceledException($message, previous: $exception); } @@ -42,11 +43,11 @@ public static function throwOperationOrCleanupFailure( ?Throwable $operationFailure, ?Throwable $cleanupFailure, ): void { - if ($operationFailure instanceof CanceledException) { + if ($operationFailure !== null && Coroutine::causedByCancellation($operationFailure)) { throw $operationFailure; } - if ($cleanupFailure instanceof CanceledException) { + if ($cleanupFailure !== null && Coroutine::causedByCancellation($cleanupFailure)) { throw $cleanupFailure; } diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index c4f74775c9..78bd50ccb3 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -217,14 +217,11 @@ public function __call(string $name, array $arguments): mixed ? $connection->discardTransaction() : $connection->{$name}(...$arguments); } catch (Throwable $throwable) { - if ($cancellation = RedisCancellation::cancellationFrom( + $commandException = RedisCancellation::cancellationFrom( $throwable, "Executing Redis command [{$name}] was canceled.", - )) { - $commandException = $cancellation; - } else { - $commandException = $throwable; - + ) ?? $throwable; + if (! Coroutine::causedByCancellation($commandException)) { try { $dispatcher = $connection->getEventDispatcher(); @@ -289,15 +286,15 @@ public function __call(string $name, array $arguments): mixed $cleanupException = $throwable; } - if ($commandException instanceof CanceledException) { + if ($commandException !== null && Coroutine::causedByCancellation($commandException)) { throw $commandException; } - if ($eventException instanceof CanceledException) { + if ($eventException !== null && Coroutine::causedByCancellation($eventException)) { throw $eventException; } - if ($cleanupException instanceof CanceledException) { + if ($cleanupException !== null && Coroutine::causedByCancellation($cleanupException)) { throw $cleanupException; } @@ -421,7 +418,7 @@ protected function handleSubscribe(string $name, array $arguments): void try { $subscriber->close(); } catch (Throwable $exception) { - if (! $failure instanceof CanceledException) { + if ($failure === null || ! Coroutine::causedByCancellation($failure)) { // Required close failure is terminal unless cancellation is already primary. $failure = $exception; } @@ -674,11 +671,11 @@ public function subscriber(): Subscriber // Preserve discovery failures over ordinary release failures while // still allowing cancellation from either boundary to take precedence. - if ($discoveryException instanceof CanceledException) { + if ($discoveryException !== null && Coroutine::causedByCancellation($discoveryException)) { throw $discoveryException; } - if ($releaseException instanceof CanceledException) { + if ($releaseException !== null && Coroutine::causedByCancellation($releaseException)) { throw $releaseException; } diff --git a/src/saloon/src/OAuthAccounts.php b/src/saloon/src/OAuthAccounts.php index 670c3a2c55..a420c289c0 100644 --- a/src/saloon/src/OAuthAccounts.php +++ b/src/saloon/src/OAuthAccounts.php @@ -16,6 +16,7 @@ use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Http\Client\ConnectionException; use Hypervel\Http\Client\RequestException; @@ -34,7 +35,6 @@ use Psr\Http\Message\ResponseInterface; use RuntimeException; use SensitiveParameter; -use Swoole\Coroutine\CanceledException; use Throwable; use UnexpectedValueException; @@ -300,10 +300,8 @@ function () use ($identity, $refreshUsing, $attempt, &$retryDelay): OAuthAuthent return $this->renew($identity, $refreshUsing, $attempt, $retryDelay); }, ); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { - if ($retryDelay === null) { + if ($retryDelay === null || Coroutine::causedByCancellation($exception)) { throw $exception; } @@ -381,8 +379,11 @@ protected function renew(OAuthAccountIdentity $identity, Closure $refreshUsing, try { $renewed = $refreshUsing($current); } catch (Throwable $exception) { - $oauthError = $this->oauthError($exception); - $retryDelay = $attempt < 3 ? $this->retryDelay($exception, $oauthError, $attempt) : null; + if (! Coroutine::causedByCancellation($exception)) { + $oauthError = $this->oauthError($exception); + $retryDelay = $attempt < 3 ? $this->retryDelay($exception, $oauthError, $attempt) : null; + } + throw $exception; } @@ -425,10 +426,15 @@ protected function renew(OAuthAccountIdentity $identity, Closure $refreshUsing, } catch (Throwable $cleanupFailure) { $retryDelay = null; $oauthError = null; - try { - $this->exceptions->report($cleanupFailure); - } catch (Throwable) { - // Reporting must not replace the provider failure or cancellation. + if (Coroutine::causedByCancellation($cleanupFailure)) { + $exception = Coroutine::preferCancellation($exception, $cleanupFailure); + } else { + try { + $this->exceptions->report($cleanupFailure); + } catch (Throwable $reportingFailure) { + // Ordinary reporting failures must not replace the provider failure. + $exception = Coroutine::preferCancellation($exception, $reportingFailure); + } } } diff --git a/src/session/composer.json b/src/session/composer.json index ad70f102d6..9aebda387b 100644 --- a/src/session/composer.json +++ b/src/session/composer.json @@ -36,6 +36,7 @@ "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/cookie": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/http": "^0.4", diff --git a/src/session/src/RedisSessionHandler.php b/src/session/src/RedisSessionHandler.php index da19f97bfc..eb06611fb2 100644 --- a/src/session/src/RedisSessionHandler.php +++ b/src/session/src/RedisSessionHandler.php @@ -9,6 +9,7 @@ use Hypervel\Context\RequestContext; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Redis\Factory as RedisFactory; +use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\RedisConnection; use Hypervel\Session\Concerns\ValidatesUserSessionArguments; use Hypervel\Session\Contracts\CanManageUserSessions; @@ -764,6 +765,10 @@ protected function destroyUserSessionsInCluster( try { $this->cleanUserIndex($connection, $owner, $cleanupSessionIds); } catch (Throwable $cleanupFailure) { + if (Coroutine::causedByCancellation($payloadFailure)) { + throw $payloadFailure; + } + throw new RuntimeException( sprintf( 'Redis session payload deletion failed with %s: %s', diff --git a/src/socialite/composer.json b/src/socialite/composer.json index 8350178f83..a772072845 100644 --- a/src/socialite/composer.json +++ b/src/socialite/composer.json @@ -36,6 +36,7 @@ "hypervel/collections": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/guzzle": "^8.3", "hypervel/http": "^0.4", "hypervel/support": "^0.4" diff --git a/src/socialite/src/Two/BitbucketProvider.php b/src/socialite/src/Two/BitbucketProvider.php index 9207cea2dd..8642db9059 100644 --- a/src/socialite/src/Two/BitbucketProvider.php +++ b/src/socialite/src/Two/BitbucketProvider.php @@ -6,6 +6,7 @@ use Exception; use GuzzleHttp\RequestOptions; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Arr; use SensitiveParameter; @@ -55,7 +56,11 @@ protected function getEmailByToken(#[SensitiveParameter] string $token): ?string $response = $this->getHttpClient()->get('https://api.bitbucket.org/2.0/user/emails', [ RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], ]); - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return null; } diff --git a/src/socialite/src/Two/GithubProvider.php b/src/socialite/src/Two/GithubProvider.php index 137e628056..ac3cea1935 100644 --- a/src/socialite/src/Two/GithubProvider.php +++ b/src/socialite/src/Two/GithubProvider.php @@ -6,6 +6,7 @@ use Exception; use GuzzleHttp\RequestOptions; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Arr; use SensitiveParameter; @@ -56,7 +57,11 @@ protected function getEmailByToken(#[SensitiveParameter] string $token): ?string $emailsUrl, $this->getRequestOptions($token) ); - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return null; } diff --git a/src/support/src/SafeCaller.php b/src/support/src/SafeCaller.php index fab5efda71..90e81f20cf 100644 --- a/src/support/src/SafeCaller.php +++ b/src/support/src/SafeCaller.php @@ -7,7 +7,7 @@ use Closure; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; class SafeCaller @@ -32,15 +32,19 @@ public function call(Closure $closure, ?Closure $default = null): mixed { try { return $closure(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + if ($this->container->has(ExceptionHandlerContract::class)) { try { $this->container->make(ExceptionHandlerContract::class)->report($exception); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $reportingFailure) { + if (Coroutine::causedByCancellation($reportingFailure)) { + throw $reportingFailure; + } + try { error_log((string) $exception . PHP_EOL . (string) $reportingFailure); } catch (Throwable) { diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 568f88ab04..7ecd79508d 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -7,6 +7,7 @@ use Closure; use Countable; use DateTimeInterface; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use League\CommonMark\Environment\Environment; @@ -17,7 +18,6 @@ use League\CommonMark\MarkdownConverter; use LogicException; use Stringable as BaseStringable; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\UuidV7; @@ -1102,9 +1102,11 @@ private static function toStringOr(mixed $value, string $fallback): string { try { return (string) $value; - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return $fallback; } } diff --git a/src/support/src/Timebox.php b/src/support/src/Timebox.php index 430c6ab0a1..c3e0b001ad 100644 --- a/src/support/src/Timebox.php +++ b/src/support/src/Timebox.php @@ -4,7 +4,7 @@ namespace Hypervel\Support; -use Swoole\Coroutine\CanceledException; +use Hypervel\Coroutine\Coroutine; use Throwable; class Timebox @@ -32,9 +32,11 @@ public function call(callable $callback, int $microseconds): mixed try { $result = $callback($this); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $caught) { + if (Coroutine::causedByCancellation($caught)) { + throw $caught; + } + $exception = $caught; } diff --git a/src/validation/composer.json b/src/validation/composer.json index c5425eeb1c..18a24651b6 100644 --- a/src/validation/composer.json +++ b/src/validation/composer.json @@ -35,6 +35,7 @@ "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", diff --git a/src/validation/src/NotPwnedVerifier.php b/src/validation/src/NotPwnedVerifier.php index 931e204887..42562215da 100644 --- a/src/validation/src/NotPwnedVerifier.php +++ b/src/validation/src/NotPwnedVerifier.php @@ -6,6 +6,7 @@ use Exception; use Hypervel\Contracts\Validation\UncompromisedVerifier; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Client\Factory as HttpClientFactory; use Hypervel\Support\Collection; use Hypervel\Support\Stringable; @@ -70,6 +71,10 @@ protected function search(string $hashPrefix): Collection 'https://api.pwnedpasswords.com/range/' . $hashPrefix ); } catch (Exception $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + report($e); } diff --git a/src/view/composer.json b/src/view/composer.json index 4647b4fec6..7ac51091b3 100644 --- a/src/view/composer.json +++ b/src/view/composer.json @@ -31,6 +31,7 @@ "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/events": "^0.4", "hypervel/filesystem": "^0.4", diff --git a/src/view/src/Factory.php b/src/view/src/Factory.php index d5db8d8b07..02b948075c 100755 --- a/src/view/src/Factory.php +++ b/src/view/src/Factory.php @@ -12,11 +12,11 @@ use Hypervel\Contracts\View\Engine; use Hypervel\Contracts\View\Factory as FactoryContract; use Hypervel\Contracts\View\View as ViewContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; use Hypervel\View\Engines\EngineResolver; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; use Throwable; class Factory implements FactoryContract @@ -374,9 +374,11 @@ public function notifyRendered(ViewContract $view, ?Throwable $exception): void foreach ($this->renderedObservers as $observer) { try { $observer($view, $exception); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $observerException ??= $throwable; } } diff --git a/src/view/src/View.php b/src/view/src/View.php index 5d78a62b81..87a764365a 100755 --- a/src/view/src/View.php +++ b/src/view/src/View.php @@ -13,6 +13,7 @@ use Hypervel\Contracts\Support\Renderable; use Hypervel\Contracts\View\Engine; use Hypervel\Contracts\View\View as ViewContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Collection; use Hypervel\Support\HtmlString; use Hypervel\Support\MessageBag; @@ -20,7 +21,6 @@ use Hypervel\Support\Traits\Macroable; use Hypervel\Support\ViewErrorBag; use Stringable; -use Swoole\Coroutine\CanceledException; use Throwable; class View implements ArrayAccess, Htmlable, Stringable, ViewContract @@ -181,18 +181,18 @@ protected function renderContentsWithObservers(): string try { $this->factory->notifyRendering($this); $contents = $this->getContents(); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception = $throwable; } try { $this->factory->notifyRendered($this, $exception); - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + $exception = Coroutine::preferCancellation($exception, $throwable); } if ($exception !== null) { diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php index 155bb7d0e4..5a79f604cf 100644 --- a/tests/Bus/BusBatchTest.php +++ b/tests/Bus/BusBatchTest.php @@ -8,16 +8,20 @@ use Hypervel\Bus\Batch; use Hypervel\Bus\Batchable; use Hypervel\Bus\BatchFactory; +use Hypervel\Bus\BatchRepository; use Hypervel\Bus\DatabaseBatchRepository; use Hypervel\Bus\Events\BatchCanceled; use Hypervel\Bus\Events\BatchFinished; use Hypervel\Bus\Events\BatchStarted; use Hypervel\Bus\PendingBatch; use Hypervel\Bus\Queueable; +use Hypervel\Bus\UpdatedBatchJobCounts; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Queue\Factory; use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\PostgresConnection; use Hypervel\Database\Query\Builder; @@ -34,7 +38,9 @@ use Hypervel\Testbench\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Throwable; class BusBatchTest extends TestCase @@ -265,6 +271,36 @@ public function testProcessedJobsCanBeCalculated(): void $this->assertEquals(60, $batch->progress()); } + #[TestWith([false], 'ordinary callback failure')] + #[TestWith([true], 'wrapped cancellation')] + public function testProgressContinuesOnlyAfterOrdinaryCallbackFailures(bool $canceled): void + { + $failure = new RuntimeException('Progress failed.', previous: $canceled ? new CanceledException : null); + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->times($canceled ? 0 : 1)->with($failure, []); + $this->app->instance(ExceptionHandler::class, $handler); + $repository = m::mock(BatchRepository::class); + $continued = false; + $batch = new Batch(m::mock(Factory::class), $repository, 'batch', 'batch', 2, 2, 0, [], [ + 'progress' => [ + static fn () => throw $failure, + static function () use (&$continued): void { $continued = true; }, + ], + ], CarbonImmutable::now()); + $repository->shouldReceive('find')->with('batch')->andReturn($batch); + $repository->shouldReceive('decrementPendingJobs')->once()->andReturn(new UpdatedBatchJobCounts(1)); + $caught = null; + + try { + $batch->recordSuccessfulJob('job'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($canceled ? $failure : null, $caught); + $this->assertSame(! $canceled, $continued); + } + public function testSuccessfulJobsCanBeRecorded(): void { $queue = m::mock(Factory::class); @@ -823,6 +859,44 @@ public function testOptionsSerializationOnPostgres(): void }); } + #[TestWith([false], 'ordinary restoration failure')] + #[TestWith([true], 'wrapped cancellation')] + public function testOptionsRestorationFallbackDoesNotSwallowCancellation(bool $canceled): void + { + $factory = m::mock(BatchFactory::class); + $connection = m::mock(PostgresConnection::class); + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->andReturn($connection); + $connection->shouldReceive('table->useWritePdo->where->first')->once()->andReturn((object) [ + 'id' => 'batch', 'name' => '', 'total_jobs' => 0, 'pending_jobs' => 0, + 'failed_jobs' => 0, 'failed_job_ids' => '[]', + 'options' => serialize([new BatchOptionWithFailingRestoration($canceled)]), + 'created_at' => now()->getTimestamp(), 'cancelled_at' => null, 'finished_at' => null, + ]); + $repository = new DatabaseBatchRepository($factory, $resolver, 'job_batches'); + if ($canceled) { + $factory->shouldNotReceive('make'); + } else { + $factory->shouldReceive('make')->once() + ->withSomeOfArgs($repository, 'batch', '', 0, 0, 0, [], []) + ->andReturn(m::mock(Batch::class)); + } + $caught = null; + + try { + $repository->find('batch'); + } catch (Throwable $exception) { + $caught = $exception; + } + + if ($canceled) { + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } else { + $this->assertNull($caught); + } + } + #[DataProvider('serializedOptions')] public function testOptionsUnserializeOnPostgres(string $serialize, array $options): void { @@ -905,6 +979,24 @@ protected function createTestBatch(Factory $queue, bool $allowFailures = false): } } +class BatchOptionWithFailingRestoration +{ + /** + * Configure a canceled or ordinary application-object restoration failure. + */ + public function __construct(private bool $canceled) + { + } + + /** + * Fail while restoring a stored batch option. + */ + public function __wakeup(): void + { + throw new RuntimeException('Option restoration failed.', previous: $this->canceled ? new CanceledException : null); + } +} + class TestBatchJob implements ShouldQueue { use Batchable; diff --git a/tests/Bus/BusDebounceLockTest.php b/tests/Bus/BusDebounceLockTest.php index 7db7d2d822..e3f52c0e8f 100644 --- a/tests/Bus/BusDebounceLockTest.php +++ b/tests/Bus/BusDebounceLockTest.php @@ -12,7 +12,10 @@ use Hypervel\Queue\Attributes\DebounceFor; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -59,12 +62,14 @@ public function testReleaseProbeFailureDoesNotRemoveEitherDebounceRecord(): void } } - public function testAcquisitionFailureRemainsPrimaryWhenCleanupProbeFails(): void + #[TestWith([false], 'ordinary cleanup failure')] + #[TestWith([true], 'wrapped cleanup cancellation')] + public function testAcquisitionFailureRemainsPrimaryWhenCleanupProbeFails(bool $canceled): void { $job = new BusDebounceLockJob('entity-1'); $key = DebounceLock::getKey($job); $failure = new RuntimeException('Maximum-wait read failed.'); - $cleanupFailure = new RuntimeException('Cleanup owner probe failed.'); + $cleanupFailure = new RuntimeException('Cleanup owner probe failed.', previous: $canceled ? new CanceledException : null); $cache = m::mock(Cache::class); $cache->shouldReceive('put')->once()->with($key, m::type('string'), 300)->andReturnTrue(); $cache->shouldReceive('get')->once()->with($key . ':first_dispatched_at')->andThrow($failure); @@ -75,9 +80,26 @@ public function testAcquisitionFailureRemainsPrimaryWhenCleanupProbeFails(): voi (new DebounceLock($cache))->acquire($job); $this->fail('Expected the maximum-wait read failure was not thrown.'); } catch (RuntimeException $exception) { - $this->assertSame($failure, $exception); + $this->assertSame($canceled ? $cleanupFailure : $failure, $exception); } } + + public function testReleasePreservesCancellationFromSecondCleanup(): void + { + $failure = new RuntimeException('Cache cleanup failed.', previous: new CanceledException); + $cache = m::mock(Cache::class); + $cache->shouldReceive('forget')->once()->with('key')->andThrow(new RuntimeException('First deletion failed.')); + $cache->shouldReceive('forget')->once()->with('key:first_dispatched_at')->andThrow($failure); + $caught = null; + + try { + DebounceLock::releaseOwned($cache, 'key'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + } } #[DebounceFor(30, maxWait: 60)] diff --git a/tests/Bus/BusPendingBatchTest.php b/tests/Bus/BusPendingBatchTest.php index a53b035822..1c7cde0d56 100644 --- a/tests/Bus/BusPendingBatchTest.php +++ b/tests/Bus/BusPendingBatchTest.php @@ -13,17 +13,107 @@ use Hypervel\Bus\Queueable; use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcher; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Events\Dispatcher as EventsDispatcher; use Hypervel\Support\Collection; use Hypervel\Support\Testing\Fakes\EventFake; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; +use Swoole\Coroutine\CanceledException; +use Throwable; class BusPendingBatchTest extends TestCase { + #[TestWith(['direct'])] + #[TestWith(['wrapped'])] + #[TestWith(['deletion failure'])] + #[TestWith(['after response'])] + #[TestWith(['reporting'])] + public function testBeforeCallbackCancellationStopsBatchDispatch(string $scenario): void + { + $deleteThrows = $scenario === 'deletion failure'; + $reporting = $scenario === 'reporting'; + $cancellation = new CanceledException; + $failure = $scenario === 'direct' ? $cancellation : new RuntimeException('Before cleanup failed.', previous: $cancellation); + $container = new Container; + $repository = m::mock(BatchRepository::class); + $batch = m::mock(Batch::class); + $batch->id = 'batch'; + $batch->shouldNotReceive('add'); + $repository->shouldReceive('store')->once()->andReturn($batch); + $deletionFailure = new RuntimeException('Batch deletion failed.'); + $deletion = $repository->shouldReceive('delete')->once()->with('batch'); + $deleteThrows ? $deletion->andThrow($deletionFailure) : $deletion->andReturnNull(); + $container->instance(BatchRepository::class, $repository); + if ($reporting) { + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->once()->andThrow($failure); + $container->instance(ExceptionHandler::class, $handler); + Container::setInstance($container); + } + $callbackFailure = $reporting ? new RuntimeException('Before callback failed.') : $failure; + $pending = (new PendingBatch($container, new Collection))->before(static fn () => throw $callbackFailure); + $caught = null; + + try { + $scenario === 'after response' ? $pending->dispatchAfterResponse() : $pending->dispatch(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($deleteThrows ? $deletionFailure : $failure, $caught); + if ($deleteThrows) { + $this->assertSame($failure, $caught->getPrevious()); + } + } + + #[TestWith([false], 'new batch')] + #[TestWith([true], 'stored batch')] + public function testDeletionFailureRetainsDispatchCancellation(bool $stored): void + { + $cancellation = new CanceledException; + $cleanup = new RuntimeException('Batch deletion failed.'); + $container = new Container; + $repository = m::mock(BatchRepository::class); + $batch = m::mock(Batch::class); + $batch->id = 'batch'; + $batch->shouldReceive('add')->once()->andThrow($cancellation); + $container->instance(BatchRepository::class, $repository); + + if ($stored) { + $batch->shouldReceive('delete')->once()->andThrow($cleanup); + } else { + $repository->shouldReceive('store')->once()->andReturn($batch); + $repository->shouldReceive('delete')->once()->with('batch')->andThrow($cleanup); + } + + $pending = new class($container, new Collection) extends PendingBatch { + /** + * Exercise the same stored-batch dispatch used after a response. + */ + public function dispatchStored(Batch $batch): void + { + $this->dispatchExistingBatch($batch); + } + }; + $caught = null; + + try { + $stored ? $pending->dispatchStored($batch) : $pending->dispatch(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($cleanup, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + public function testPendingBatchNormalizesEnumConnectionAndQueueIdentifiers(): void { $job = new class { diff --git a/tests/Bus/DispatchLockContextTest.php b/tests/Bus/DispatchLockContextTest.php index b59d2cb2cc..ec2550c54e 100644 --- a/tests/Bus/DispatchLockContextTest.php +++ b/tests/Bus/DispatchLockContextTest.php @@ -12,6 +12,7 @@ use Hypervel\Cache\Repository; use Hypervel\Cache\WorkerArrayStore; use Hypervel\Container\Container; +use Hypervel\Contracts\Cache\Lock; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; @@ -33,6 +34,32 @@ class DispatchLockContextTest extends TestCase { + public function testReleaseAttemptsBothLocksAndPreservesCancellationFromDebounceCleanup(): void + { + $failure = new RuntimeException('Debounce cleanup failed.', previous: new CanceledException); + $unique = m::mock(Cache::class); + $lock = m::mock(Lock::class); + $unique->shouldReceive('lock')->once()->with('unique')->andReturn($lock); + $lock->shouldReceive('forceRelease')->once()->andThrow(new RuntimeException('Unique release failed.')); + $debounce = m::mock(Cache::class); + $debounce->shouldReceive('get')->once()->with('debounce')->andReturn('owner'); + $debounce->shouldReceive('forget')->once()->with('debounce')->andThrow($failure); + $debounce->shouldReceive('forget')->once()->with('debounce:first_dispatched_at')->andReturnTrue(); + $job = new DispatchLockContextJob('cleanup'); + DispatchLockContext::registerUnique($job, $unique, null, 'unique', ''); + DispatchLockContext::registerDebounce($job, $debounce, 'debounce', 'owner'); + $caught = null; + + try { + DispatchLockContext::release($job); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse(DispatchLockContext::has($job)); + } + public function testMetadataRemainsAvailableUntilTheExactJobIsAccepted(): void { $cache = new Repository(new WorkerArrayStore, ['store' => 'unique']); diff --git a/tests/Cache/CacheEventsTest.php b/tests/Cache/CacheEventsTest.php index 58990560c7..02c28fb2a6 100644 --- a/tests/Cache/CacheEventsTest.php +++ b/tests/Cache/CacheEventsTest.php @@ -27,6 +27,7 @@ use Hypervel\Cache\NamespacedTaggedCache; use Hypervel\Cache\Repository; use Hypervel\Cache\VersionedTagSet; +use Hypervel\Contracts\Cache\AuthoritativeRawReadable; use Hypervel\Contracts\Cache\CanFlushLocks; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; @@ -36,6 +37,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class CacheEventsTest extends TestCase { @@ -375,9 +377,13 @@ public function testCancellationDoesNotDispatchFailureEvents( string $repositoryMethod, array $repositoryArguments, string $startedEvent, + bool $cleanupThrows, ): void { $cancellation = new CanceledException('cache operation canceled'); - $store = m::mock(Store::class, CanFlushLocks::class); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } + $store = m::mock(Store::class, CanFlushLocks::class, AuthoritativeRawReadable::class); $store->shouldReceive('supportsFlushingLocks')->andReturnTrue()->byDefault(); $store->expects($storeMethod) ->with(...$storeArguments) @@ -389,7 +395,7 @@ public function testCancellationDoesNotDispatchFailureEvents( try { $repository->{$repositoryMethod}(...$repositoryArguments); $this->fail('The cache operation was expected to be canceled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -399,10 +405,11 @@ public function testCancellationDoesNotDispatchFailureEvents( /** * Provide cache operations that propagate coroutine cancellation. */ - public static function repositoryCancellationOperations(): array + public static function repositoryCancellationOperations(): iterable { - return [ + $operations = [ 'get' => ['get', ['foo'], 'get', ['foo'], RetrievingKey::class], + 'authoritative get' => ['getAuthoritativeRaw', ['foo'], 'getAuthoritativeRaw', ['foo'], RetrievingKey::class], 'many' => ['many', [['foo', 'bar']], 'many', [['foo', 'bar']], RetrievingManyKeys::class], 'put' => ['put', ['foo', 'bar', 60], 'put', ['foo', 'bar', 60], WritingKey::class], 'put many' => ['putMany', [['foo' => 'bar'], 60], 'putMany', [['foo' => 'bar'], 60], WritingManyKeys::class], @@ -411,6 +418,11 @@ public static function repositoryCancellationOperations(): array 'clear' => ['flush', [], 'clear', [], CacheFlushing::class], 'flush locks' => ['flushLocks', [], 'flushLocks', [], CacheLocksFlushing::class], ]; + + foreach ($operations as $name => $arguments) { + yield $name => [...$arguments, false]; + yield $name . ' with cleanup failure' => [...$arguments, true]; + } } public function testPullTriggersEvents(): void diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 0f52cec3d6..da4139d13b 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -18,6 +18,7 @@ use Hypervel\Support\Sleep; use Hypervel\Support\Str; use Hypervel\Testing\ParallelTesting; +use Hypervel\Tests\Cache\Fixtures\CanceledDeserialization; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; @@ -27,6 +28,7 @@ use stdClass; use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Process; +use Throwable; class CacheFileStoreTest extends TestCase { @@ -193,9 +195,34 @@ public function testRemainingSecondsPreservesCancellationFromFilesystemRead(): v } } - public function testGetPreservesCancellationFromSerializableClassPolicy(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testCanceledDeserializationDoesNotDeleteTheCachedValue(bool $wrapped): void { - $cancellation = new CanceledException('policy canceled'); + $store = new FileStore($this->filesystem, $this->tempDir); + $store->put('foo', new CanceledDeserialization($wrapped), 60); + $caught = null; + + try { + $store->get('foo'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertNotNull($caught); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + $this->assertFileExists($store->path('foo')); + } + + #[TestWith(['get', false], 'value direct cancellation')] + #[TestWith(['get', true], 'value wrapped cancellation')] + #[TestWith(['getLockOwner', false], 'owner direct cancellation')] + #[TestWith(['getLockOwner', true], 'owner wrapped cancellation')] + public function testSerializableClassPolicyCancellationEscapesReads(string $method, bool $wrapped): void + { + $cancellation = $wrapped + ? new RuntimeException('Policy cleanup failed.', previous: new CanceledException) + : new CanceledException('policy canceled'); $files = $this->mockFilesystem(); $files->expects($this->once()) ->method('get') @@ -207,13 +234,15 @@ public function testGetPreservesCancellationFromSerializableClassPolicy(): void static fn (): never => throw $cancellation, ), ); + $caught = null; try { - $store->get('foo'); - $this->fail('Resolving the serialization policy was expected to be canceled.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + $store->{$method}('foo'); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($cancellation, $caught); } public function testPutCreatesMissingDirectories(): void diff --git a/tests/Cache/CacheLockTest.php b/tests/Cache/CacheLockTest.php index 907f1ff64b..72b92ff7f0 100644 --- a/tests/Cache/CacheLockTest.php +++ b/tests/Cache/CacheLockTest.php @@ -9,6 +9,7 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Sleep; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -55,15 +56,22 @@ public function testGetPreservesCallbackCancellationWhenReleaseIsCanceled(): voi $this->assertTrue($lock->released); } - public function testGetReleaseCancellationSupersedesOrdinaryCallbackFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testGetReleaseCancellationSupersedesOrdinaryCallbackFailure(bool $wrapped): void { $releaseCancellation = new CanceledException('release canceled'); + + if ($wrapped) { + $releaseCancellation = new RuntimeException('Release cleanup failed.', previous: $releaseCancellation); + } + $lock = new FailingReleaseLock($releaseCancellation); try { $lock->get(fn () => throw new RuntimeException('callback failure')); $this->fail('Expected the release cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($releaseCancellation, $exception); } } @@ -124,15 +132,22 @@ public function testBlockPreservesCallbackCancellationWhenReleaseIsCanceled(): v $this->assertTrue($lock->released); } - public function testBlockReleaseCancellationSupersedesOrdinaryCallbackFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testBlockReleaseCancellationSupersedesOrdinaryCallbackFailure(bool $wrapped): void { $releaseCancellation = new CanceledException('release canceled'); + + if ($wrapped) { + $releaseCancellation = new RuntimeException('Release cleanup failed.', previous: $releaseCancellation); + } + $lock = new FailingReleaseLock($releaseCancellation); try { $lock->block(0, fn () => throw new RuntimeException('callback failure')); $this->fail('Expected the release cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($releaseCancellation, $exception); } } diff --git a/tests/Cache/CacheServiceProviderTest.php b/tests/Cache/CacheServiceProviderTest.php index 4860079673..9048a4ccf9 100644 --- a/tests/Cache/CacheServiceProviderTest.php +++ b/tests/Cache/CacheServiceProviderTest.php @@ -13,10 +13,13 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\BeforeServerStart; +use Hypervel\ServerProcess\AbstractProcess; +use Hypervel\ServerProcess\Events\BeforeProcessHandle; use Hypervel\Support\Facades\Cache; use Hypervel\Tests\TestCase; use LogicException; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use Swoole\Server as SwooleServer; class CacheServiceProviderTest extends TestCase @@ -81,7 +84,9 @@ public function testConsoleFinalizationRunsAfterEveryProviderCanContribute(): vo $manager->allowSerializableClassesUsing(static fn (): array => []); } - public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): void + #[TestWith([false], 'server worker')] + #[TestWith([true], 'custom server process')] + public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(bool $customProcess): void { $workerManager = $this->manager(); $events = m::mock(Dispatcher::class); @@ -89,7 +94,9 @@ public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): voi $events->shouldReceive('listen') ->times(3) ->andReturnUsing(function (mixed $event, mixed $listener) use (&$listeners): void { - $listeners[$event][] = $listener; + foreach ((array) $event as $name) { + $listeners[$name][] = $listener; + } }); $application = m::mock(Application::class); $application->shouldReceive('make') @@ -112,10 +119,17 @@ public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): voi $this->assertCount(1, $listeners[BeforeServerStart::class]); $this->assertCount(2, $listeners[AfterWorkerStart::class]); - $server = m::mock(SwooleServer::class); - // Policy finalization runs in request workers and taskworkers, unlike Swoole timer registration. - $server->taskworker = true; - $listeners[AfterWorkerStart::class][1](new AfterWorkerStart($server, 9)); + if ($customProcess) { + $event = new BeforeProcessHandle(m::mock(AbstractProcess::class), 0); + } else { + $server = m::mock(SwooleServer::class); + // Policy finalization runs in request workers and taskworkers, unlike Swoole timer registration. + $server->taskworker = true; + $event = new AfterWorkerStart($server, 9); + } + + $this->assertArrayHasKey($event::class, $listeners); + $listeners[$event::class][array_key_last($listeners[$event::class])]($event); $this->expectException(LogicException::class); $workerManager->allowSerializableClassesUsing(static fn (): array => []); diff --git a/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php index e69b741d9d..e63f3ed72e 100644 --- a/tests/Cache/CacheStackStoreTagsTest.php +++ b/tests/Cache/CacheStackStoreTagsTest.php @@ -33,9 +33,11 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Throwable; class CacheStackStoreTagsTest extends TestCase { @@ -461,9 +463,14 @@ public function testTaggedRememberDispatchesRepositoryReadFailureEventWhenTheSto $this->assertSame($exception, $captured[1]->exception); } - public function testTaggedRememberDoesNotDispatchFailureEventWhenTheStoreIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTaggedRememberDoesNotDispatchFailureEventWhenTheStoreIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('read canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $taggable = $this->anyModeTaggableStore(); $taggable->shouldReceive('get')->once()->with('key')->andThrow($cancellation); @@ -475,7 +482,7 @@ public function testTaggedRememberDoesNotDispatchFailureEventWhenTheStoreIsCance try { $cache->remember('key', 60, fn () => 'computed'); $this->fail('Expected the tagged cache read cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -508,9 +515,14 @@ public function testTaggedPutDispatchesRepositoryWriteFailureEventWhenTheStoreTh $this->assertSame($exception, $captured[1]->exception); } - public function testTaggedPutDoesNotDispatchFailureEventWhenTheStoreIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTaggedPutDoesNotDispatchFailureEventWhenTheStoreIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $taggable = $this->anyModeTaggableStore(); $taggedCache = m::mock(TaggedCache::class); $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); @@ -525,7 +537,7 @@ public function testTaggedPutDoesNotDispatchFailureEventWhenTheStoreIsCanceled() try { $cache->put('key', 'value', 60); $this->fail('Expected the tagged cache write cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -559,9 +571,14 @@ public function testTaggedForeverDispatchesRepositoryWriteFailureEventWhenTheSto $this->assertSame($exception, $captured[1]->exception); } - public function testTaggedForeverDoesNotDispatchFailureEventWhenTheStoreIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTaggedForeverDoesNotDispatchFailureEventWhenTheStoreIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('forever write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $taggable = $this->anyModeTaggableStore(); $taggedCache = m::mock(TaggedCache::class); $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); @@ -576,7 +593,7 @@ public function testTaggedForeverDoesNotDispatchFailureEventWhenTheStoreIsCancel try { $cache->forever('key', 'value'); $this->fail('Expected the tagged forever cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -606,9 +623,14 @@ public function testTaggedExpiredPutDispatchesRepositoryForgetFailureEventWhenTh $this->assertSame($exception, $captured[1]->exception); } - public function testTaggedExpiredPutDoesNotDispatchFailureEventWhenTheStoreIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTaggedExpiredPutDoesNotDispatchFailureEventWhenTheStoreIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('forget canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $taggable = $this->anyModeTaggableStore(); $taggable->shouldReceive('forget')->once()->with('key')->andThrow($cancellation); @@ -620,7 +642,7 @@ public function testTaggedExpiredPutDoesNotDispatchFailureEventWhenTheStoreIsCan try { $cache->put('key', 'value', 0); $this->fail('Expected the tagged forget cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -729,9 +751,14 @@ public function testTagSetFlushAttemptsEveryLayerAndPreservesTheFirstException() } } - public function testTagSetFlushStopsImmediatelyOnCancellation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTagSetFlushStopsImmediatelyOnCancellation(bool $cleanupThrows): void { $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $firstTaggable = $this->anyModeTaggableStore(); $secondTaggable = $this->anyModeTaggableStore(); $firstTaggedCache = m::mock(TaggedCache::class); @@ -745,7 +772,7 @@ public function testTagSetFlushStopsImmediatelyOnCancellation(): void try { (new StackStore([$firstTaggable, $secondTaggable]))->tags(['tag'])->getTags()->flush(); $this->fail('Expected the tag flush cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } @@ -781,9 +808,14 @@ public function testClearDispatchesFailureEventWithExactTagSetException(): void $this->assertSame($exception, $captured[1]->exception); } - public function testClearCancellationSkipsFailureEventAndRemainingTaggableLayers(): void + #[TestWith([false])] + #[TestWith([true])] + public function testClearCancellationSkipsFailureEventAndRemainingTaggableLayers(bool $cleanupThrows): void { $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $firstTaggable = $this->anyModeTaggableStore(); $secondTaggable = $this->anyModeTaggableStore(); $firstTaggedCache = m::mock(TaggedCache::class); @@ -800,7 +832,7 @@ public function testClearCancellationSkipsFailureEventAndRemainingTaggableLayers try { $cache->clear(); $this->fail('Expected the tagged flush cancellation to be rethrown.'); - } catch (CanceledException $caught) { + } catch (Throwable $caught) { $this->assertSame($cancellation, $caught); } diff --git a/tests/Cache/CacheStorageStoreTest.php b/tests/Cache/CacheStorageStoreTest.php index 9118c5ed4d..e1210ba369 100644 --- a/tests/Cache/CacheStorageStoreTest.php +++ b/tests/Cache/CacheStorageStoreTest.php @@ -8,11 +8,16 @@ use Hypervel\Cache\Repository; use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Cache\StorageStore; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\Cache\Fixtures\ArrayFilesystem; +use Hypervel\Tests\Cache\Fixtures\CanceledDeserialization; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Throwable; class CacheStorageStoreTest extends TestCase { @@ -26,11 +31,18 @@ public function testValuesCanBeStoredAndRetrieved(): void $this->assertStringStartsWith('cache/', $store->path('foo')); } - public function testGetPreservesCancellationFromFilesystemRead(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testGetPreservesCancellationFromFilesystemRead(bool $wrapped): void { $cancellation = new CanceledException('read canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('Read cleanup failed.', previous: $cancellation); + } + $disk = new class($cancellation) extends ArrayFilesystem { - public function __construct(protected CanceledException $cancellation) + public function __construct(protected Throwable $cancellation) { } @@ -44,11 +56,31 @@ public function get(string $path): ?string try { $store->get('foo'); $this->fail('Reading the cache file was expected to be canceled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testCanceledDeserializationDoesNotDeleteTheCachedValue(bool $wrapped): void + { + $disk = new ArrayFilesystem; + $store = new StorageStore($disk, 'cache'); + $store->put('foo', new CanceledDeserialization($wrapped), 60); + $caught = null; + + try { + $store->get('foo'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertNotNull($caught); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + $this->assertTrue($disk->exists($store->path('foo'))); + } + public function testGetPreservesCancellationFromSerializableClassPolicy(): void { $cancellation = new CanceledException('policy canceled'); diff --git a/tests/Cache/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index 0e84f8ed32..176edb72f5 100644 --- a/tests/Cache/CacheTaggedCacheTest.php +++ b/tests/Cache/CacheTaggedCacheTest.php @@ -15,8 +15,10 @@ use Hypervel\Contracts\Cache\Store; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; enum TaggedCacheTestKeyStringEnum: string { @@ -212,9 +214,14 @@ public function testVersionedTagFlushAttemptsLaterTagsAndRethrowsTheFirstExcepti } } - public function testVersionedTagResetStopsImmediatelyOnCancellation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testVersionedTagResetStopsImmediatelyOnCancellation(bool $cleanupThrows): void { $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $store = m::mock(Store::class); $store->shouldReceive('forever') ->once() @@ -225,22 +232,28 @@ public function testVersionedTagResetStopsImmediatelyOnCancellation(): void try { (new VersionedTagSet($store, ['users', 'posts']))->reset(); $this->fail('Expected the tag reset cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } - public function testVersionedTagFlushStopsImmediatelyOnCancellation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testVersionedTagFlushStopsImmediatelyOnCancellation(bool $cleanupThrows): void { $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $store = m::mock(Store::class); + $store->shouldReceive('forget')->once()->with('tag:earlier:key')->andThrow(new RuntimeException('Earlier tag failed.')); $store->shouldReceive('forget')->once()->with('tag:users:key')->andThrow($cancellation); $store->shouldNotReceive('forget')->with('tag:posts:key'); try { - (new VersionedTagSet($store, ['users', 'posts']))->flush(); + (new VersionedTagSet($store, ['earlier', 'users', 'posts']))->flush(); $this->fail('Expected the tag flush cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } diff --git a/tests/Cache/ConcurrencyLimiterTest.php b/tests/Cache/ConcurrencyLimiterTest.php index d3cd735b4b..1151daec63 100644 --- a/tests/Cache/ConcurrencyLimiterTest.php +++ b/tests/Cache/ConcurrencyLimiterTest.php @@ -21,6 +21,7 @@ use Hypervel\Support\Sleep; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -250,9 +251,16 @@ public function testBlockPreservesCallbackCancellationWhenReleaseIsCanceled(): v } } - public function testBlockReleaseCancellationSupersedesOrdinaryCallbackFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testBlockReleaseCancellationSupersedesOrdinaryCallbackFailure(bool $wrapped): void { $releaseCancellation = new CanceledException('release canceled'); + + if ($wrapped) { + $releaseCancellation = new RuntimeException('Release cleanup failed.', previous: $releaseCancellation); + } + $limiter = new ConcurrencyLimiter( $this->failingReleaseStore(releaseFailure: $releaseCancellation), 'key', @@ -263,7 +271,7 @@ public function testBlockReleaseCancellationSupersedesOrdinaryCallbackFailure(): try { $limiter->block(0, fn () => throw new RuntimeException('callback failure')); $this->fail('Expected the release cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($releaseCancellation, $exception); } } @@ -519,9 +527,16 @@ public function testFunnelPreservesCallbackCancellationWhenReleaseIsCanceled(): } } - public function testFunnelReleaseCancellationSupersedesOrdinaryCallbackFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testFunnelReleaseCancellationSupersedesOrdinaryCallbackFailure(bool $wrapped): void { $releaseCancellation = new CanceledException('release canceled'); + + if ($wrapped) { + $releaseCancellation = new RuntimeException('Release cleanup failed.', previous: $releaseCancellation); + } + $repository = new Repository($this->failingReleaseStore(releaseFailure: $releaseCancellation)); try { @@ -530,7 +545,7 @@ public function testFunnelReleaseCancellationSupersedesOrdinaryCallbackFailure() ->block(0) ->then(fn () => throw new RuntimeException('callback failure')); $this->fail('Expected the release cancellation to be thrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($releaseCancellation, $exception); } } diff --git a/tests/Cache/Fixtures/CanceledDeserialization.php b/tests/Cache/Fixtures/CanceledDeserialization.php new file mode 100644 index 0000000000..38e611233d --- /dev/null +++ b/tests/Cache/Fixtures/CanceledDeserialization.php @@ -0,0 +1,32 @@ +wrapped) { + throw new RuntimeException('Restoration cleanup failed.'); + } + } + } +} diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index 26001c0562..ae2b696d11 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -31,6 +31,7 @@ use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; /** * Tests for AllTaggedCache behavior. @@ -1128,9 +1129,14 @@ public function testPutDispatchesTheRepositoryFailureEventWhenTheOperationThrows $this->assertSame($exception, $captured[1]->exception); } - public function testPutDoesNotDispatchFailureEventWhenTheOperationIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testPutDoesNotDispatchFailureEventWhenTheOperationIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $put = m::mock(Put::class); $put->shouldReceive('execute')->once()->andThrow($cancellation); @@ -1142,7 +1148,7 @@ public function testPutDoesNotDispatchFailureEventWhenTheOperationIsCanceled(): try { $tagged->put('name', 'John', 60); $this->fail('Expected the tagged cache write cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1177,9 +1183,14 @@ public function testPutManyDispatchesRepositoryFailureEventsWhenTheOperationThro $this->assertSame($exception, $captured[2]->exception); } - public function testPutManyDoesNotDispatchFailureEventsWhenTheOperationIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testPutManyDoesNotDispatchFailureEventsWhenTheOperationIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('batch write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $values = ['name' => 'John', 'age' => 30]; $putMany = m::mock(PutMany::class); $putMany->shouldReceive('execute')->once()->andThrow($cancellation); @@ -1192,7 +1203,7 @@ public function testPutManyDoesNotDispatchFailureEventsWhenTheOperationIsCancele try { $tagged->putMany($values, 60); $this->fail('Expected the tagged cache batch write cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1236,9 +1247,14 @@ public function testForeverDispatchesTheRepositoryFailureEventWhenTheOperationTh $this->assertSame($exception, $captured[1]->exception); } - public function testForeverDoesNotDispatchFailureEventWhenTheOperationIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testForeverDoesNotDispatchFailureEventWhenTheOperationIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('forever write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $forever = m::mock(Forever::class); $forever->shouldReceive('execute')->once()->andThrow($cancellation); @@ -1250,7 +1266,7 @@ public function testForeverDoesNotDispatchFailureEventWhenTheOperationIsCanceled try { $tagged->forever('name', 'John'); $this->fail('Expected the tagged forever cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -1348,9 +1364,14 @@ public function testFlushDispatchesFailureEventWithExactOperationException(): vo $this->assertSame($exception, $captured[1]->exception); } - public function testFlushDoesNotDispatchFailureEventWhenTheOperationIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testFlushDoesNotDispatchFailureEventWhenTheOperationIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException; + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $flush = m::mock(Flush::class); $flush->shouldReceive('execute')->once()->andThrow($cancellation); @@ -1362,7 +1383,7 @@ public function testFlushDoesNotDispatchFailureEventWhenTheOperationIsCanceled() try { $tagged->flush(); $this->fail('Expected the tagged cache flush cancellation to be rethrown.'); - } catch (CanceledException $caught) { + } catch (Throwable $caught) { $this->assertSame($cancellation, $caught); } diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index fe932fca06..82cad73828 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -29,9 +29,11 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Redis\Exceptions\LuaScriptException; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Throwable; /** * Tests for AnyTaggedCache behavior. @@ -875,9 +877,14 @@ public function testPutDispatchesTheRepositoryFailureEventWhenTheOperationThrows $this->assertSame($exception, $captured[1]->exception); } - public function testPutDoesNotDispatchFailureEventWhenTheOperationIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testPutDoesNotDispatchFailureEventWhenTheOperationIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $put = m::mock(Put::class); $put->shouldReceive('execute') ->once() @@ -892,7 +899,7 @@ public function testPutDoesNotDispatchFailureEventWhenTheOperationIsCanceled(): try { $tagged->put('mykey', 'myvalue', 60); $this->fail('Expected the tagged cache write cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -929,9 +936,14 @@ public function testPutManyDispatchesRepositoryFailureEventsWhenTheOperationThro $this->assertSame([$exception, $exception], array_map(static fn (KeyWriteFailed $event): RuntimeException => $event->exception, array_slice($captured, 1))); } - public function testPutManyDoesNotDispatchFailureEventsWhenTheOperationIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testPutManyDoesNotDispatchFailureEventsWhenTheOperationIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('batch write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $values = ['first' => 'one', 'second' => 'two']; $putMany = m::mock(PutMany::class); $putMany->shouldReceive('execute') @@ -947,7 +959,7 @@ public function testPutManyDoesNotDispatchFailureEventsWhenTheOperationIsCancele try { $tagged->putMany($values, 60); $this->fail('Expected the tagged cache batch write cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -994,9 +1006,14 @@ public function testForeverDispatchesTheRepositoryFailureEventWhenTheOperationTh $this->assertSame($exception, $captured[1]->exception); } - public function testForeverDoesNotDispatchFailureEventWhenTheOperationIsCanceled(): void + #[TestWith([false])] + #[TestWith([true])] + public function testForeverDoesNotDispatchFailureEventWhenTheOperationIsCanceled(bool $cleanupThrows): void { $cancellation = new CanceledException('forever write canceled'); + if ($cleanupThrows) { + $cancellation = new RuntimeException('Cleanup failed.', previous: $cancellation); + } $forever = m::mock(Forever::class); $forever->shouldReceive('execute') ->once() @@ -1011,7 +1028,7 @@ public function testForeverDoesNotDispatchFailureEventWhenTheOperationIsCanceled try { $tagged->forever('mykey', 'myvalue'); $this->fail('Expected the tagged forever cancellation to be rethrown.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Core/StdoutLoggerTest.php b/tests/Core/StdoutLoggerTest.php index 41978545e5..ab79cca2f2 100644 --- a/tests/Core/StdoutLoggerTest.php +++ b/tests/Core/StdoutLoggerTest.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use JsonSerializable; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LogLevel; use RuntimeException; @@ -21,6 +22,7 @@ use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; class StdoutLoggerTest extends TestCase { @@ -223,11 +225,18 @@ public function testThrowingStringableDoesNotMaskLineLog(): void ); } - public function testStringableCancellationEscapesWithoutWritingFallbackLog(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testStringableCancellationEscapesWithoutWritingFallbackLog(bool $wrapped): void { $cancellation = new CanceledException('canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('String conversion cleanup failed.', previous: $cancellation); + } + $message = new class($cancellation) implements Stringable { - public function __construct(private CanceledException $cancellation) + public function __construct(private Throwable $cancellation) { } @@ -246,7 +255,7 @@ public function __toString(): string $logger->info($message); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } @@ -393,11 +402,18 @@ public function testJsonOutputDropsContextWhenNestedSerializerThrows(): void $this->assertArrayNotHasKey('context', $data); } - public function testJsonSerializationCancellationEscapesWithoutWritingFallbackLog(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testJsonSerializationCancellationEscapesWithoutWritingFallbackLog(bool $wrapped): void { $cancellation = new CanceledException('canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('Serialization cleanup failed.', previous: $cancellation); + } + $serializer = new class($cancellation) implements JsonSerializable { - public function __construct(private CanceledException $cancellation) + public function __construct(private Throwable $cancellation) { } @@ -416,7 +432,7 @@ public function jsonSerialize(): never $logger->info('Payload.', ['payload' => ['serializer' => $serializer]]); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } diff --git a/tests/Coroutine/CoroutineTest.php b/tests/Coroutine/CoroutineTest.php index 6231ed03c5..a3d1317a01 100644 --- a/tests/Coroutine/CoroutineTest.php +++ b/tests/Coroutine/CoroutineTest.php @@ -6,6 +6,7 @@ use Closure; use Exception; +use Google\Cloud\Core\Exception\ServiceException; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; @@ -46,11 +47,25 @@ public static function cancellationExceptions(): array $cleanup = new RuntimeException('Cleanup failed.', previous: $cancellation); $child = new ChildCancellationException('The child stopped.', previous: $cleanup); $foreign = new ForeignCancellationException('The shared operation stopped.', previous: $cleanup); + $dualCause = new ServiceException('Cleanup request failed.', 0, new RuntimeException('Transport failed.')); + + try { + try { + throw new CanceledException; + } finally { + throw $dualCause; + } + } catch (ServiceException) { + // PHP adds the unwinding cancellation separately from Google's service cause. + } return [ 'direct cancellation' => [$cancellation, true], 'cleanup replaced cancellation' => [$cleanup, true], 'nested cleanup' => [new RuntimeException(previous: $cleanup), true], + 'Google service cause' => [new ServiceException('Request failed.', 0, $cancellation), true], + 'Google foreign cause' => [new ServiceException('Request failed.', 0, $foreign), false], + 'Google service cause and PHP cancellation' => [$dualCause, true], 'independent child' => [$child, false], 'wrapped independent child' => [new RuntimeException(previous: $child), false], 'grandchild boundary' => [new ChildCancellationException(previous: $child), false], @@ -60,6 +75,38 @@ public static function cancellationExceptions(): array ]; } + #[DataProvider('cancellationPrecedence')] + public function testFailureSelectionPreservesTheFirstFailureUnlessCancellationTakesPrecedence( + ?Throwable $kept, + Throwable $next, + Throwable $expected, + ): void { + $this->assertSame($expected, Coroutine::preferCancellation($kept, $next)); + } + + /** + * Provide primary and cleanup failures with distinct cancellation ownership. + */ + public static function cancellationPrecedence(): array + { + $ordinary = new RuntimeException('Operation failed.'); + $cleanup = new RuntimeException('Cleanup failed.'); + $cancellation = new CanceledException; + $wrapped = new RuntimeException('Canceled cleanup failed.', previous: $cancellation); + $foreign = new ForeignCancellationException(previous: $wrapped); + + return [ + 'first failure' => [null, $ordinary, $ordinary], + 'ordinary cleanup' => [$ordinary, $cleanup, $ordinary], + 'direct cleanup cancellation' => [$ordinary, $cancellation, $cancellation], + 'wrapped cleanup cancellation' => [$ordinary, $wrapped, $wrapped], + 'primary cancellation' => [$cancellation, $cleanup, $cancellation], + 'first cancellation wins' => [$wrapped, $cancellation, $wrapped], + 'foreign cleanup is not our cancellation' => [$ordinary, $foreign, $ordinary], + 'own cancellation beats foreign failure' => [$foreign, $wrapped, $wrapped], + ]; + } + public function testCreateAndForkReturnPositiveCoroutineIds(): void { $this->assertGreaterThan(0, Coroutine::create(static fn (): null => null)); @@ -152,6 +199,34 @@ public function testTerminalCancellationIsNotReported(): void $this->assertFalse(Coroutine::exists($coroutineId)); } + public function testTerminalBoundaryReportsCleanupFailureDuringCancellationOnce(): void + { + $container = new Container; + $handler = m::mock(ExceptionHandlerContract::class); + $failure = new RuntimeException('Coroutine cleanup failed.'); + $handler->shouldReceive('report')->once()->with($failure); + $container->instance(ExceptionHandlerContract::class, $handler); + Container::setInstance($container); + $blocker = new Channel(1); + + $coroutineId = Coroutine::create(static function () use ($blocker, $failure): void { + try { + $blocker->pop(); + } finally { + throw $failure; + } + }); + + try { + $this->assertTrue(EngineCoroutine::cancelById($coroutineId, throwException: true)); + $this->assertFalse(Coroutine::exists($coroutineId)); + $this->assertInstanceOf(CanceledException::class, $failure->getPrevious()); + } finally { + $blocker->close(); + Coroutine::join([$coroutineId], 1); + } + } + public function testAfterCreatedCallbacksAreExecuted(): void { $executed = false; diff --git a/tests/Filesystem/AwsS3V3AdapterTest.php b/tests/Filesystem/AwsS3V3AdapterTest.php index 2f2af753fd..3180c9c1b8 100644 --- a/tests/Filesystem/AwsS3V3AdapterTest.php +++ b/tests/Filesystem/AwsS3V3AdapterTest.php @@ -4,10 +4,14 @@ namespace Hypervel\Tests\Filesystem; +use Aws\Command; use Aws\CommandInterface; use Aws\MockHandler; use Aws\Result; +use Aws\S3\Exception\S3Exception; use Aws\S3\S3Client; +use GuzzleHttp\Handler\InflateBodyStream; +use GuzzleHttp\Psr7\PumpStream; use GuzzleHttp\Psr7\Utils; use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; @@ -30,6 +34,7 @@ use Psr\Http\Message\StreamInterface; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -419,40 +424,89 @@ public function testReadStreamRangeWrapsClientFailures(): void $adapter->readStreamRange('file.txt', 1, 2); } - public function testReadStreamRangePreservesCancellationWithoutReporting(): void + #[TestWith([false, false], 'direct whole read')] + #[TestWith([false, true], 'direct range read')] + #[TestWith([true, false], 'wrapped whole read')] + #[TestWith([true, true], 'wrapped range read')] + public function testReadStreamRangePreservesCancellationWithoutReporting(bool $wrapped, bool $range): void { $cancellation = new CanceledException('read canceled'); + $failure = $wrapped ? new S3Exception('SDK request canceled.', new Command('GetObject'), previous: $cancellation) : $cancellation; $exceptionHandler = m::mock(ExceptionHandler::class); $exceptionHandler->shouldNotReceive('report'); Container::getInstance()->bind(ExceptionHandler::class, static fn () => $exceptionHandler); - $handler = new MockHandler([$cancellation]); + $handler = new MockHandler([$failure]); $adapter = $this->adapter($handler, [ 'bucket' => 'bucket', 'report' => true, 'throw' => false, ]); + $caught = null; + try { - $adapter->readStreamRange('file.txt', 1, 2); - $this->fail('Expected the cancellation to escape.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + $range ? $adapter->readStreamRange('file.txt', 1, 2) : $adapter->readStream('file.txt'); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertNotNull($caught); + $this->assertSame($failure, $caught); } public function testReadStreamRangeRejectsAResultWithoutAResource(): void { $body = m::mock(StreamInterface::class); - $body->shouldReceive('detach')->once()->andReturnNull(); + $body->shouldReceive('isReadable', 'isWritable')->andReturnFalse(); $handler = new MockHandler([new Result(['Body' => $body])]); $adapter = $this->adapter($handler, ['bucket' => 'bucket', 'throw' => true]); $this->expectException(UnableToReadFile::class); - $this->expectExceptionMessageIsOrContains('Downloaded object does not contain a file resource.'); + $this->expectExceptionMessageIsOrContains('The stream must be readable, writable, or both.'); $adapter->readStreamRange('file.txt', 1, 2); } + public function testReadStreamPreservesCancellationWhileReadingADecodedBody(): void + { + $failure = new RuntimeException('Read cleanup failed.', previous: new CanceledException); + $body = new InflateBodyStream(new PumpStream(static fn () => throw $failure)); + $adapter = $this->adapter(new MockHandler([new Result(['Body' => $body])]), ['bucket' => 'bucket']); + $resource = $adapter->readStream('file.gz'); + $caught = null; + + try { + fread($resource, 1); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + fclose($resource); + } + + $this->assertSame($failure, $caught); + } + + public function testClosingReadStreamClosesARetainedDecodedBody(): void + { + $native = fopen('php://temp', 'w+b'); + fwrite($native, gzencode('contents')); + rewind($native); + $body = new InflateBodyStream(Utils::streamFor($native)); + $adapter = $this->adapter(new MockHandler([new Result(['Body' => $body])]), ['bucket' => 'bucket']); + $resource = $adapter->readStream('file.gz'); + + try { + $this->assertSame('contents', stream_get_contents($resource)); + fclose($resource); + $this->assertFalse(is_resource($native)); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + $body->close(); + } + } + public function testReadFailuresReturnNullWhenExceptionsAreDisabled(): void { $handler = new MockHandler([ diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index cc6d8c8d3e..11da6cbeb0 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Filesystem; use DateTimeInterface; +use GuzzleHttp\Handler\InflateBodyStream; +use GuzzleHttp\Psr7\CachingStream; +use GuzzleHttp\Psr7\PumpStream; use GuzzleHttp\Psr7\Stream; use Hypervel\Container\Container; use Hypervel\Context\RequestContext; @@ -20,6 +23,7 @@ use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\Http\StreamWrapper; use Hypervel\Http\UploadedFile; use Hypervel\Image\Image; use Hypervel\Image\ImageException; @@ -36,13 +40,22 @@ use League\Flysystem\Ftp\FtpAdapter; use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\PathTraversalDetected; +use League\Flysystem\UnableToCopyFile; +use League\Flysystem\UnableToCreateDirectory; +use League\Flysystem\UnableToDeleteDirectory; +use League\Flysystem\UnableToDeleteFile; +use League\Flysystem\UnableToMoveFile; +use League\Flysystem\UnableToProvideChecksum; use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToRetrieveMetadata; +use League\Flysystem\UnableToSetVisibility; use League\Flysystem\UnableToWriteFile; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\ExpectationFailedException; +use Psr\Http\Message\StreamInterface; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; @@ -931,6 +944,38 @@ public function testPutWithStreamInterface(): void $this->assertSame('some-data', $filesystemAdapter->get('bar.txt')); } + public function testPutWithDecodedStreamPreservesProducerCancellationAndClosesTheBody(): void + { + $failure = new RuntimeException('Read cleanup failed.', previous: new CanceledException); + $body = new InflateBodyStream(new PumpStream(static fn () => throw $failure)); + $adapter = new FilesystemAdapter($this->filesystem, $this->adapter); + $caught = null; + + try { + $adapter->put('bar.txt', $body); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse($body->isReadable()); + } + + public function testPutWithStreamAllowsTheWriterToCloseItsInput(): void + { + $body = new CachingStream(new Stream(fopen('php://temp', 'w+b'))); + $driver = m::mock(FilesystemOperator::class); + $driver->shouldReceive('writeStream')->once()->andReturnUsing( + static function (string $path, mixed $stream): void { + fclose($stream); + }, + ); + $adapter = new FilesystemAdapter($driver, $this->adapter); + + $this->assertTrue($adapter->put('bar.txt', $body)); + $this->assertFalse($body->isReadable()); + } + public function testPutFileAs(): void { file_put_contents($filePath = $this->tempDir . '/foo.txt', 'uploaded file content'); @@ -1265,6 +1310,95 @@ public function testCallbackMutatorsCanClearPreviouslyConfiguredCallbacks(): voi $this->assertFalse($serveCalled); } + #[DataProvider('canceledFilesystemOperations')] + public function testCancellationBypassesTheFilesystemFailurePolicy(string $method, array $arguments, string $driverMethod, Throwable $failure): void + { + $handler = m::mock(ExceptionHandler::class); + $handler->shouldNotReceive('report'); + $this->app->instance(ExceptionHandler::class, $handler); + $driver = m::mock(FilesystemOperator::class); + $driver->shouldReceive($driverMethod)->once()->andThrow($failure); + $adapter = new FilesystemAdapter($driver, $this->adapter, ['throw' => false, 'report' => true]); + $stream = $method === 'writeStream' ? fopen('php://memory', 'w+b') : null; + + if ($stream !== null) { + $arguments[1] = $stream; + } + + $caught = null; + + try { + $adapter->{$method}(...$arguments); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + $this->assertSame($failure, $caught); + } + + /** + * Cover the public operations that apply the disk's failure policy. + */ + public static function canceledFilesystemOperations(): array + { + $cancellation = new CanceledException('Filesystem operation canceled.'); + + return [ + 'read' => ['get', ['file'], 'read', UnableToReadFile::fromLocation('file', previous: $cancellation)], + 'read stream' => ['readStream', ['file'], 'readStream', UnableToReadFile::fromLocation('file', previous: $cancellation)], + 'write' => ['put', ['file', 'contents'], 'write', UnableToWriteFile::atLocation('file', previous: $cancellation)], + 'write stream' => ['writeStream', ['file', null], 'writeStream', UnableToWriteFile::atLocation('file', previous: $cancellation)], + 'visibility' => ['setVisibility', ['file', 'private'], 'setVisibility', UnableToSetVisibility::atLocation('file', previous: $cancellation)], + 'delete' => ['delete', ['file'], 'delete', UnableToDeleteFile::atLocation('file', previous: $cancellation)], + 'copy' => ['copy', ['file', 'copy'], 'copy', UnableToCopyFile::fromLocationTo('file', 'copy', $cancellation)], + 'move' => ['move', ['file', 'moved'], 'move', UnableToMoveFile::fromLocationTo('file', 'moved', $cancellation)], + 'checksum' => ['checksum', ['file'], 'checksum', new UnableToProvideChecksum('Canceled.', 'file', $cancellation)], + 'mime type' => ['mimeType', ['file'], 'mimeType', UnableToRetrieveMetadata::mimeType('file', previous: $cancellation)], + 'create directory' => ['makeDirectory', ['directory'], 'createDirectory', UnableToCreateDirectory::atLocation('directory', previous: $cancellation)], + 'delete directory' => ['deleteDirectory', ['directory'], 'deleteDirectory', UnableToDeleteDirectory::atLocation('directory', previous: $cancellation)], + ]; + } + + #[TestWith([false])] + #[TestWith([true])] + public function testRangeReadPreservesCancellationFromTheStreamProducerAndClosesTheStream(bool $closeThrows): void + { + $failure = new RuntimeException('Read cleanup failed.', previous: new CanceledException); + $closeFailure = new RuntimeException('Stream close failed.'); + $body = m::mock(StreamInterface::class, new CachingStream(new PumpStream(static fn () => throw $failure))); + if ($closeThrows) { + $body->shouldReceive('close')->once()->andThrow($closeFailure); + } + $stream = StreamWrapper::detach($body); + $driver = m::mock(FilesystemOperator::class); + $driver->shouldReceive('readStream')->once()->andReturn($stream); + $adapter = new FilesystemAdapter($driver, $this->adapter); + $caught = null; + + try { + $adapter->readStreamRange('file', 3, 5); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + $closed = ! is_resource($stream); + + if (! $closed) { + fclose($stream); + } + } + + $this->assertSame($closeThrows ? $closeFailure : $failure, $caught); + if ($closeThrows) { + $this->assertSame($failure, $caught->getPrevious()); + } + $this->assertTrue(Coroutine::causedByCancellation($caught)); + $this->assertTrue($closed); + } + public function testThrowExceptionsForGet(): void { $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['throw' => true]); diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 4daeb1bf30..7cc412a918 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -55,6 +55,7 @@ use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Throwable; use UnitEnum; use function Aws\serialize; @@ -1117,7 +1118,9 @@ public function testReadThroughDiskPromotionFailuresAreBestEffortByDefault(): vo } } - public function testReadThroughPromotionCancellationClosesOwnedStreams(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testReadThroughPromotionCancellationClosesOwnedStreams(bool $wrapped): void { $initialStreamIds = array_keys(get_resources('stream')); $source = fopen('php://temp', 'w+b'); @@ -1125,24 +1128,30 @@ public function testReadThroughPromotionCancellationClosesOwnedStreams(): void rewind($source); $temporary = null; $cancellation = new CanceledException('promotion canceled'); + $failure = $wrapped ? UnableToWriteFile::atLocation('file.txt', previous: $cancellation) : $cancellation; $primary = m::mock(FilesystemOperator::class); $primary->shouldReceive('fileExists')->twice()->with('file.txt')->andReturn(false); $primary->shouldReceive('writeStream')->once()->andReturnUsing( - static function (string $path, mixed $stream) use (&$temporary, $cancellation): never { + static function (string $path, mixed $stream) use (&$temporary, $failure): never { $temporary = $stream; - throw $cancellation; + throw $failure; }, ); $fallback = m::mock(FilesystemOperator::class); $fallback->shouldReceive('readStream')->once()->with('file.txt')->andReturn($source); $adapter = new ReadThroughFilesystemAdapter($primary, $fallback); + $caught = null; + try { - $adapter->readStream('file.txt'); - $this->fail('Expected cancellation to escape promotion.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + try { + $adapter->readStream('file.txt'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); $this->assertFalse(is_resource($source)); $this->assertNotNull($temporary); $this->assertFalse(is_resource($temporary)); @@ -1161,6 +1170,26 @@ static function (string $path, mixed $stream) use (&$temporary, $cancellation): } } + public function testReadThroughPromotionPreservesWrappedCancellationForStringReads(): void + { + $failure = UnableToWriteFile::atLocation('file.txt', previous: new CanceledException); + $primary = m::mock(FilesystemOperator::class); + $primary->shouldReceive('fileExists')->twice()->with('file.txt')->andReturnFalse(); + $primary->shouldReceive('write')->once()->with('file.txt', 'contents')->andThrow($failure); + $fallback = m::mock(FilesystemOperator::class); + $fallback->shouldReceive('read')->once()->with('file.txt')->andReturn('contents'); + $adapter = new ReadThroughFilesystemAdapter($primary, $fallback); + $caught = null; + + try { + $adapter->read('file.txt'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + } + #[DataProvider('closingPromotionResults')] public function testReadThroughPromotionRetainsItsStreamWhenThePrimaryClosesItsInput(bool $fails): void { diff --git a/tests/Filesystem/GoogleCloudStorageAdapterTest.php b/tests/Filesystem/GoogleCloudStorageAdapterTest.php index 6f915399a0..b9bfe1d986 100644 --- a/tests/Filesystem/GoogleCloudStorageAdapterTest.php +++ b/tests/Filesystem/GoogleCloudStorageAdapterTest.php @@ -5,14 +5,19 @@ namespace Hypervel\Tests\Filesystem; use Google\Cloud\Core\AnonymousCredentials; +use Google\Cloud\Core\Exception\ServiceException; +use Google\Cloud\Core\Upload\AbstractUploader; use Google\Cloud\Storage\Bucket; use Google\Cloud\Storage\StorageClient; use Google\Cloud\Storage\StorageObject; use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Handler\InflateBodyStream; +use GuzzleHttp\Psr7\PumpStream; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; use Hypervel\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Coroutine\Coroutine; use Hypervel\Filesystem\GoogleCloudStorageAdapter; use Hypervel\Filesystem\ReadThroughFilesystemAdapter; use Hypervel\Tests\TestCase; @@ -27,11 +32,58 @@ use Psr\Http\Message\StreamInterface; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; class GoogleCloudStorageAdapterTest extends TestCase { + public function testResumableUploadPreservesCancellation(): void + { + // @TODO: Remove this skip once google/cloud-core's ResumableUploader::upload preserves + // the original exception as ServiceException's cause instead of discarding it. + $this->markTestSkipped('google/cloud-core discards cancellation causes during resumable uploads.'); + + $cancellation = new CanceledException('Upload canceled.'); + $requests = 0; + $client = new StorageClient([ + 'projectId' => 'test-project', + 'credentialsFetcher' => new AnonymousCredentials, + 'retries' => 0, + 'httpHandler' => function (RequestInterface $request) use ($cancellation, &$requests): Response { + if (++$requests === 1) { + $this->assertSame('POST', $request->getMethod()); + $this->assertStringContainsString('uploadType=resumable', $request->getUri()->getQuery()); + + return new Response(200, ['Location' => 'https://storage.example.test/resume']); + } + + $this->assertSame('PUT', $request->getMethod()); + + throw $cancellation; + }, + ]); + $flysystem = new FlysystemGoogleCloudAdapter($client->bucket('test-bucket')); + $adapter = new GoogleCloudStorageAdapter(new Filesystem($flysystem), $flysystem, ['bucket' => 'test-bucket'], $client); + $source = fopen('php://temp', 'w+b'); + ftruncate($source, AbstractUploader::RESUMABLE_LIMIT + 1); + $caught = null; + + try { + $adapter->put('upload.bin', $source, ['metadata' => ['contentType' => 'application/octet-stream']]); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + if (is_resource($source)) { + fclose($source); + } + } + + $this->assertSame(2, $requests); + $this->assertNotNull($caught, 'Cancellation must escape instead of returning a failed write.'); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + #[TestWith([false])] #[TestWith([true])] public function testReadThroughPromotionRetainsItsStreamAfterGoogleUploads(bool $fails): void @@ -256,14 +308,19 @@ public function testReadStreamRangeWrapsClientFailures(): void $adapter->readStreamRange('file.txt', 3, 5); } - public function testReadStreamRangePreservesCancellationWithoutReporting(): void + #[TestWith([false, false], 'direct whole read')] + #[TestWith([false, true], 'direct range read')] + #[TestWith([true, false], 'wrapped whole read')] + #[TestWith([true, true], 'wrapped range read')] + public function testReadStreamRangePreservesCancellationWithoutReporting(bool $wrapped, bool $range): void { $cancellation = new CanceledException('read canceled'); + $failure = $wrapped ? new ServiceException('SDK request canceled.', 0, $cancellation) : $cancellation; $exceptionHandler = m::mock(ExceptionHandler::class); $exceptionHandler->shouldNotReceive('report'); Container::getInstance()->bind(ExceptionHandler::class, static fn () => $exceptionHandler); $object = m::mock(StorageObject::class); - $object->shouldReceive('downloadAsStream')->once()->andThrow($cancellation); + $object->shouldReceive('downloadAsStream')->once()->andThrow($failure); $bucket = m::mock(Bucket::class); $bucket->shouldReceive('object')->once()->with('file.txt')->andReturn($object); $client = m::mock(StorageClient::class); @@ -274,18 +331,22 @@ public function testReadStreamRangePreservesCancellationWithoutReporting(): void 'throw' => false, ]); + $caught = null; + try { - $adapter->readStreamRange('file.txt', 3, 5); - $this->fail('Expected the cancellation to escape.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + $range ? $adapter->readStreamRange('file.txt', 3, 5) : $adapter->readStream('file.txt'); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertNotNull($caught); + $this->assertSame($failure, $caught); } public function testReadStreamRangeRejectsAResultWithoutAResourceWhenConfigured(): void { $stream = m::mock(StreamInterface::class); - $stream->shouldReceive('detach')->once()->andReturnNull(); + $stream->shouldReceive('isReadable', 'isWritable')->andReturnFalse(); $object = m::mock(StorageObject::class); $object->shouldReceive('downloadAsStream')->once()->andReturn($stream); $bucket = m::mock(Bucket::class); @@ -295,7 +356,7 @@ public function testReadStreamRangeRejectsAResultWithoutAResourceWhenConfigured( $adapter = $this->adapter($client, ['bucket' => 'bucket', 'throw' => true]); $this->expectException(UnableToReadFile::class); - $this->expectExceptionMessageIsOrContains('Downloaded object does not contain a file resource.'); + $this->expectExceptionMessageIsOrContains('The stream must be readable, writable, or both.'); $adapter->readStreamRange('file.txt', 3, 5); } @@ -317,8 +378,8 @@ public function testReadFailuresReturnNullWhenExceptionsAreDisabled(): void public function testNonResourceReturnsNullWhenExceptionsAreDisabled(): void { - $stream = m::mock(StreamInterface::class); - $stream->shouldReceive('detach')->once()->andReturnNull(); + $stream = Utils::streamFor('contents'); + fclose($stream->detach()); $object = m::mock(StorageObject::class); $object->shouldReceive('downloadAsStream')->once()->andReturn($stream); $bucket = m::mock(Bucket::class); @@ -330,6 +391,54 @@ public function testNonResourceReturnsNullWhenExceptionsAreDisabled(): void $this->assertNull($adapter->readStream('file.txt')); } + #[TestWith([false], 'decoded contents')] + #[TestWith([true], 'producer cancellation')] + public function testDecodedReadStreamPreservesCancellationAndClosesItsBody(bool $cancel): void + { + $failure = new RuntimeException('Read cleanup failed.', previous: new CanceledException); + $native = fopen('php://temp', 'w+b'); + fwrite($native, gzencode('contents')); + rewind($native); + $body = new InflateBodyStream($cancel + ? new PumpStream(static fn () => throw $failure) + : Utils::streamFor($native)); + $object = m::mock(StorageObject::class); + $object->shouldReceive('downloadAsStream')->once()->andReturn($body); + $bucket = m::mock(Bucket::class); + $bucket->shouldReceive('object')->once()->with('file.gz')->andReturn($object); + $client = m::mock(StorageClient::class); + $client->shouldReceive('bucket')->once()->with('bucket')->andReturn($bucket); + $adapter = $this->adapter($client, ['bucket' => 'bucket']); + $resource = $adapter->readStream('file.gz'); + $caught = null; + + try { + try { + $contents = stream_get_contents($resource); + } catch (Throwable $exception) { + $caught = $exception; + } + + fclose($resource); + + if ($cancel) { + $this->assertSame($failure, $caught); + } else { + $this->assertNull($caught); + $this->assertSame('contents', $contents); + $this->assertFalse(is_resource($native)); + } + } finally { + if (is_resource($resource)) { + fclose($resource); + } + $body->close(); + if (is_resource($native)) { + fclose($native); + } + } + } + private function adapter(StorageClient $client, array $config): GoogleCloudStorageAdapter { return new GoogleCloudStorageAdapter( diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 3a145dcd36..d566c7ce2f 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -21,6 +21,7 @@ use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\NoSeekStream; +use GuzzleHttp\Psr7\PumpStream; use GuzzleHttp\Psr7\Request as GuzzleRequest; use GuzzleHttp\Psr7\Response as Psr7Response; use GuzzleHttp\Psr7\StreamDecoratorTrait; @@ -886,8 +887,35 @@ public function testResponseCanBeReturnedAsResource(): void $response = $this->factory->get('http://foo.com/api'); - $this->assertIsResource($response->resource()); - $this->assertSame('{"result":{"foo":"bar"}}', stream_get_contents($response->resource())); + $resource = $response->resource(); + + try { + $this->assertIsResource($resource); + $this->assertSame('{"result":{"foo":"bar"}}', stream_get_contents($resource)); + } finally { + fclose($resource); + } + + $this->assertSame('{"result":{"foo":"bar"}}', $response->body()); + } + + public function testResponseResourcePreservesProducerCancellation(): void + { + $failure = new RuntimeException('Read cleanup failed.', previous: new CanceledException); + $response = new Response(new Psr7Response(body: new PumpStream(static fn () => throw $failure))); + $resource = $response->resource(); + $caught = null; + + try { + fread($resource, 1); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + fclose($resource); + $response->close(); + } + + $this->assertSame($failure, $caught); } public function testResponseCanBeReturnedAsCollection(): void diff --git a/tests/Http/StreamWrapperTest.php b/tests/Http/StreamWrapperTest.php new file mode 100644 index 0000000000..ef5c31427f --- /dev/null +++ b/tests/Http/StreamWrapperTest.php @@ -0,0 +1,485 @@ + 0, + 'ino' => 0, + 'mode' => 33206, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 6, + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => $stBlksize, + 'blocks' => $stBlksize, + 0 => 0, + 1 => 0, + 2 => 33206, + 3 => 0, + 4 => 0, + 5 => 0, + 6 => 0, + 7 => 6, + 8 => 0, + 9 => 0, + 10 => 0, + 11 => $stBlksize, + 12 => $stBlksize, + ], fstat($handle)); + + self::assertTrue(fclose($handle)); + self::assertSame('foobar', (string) $stream); + } + + public function testStreamContext(): void + { + $stream = Utils::streamFor('foo'); + + StreamWrapper::register(); + self::assertSame('foo', file_get_contents(StreamWrapper::PROTOCOL . '://stream', false, StreamWrapper::createStreamContext($stream))); + } + + public function testStreamCast(): void + { + $streams = [ + StreamWrapper::getResource(Utils::streamFor('foo')), + StreamWrapper::getResource(Utils::streamFor('bar')), + ]; + $write = null; + $except = null; + self::assertIsInt(stream_select($streams, $write, $except, 0)); + } + + public function testStreamCastFailureRemovesStreamFromSelection(): void + { + $native = fopen('php://temp', 'r+'); + fwrite($native, 'abcdef'); + $body = Utils::streamFor($native); + $failed = StreamWrapper::detach(new LimitStream($body, 3, 1)); + $valid = StreamWrapper::getResource(Utils::streamFor('foo')); + $streams = [$failed, $valid]; + $write = null; + $except = null; + + try { + self::assertSame(1, @stream_select($streams, $write, $except, 0)); + self::assertSame([$valid], array_values($streams)); + self::assertTrue($body->isReadable()); + self::assertSame('bcd', stream_get_contents($failed)); + fclose($failed); + self::assertFalse(is_resource($native)); + } finally { + if (is_resource($failed)) { + fclose($failed); + } + + fclose($valid); + + if (is_resource($native)) { + fclose($native); + } + } + } + + public function testValidatesStream(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->expects(self::once()) + ->method('isReadable') + ->willReturn(false); + $stream->expects(self::once()) + ->method('isWritable') + ->willReturn(false); + + $this->expectException(InvalidArgumentException::class); + StreamWrapper::getResource($stream); + } + + public function testReturnsFalseWhenStreamDoesNotExist(): void + { + $result = @fopen(StreamWrapper::PROTOCOL . '://foo', 'r'); + self::assertFalse($result); + } + + public function testReturnsFalseWhenStreamContextDoesNotContainStream(): void + { + StreamWrapper::register(); + + $context = stream_context_create([ + StreamWrapper::PROTOCOL => ['stream' => 'not a stream'], + ]); + + self::assertFalse(@fopen(StreamWrapper::PROTOCOL . '://stream', 'r', false, $context)); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testGetResourceThrowsWhenFopenFails(): void + { + StreamWrapper::register(); + stream_wrapper_unregister(StreamWrapper::PROTOCOL); + self::assertTrue(stream_wrapper_register(StreamWrapper::PROTOCOL, FailingHttpStreamWrapper::class)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to create stream resource'); + + StreamWrapper::getResource(Utils::streamFor('foo')); + } + + public function testCanOpenReadonlyStream(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->expects(self::once()) + ->method('isReadable') + ->willReturn(false); + $stream->expects(self::once()) + ->method('isWritable') + ->willReturn(true); + $resource = StreamWrapper::getResource($stream); + self::assertIsResource($resource); + fclose($resource); + } + + public function testReadFailureReturnsFalse(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isWritable')->willReturn(false); + $stream->method('read')->willThrowException(new RuntimeException('read failed')); + + $resource = StreamWrapper::getResource($stream); + + self::assertFalse(fread($resource, 1)); + } + + public function testWriteFailureReturnsFalse(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(false); + $stream->method('isWritable')->willReturn(true); + $stream->method('write')->willThrowException(new RuntimeException('write failed')); + + $resource = StreamWrapper::getResource($stream); + + self::assertFalse(fwrite($resource, 'x')); + } + + public function testSeekFailureReturnsMinusOne(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isWritable')->willReturn(false); + $stream->method('seek')->willThrowException(new RuntimeException('seek failed')); + + $resource = StreamWrapper::getResource($stream); + + self::assertSame(-1, fseek($resource, 0)); + } + + public function testTellFailureMakesSeekReturnMinusOne(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isWritable')->willReturn(false); + $stream->method('tell')->willThrowException(new RuntimeException('tell failed')); + + $resource = StreamWrapper::getResource($stream); + + self::assertSame(-1, fseek($resource, 0)); + } + + public function testSeekToNegativeTargetReturnsMinusOne(): void + { + $resource = StreamWrapper::getResource(new CachingStream(Utils::streamFor('foo'))); + + self::assertSame(-1, fseek($resource, -100, SEEK_END)); + } + + public function testSeekToNegativeTargetWithUnknownSizeDoesNotMoveCursor(): void + { + $stream = FnStream::decorate(Utils::streamFor('foo'), [ + 'getSize' => function (): ?int { + return null; + }, + ]); + $resource = StreamWrapper::getResource(new CachingStream($stream)); + + self::assertSame(-1, fseek($resource, -100, SEEK_END)); + self::assertSame('foo', fread($resource, 3)); + } + + public function testEofFailureReturnsTrue(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isWritable')->willReturn(false); + $stream->method('eof')->willThrowException(new RuntimeException('eof failed')); + + $resource = StreamWrapper::getResource($stream); + + self::assertTrue(feof($resource)); + } + + public function testStatFailureReturnsFalse(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isWritable')->willReturn(false); + $stream->method('getSize')->willThrowException(new RuntimeException('size failed')); + + $resource = StreamWrapper::getResource($stream); + + self::assertFalse(fstat($resource)); + } + + public function testNonRuntimeReadFailuresStillPropagate(): void + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isWritable')->willReturn(false); + $stream->method('read')->willThrowException(new Error('read failed')); + + $resource = StreamWrapper::getResource($stream); + + try { + $this->expectException(Error::class); + fread($resource, 1); + } finally { + fclose($resource); + } + } + + public function testUrlStat(): void + { + StreamWrapper::register(); + + $stBlksize = PHP_OS_FAMILY === 'Windows' ? -1 : 0; + + self::assertEquals( + [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 0, + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => $stBlksize, + 'blocks' => $stBlksize, + 0 => 0, + 1 => 0, + 2 => 0, + 3 => 0, + 4 => 0, + 5 => 0, + 6 => 0, + 7 => 0, + 8 => 0, + 9 => 0, + 10 => 0, + 11 => $stBlksize, + 12 => $stBlksize, + ], + stat(StreamWrapper::PROTOCOL . '://stream') + ); + } + + #[RequiresPhpExtension('xmlreader')] + public function testXmlReaderWithStream(): void + { + $stream = Utils::streamFor(''); + + StreamWrapper::register(); + libxml_set_streams_context(StreamWrapper::createStreamContext($stream)); + $reader = new XMLReader; + + self::assertTrue($reader->open(StreamWrapper::PROTOCOL . '://stream')); + self::assertTrue($reader->read()); + self::assertSame('foo', $reader->name); + } + + #[RequiresPhpExtension('xmlwriter')] + public function testXmlWriterWithStream(): void + { + $stream = Utils::streamFor(fopen('php://memory', 'wb')); + + StreamWrapper::register(); + libxml_set_streams_context(StreamWrapper::createStreamContext($stream)); + $writer = new XMLWriter; + + self::assertTrue($writer->openURI(StreamWrapper::PROTOCOL . '://stream')); + self::assertTrue($writer->startDocument()); + self::assertTrue($writer->writeElement('foo')); + self::assertTrue($writer->endDocument()); + + $stream->rewind(); + self::assertXmlStringEqualsXmlString('', (string) $stream); + } + + public function testWrappedNullSizedStreamStaysNullSized(): void + { + $nullSizedStream = new PumpStream(function (): string { return ''; }); + $this->assertNull($nullSizedStream->getSize()); + + $resource = StreamWrapper::getResource($nullSizedStream); + $stream = Utils::streamFor($resource); + + $this->assertNull($stream->getSize()); + } + + #[TestWith([false], 'borrowed response body')] + #[TestWith([true], 'owned download body')] + public function testClosingResourceRespectsOwnershipWithARetainedBody(bool $owned): void + { + $native = fopen('php://temp', 'w+b'); + $body = new InflateBodyStream(Utils::streamFor($native)); + $resource = $owned ? StreamWrapper::detach($body) : StreamWrapper::getResource($body); + + try { + fclose($resource); + $this->assertSame(! $owned, is_resource($native)); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + $body->close(); + } + } + + public function testDetachTransfersTheExistingNativeResource(): void + { + $native = fopen('php://temp', 'w+b'); + $body = Utils::streamFor($native); + $resource = StreamWrapper::detach($body); + + try { + $this->assertSame($native, $resource); + $this->assertFalse($body->isReadable()); + } finally { + fclose($resource); + } + } + + public function testDetachRejectsAnAlreadyDetachedNativeStream(): void + { + $body = Utils::streamFor('foo'); + fclose($body->detach()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to create stream resource'); + StreamWrapper::detach($body); + } + + #[TestWith([false], 'read')] + #[TestWith([true], 'seek')] + public function testProducerCancellationKeepsItsOriginalException(bool $seek): void + { + $failure = new RuntimeException('Cleanup failed.', previous: new CanceledException); + $body = new PumpStream(static fn () => throw $failure); + $resource = StreamWrapper::getResource($seek ? new CachingStream($body) : $body); + $caught = null; + + try { + $seek ? fseek($resource, 3) : fread($resource, 1); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + fclose($resource); + $body->close(); + } + + $this->assertSame($failure, $caught); + } + + #[TestWith(['write'])] + #[TestWith(['tell'])] + #[TestWith(['eof'])] + #[TestWith(['getSize'])] + public function testOtherStreamOperationsPreserveCancellation(string $method): void + { + $failure = new RuntimeException('Stream cleanup failed.', previous: new CanceledException); + $body = $this->createStub(StreamInterface::class); + $body->method('isReadable')->willReturn(true); + $body->method('isWritable')->willReturn(true); + $body->method($method)->willThrowException($failure); + $resource = StreamWrapper::getResource($body); + $caught = null; + + try { + match ($method) { + 'write' => fwrite($resource, 'x'), + 'tell' => fseek($resource, 0), + 'eof' => feof($resource), + 'getSize' => fstat($resource), + }; + } catch (Throwable $exception) { + $caught = $exception; + } finally { + fclose($resource); + } + + $this->assertSame($failure, $caught); + } +} + +final class FailingHttpStreamWrapper +{ + /** @var resource */ + public $context; + + /** + * Reject opening a resource to exercise the factory's failure path. + */ + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath = null): bool + { + return false; + } +} diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTest.php index 0bba061634..b8fe614083 100644 --- a/tests/Integration/Auth/EloquentUserProviderCacheTest.php +++ b/tests/Integration/Auth/EloquentUserProviderCacheTest.php @@ -30,8 +30,11 @@ use Mockery as m; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; #[WithMigration] class EloquentUserProviderCacheTest extends TestCase @@ -434,6 +437,43 @@ public function testModelEventInvalidationExhaustsDescriptorsAndPreservesTheEarl $this->assertSame($earliest, $caught); } + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testModelEventInvalidationStopsOnCancellationAfterAnOrdinaryFailure(bool $wrapped): void + { + $user = User::query()->firstOrFail(); + $failure = new CanceledException('Invalidation canceled.'); + + if ($wrapped) { + $failure = new RuntimeException('Invalidation cleanup failed.', previous: $failure); + } + + foreach (['first', 'second', 'third'] as $name) { + $repository = $this->stubCache($name); + $expectation = $this->cacheCoordinator->shouldReceive('invalidate') + ->with($repository, $this->buildKey($user->getAuthIdentifier())); + + if ($name === 'third') { + $expectation->never(); + } else { + $expectation->once()->andThrow($name === 'first' ? new RuntimeException('Store unavailable.') : $failure); + } + + (new EloquentUserProvider($this->app->make('hash'), User::class))->enableCache($name); + } + + $caught = null; + + try { + $user->name = 'Updated'; + $user->save(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + } + public function testChangingProviderModelKeepsBothModelKeyspacesInvalidatable(): void { $user = User::query()->firstOrFail(); diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 99106080f4..642292b15c 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -999,14 +999,16 @@ public function testOrdinaryReleaseListenerFailureStillReturnsAnInvalidConnectio $pool->close(); } - public function testReleasePreservesTheFirstOrdinaryCleanupFailure(): void + #[TestWith([false], 'first ordinary failure')] + #[TestWith([true], 'later wrapped cancellation')] + public function testReleasePreservesCleanupFailurePrecedence(bool $releaseCanceled): void { $this->app->make('config')->set('database.connections.pool_test.pool.events', [ ConnectionReleasing::class, ]); $listenerFailure = new RuntimeException('Release listener failed.'); $loggingFailure = new RuntimeException('Release failure logging failed.'); - $poolReleaseFailure = new RuntimeException('Pool release failed.'); + $poolReleaseFailure = new RuntimeException('Pool release failed.', previous: $releaseCanceled ? new CanceledException : null); $logger = m::mock(StdoutLoggerInterface::class); $logger->shouldReceive('error')->once()->andThrow($loggingFailure); $this->app->instance(StdoutLoggerInterface::class, $logger); @@ -1024,7 +1026,7 @@ public function testReleasePreservesTheFirstOrdinaryCleanupFailure(): void $pooledConnection->release(); $this->fail('Expected release failure logging to fail.'); } catch (RuntimeException $exception) { - $this->assertSame($loggingFailure, $exception); + $this->assertSame($releaseCanceled ? $poolReleaseFailure : $loggingFailure, $exception); } $this->assertSame(1, $pool->getIdleCount()); diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index c0c0748668..fcc86d4598 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -38,9 +38,11 @@ use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; +use Swoole\Coroutine\CanceledException; use Symfony\Component\Mailer\Exception\HttpTransportException; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Contracts\HttpClient\ResponseInterface; +use Throwable; class NotificationSenderTest extends TestCase { @@ -179,6 +181,34 @@ public function testThrowingSendingListenerDispatchesFailedAndRethrows(): void } } + public function testThrowingFailureObserverDoesNotAllowRetryAfterCancellation(): void + { + $cancellation = new CanceledException('Delivery canceled.'); + $observerFailure = new RuntimeException('Failure storage unavailable.'); + $events = new EventDispatcher(Container::getInstance()); + $events->listen(NotificationFailed::class, static fn () => throw $observerFailure); + $manager = m::mock(ChannelManager::class); + $driver = m::mock(); + $manager->shouldReceive('driver')->andReturn($driver); + $attempts = 0; + $driver->shouldReceive('send')->andReturnUsing(static function () use ($cancellation, &$attempts): never { + ++$attempts; + throw $cancellation; + }); + $sender = new NotificationSender($manager, m::mock(BusDispatcherContract::class), $events); + $caught = null; + + try { + retry(2, fn () => $sender->sendNow(new AnonymousNotifiable, new Notification, ['mail'])); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame(1, $attempts); + $this->assertSame($observerFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + } + public function testThrowingDeliveredListenerIsNotRelabeledAsDeliveryFailure(): void { $exception = new RuntimeException('delivered listener failed'); diff --git a/tests/Passport/PassportCacheTest.php b/tests/Passport/PassportCacheTest.php index dc99a25f6f..c34a98fcb0 100644 --- a/tests/Passport/PassportCacheTest.php +++ b/tests/Passport/PassportCacheTest.php @@ -31,6 +31,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -419,6 +421,41 @@ public function testBatchInvalidationContinuesAfterAFailureFollowingCommit(): vo $this->assertTrue(Token::findToken($second->id)->revoked); } + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testBatchInvalidationStopsOnCancellationAfterAnOrdinaryFailure(bool $wrapped): void + { + $failure = new CanceledException('Invalidation canceled.'); + + if ($wrapped) { + $failure = new RuntimeException('Invalidation cleanup failed.', previous: $failure); + } + + $cache = $this->app->make('cache')->store('passport-file'); + $coordinator = m::mock(ModelCacheCoordinator::class, [$this->app])->makePartial(); + $coordinator->shouldReceive('invalidate')->once() + ->with($cache, 'passport:tokens:token')->andThrow(new LockTimeoutException); + $coordinator->shouldReceive('invalidate')->once() + ->with($cache, 'passport:tokens:token-second')->andThrow($failure); + $coordinator->shouldReceive('invalidate')->never() + ->with($cache, 'passport:tokens:token-third'); + $this->app->instance(ModelCacheCoordinator::class, $coordinator); + + $first = $this->createToken(); + $first->replicate()->forceFill(['id' => 'token-second'])->save(); + $first->replicate()->forceFill(['id' => 'token-third'])->save(); + $caught = null; + + try { + Client::findOrFail($first->client_id)->tokens()->update(['revoked' => true]); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertSame(0, DB::transactionLevel()); + } + public function testOwnedClientRelationUpdatesAndDeletesAcrossSeveralBatches(): void { $owner = (new User)->forceFill(['id' => 1]); diff --git a/tests/Passport/TokenIssuanceTest.php b/tests/Passport/TokenIssuanceTest.php index 58858b76c8..d2658d3aa3 100644 --- a/tests/Passport/TokenIssuanceTest.php +++ b/tests/Passport/TokenIssuanceTest.php @@ -131,13 +131,19 @@ public static function outcomes(): iterable yield 'failure' => [false]; } - #[TestWith([false, false])] - #[TestWith([true, false])] - #[TestWith([true, true])] - public function testCommitFailuresAreReportedOnlyAfterIssuanceCommits(bool $afterCommit, bool $canceled): void + #[TestWith([false, false], 'ordinary failure before commit')] + #[TestWith([true, false], 'ordinary failure after commit')] + #[TestWith([true, true], 'cancellation after commit')] + #[TestWith([true, true, true], 'wrapped cancellation after commit')] + public function testCommitFailuresAreReportedOnlyAfterIssuanceCommits(bool $afterCommit, bool $canceled, bool $wrapped = false): void { Exceptions::fake(); $failure = $canceled ? new CanceledException('Commit canceled.') : new RuntimeException('Commit callback failed.'); + + if ($wrapped) { + $failure = new RuntimeException('Commit cleanup failed.', previous: $failure); + } + Event::listen( $afterCommit ? AccessTokenCreated::class : TransactionCommitting::class, static function () use ($failure): never { @@ -228,15 +234,22 @@ public function testReplayingARevokedFamilyDoesNotRewriteItOrDispatchEvents(): v Event::assertNotDispatched(AccessTokenRevoked::class); } - #[TestWith([false, false])] - #[TestWith([true, false])] - #[TestWith([false, true])] - #[TestWith([true, true])] - public function testRollbackFailuresPreserveTheIssuanceErrorAndRestoreContext(bool $canceled, bool $cleanupCanceled): void + #[TestWith([false, false], 'ordinary failures')] + #[TestWith([true, false], 'issuance canceled')] + #[TestWith([false, true], 'rollback canceled')] + #[TestWith([true, true], 'both canceled')] + #[TestWith([false, true, true], 'wrapped rollback cancellation')] + #[TestWith([true, true, true], 'wrapped cancellation at both boundaries')] + public function testRollbackFailuresPreserveTheIssuanceErrorAndRestoreContext(bool $canceled, bool $cleanupCanceled, bool $wrapped = false): void { $failure = $canceled ? new CanceledException('Issuance canceled.') : new RuntimeException('Issuance failed.'); $cleanup = $cleanupCanceled ? new CanceledException('Rollback canceled.') : new RuntimeException('Rollback failed.'); + if ($wrapped) { + $failure = $canceled ? new RuntimeException('Issuance cleanup failed.', previous: $failure) : $failure; + $cleanup = new RuntimeException('Rollback cleanup failed.', previous: $cleanup); + } + TokenIssuance::run($this->servers, function () use ($failure, $cleanup, $canceled, $cleanupCanceled): void { $outer = TokenIssuance::current(); diff --git a/tests/Pennant/StoredValueCacheTest.php b/tests/Pennant/StoredValueCacheTest.php index 49b0445a9b..05ce17a350 100644 --- a/tests/Pennant/StoredValueCacheTest.php +++ b/tests/Pennant/StoredValueCacheTest.php @@ -19,7 +19,10 @@ use JsonSerializable; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -301,6 +304,34 @@ public function testATargetThatThrowsDoesntStopTheOthers(): void } } + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testCancellationStopsInvalidationAfterAnOrdinaryFailure(bool $wrapped): void + { + $failure = new CanceledException('Invalidation canceled.'); + + if ($wrapped) { + $failure = new RuntimeException('Invalidation cleanup failed.', previous: $failure); + } + + $failed = m::mock(Repository::class); + $failed->shouldReceive('put')->once()->andReturnFalse(); + $canceled = m::mock(Repository::class); + $canceled->shouldReceive('put')->once()->andThrow($failure); + $unvisited = m::mock(Repository::class); + $unvisited->shouldNotReceive('put'); + $caught = null; + + try { + (new StoredValueCache('features', [[$failed, 60], [$canceled, 60], [$unvisited, 60]])) + ->rotatePartition('testing', null); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + } + #[DataProvider('invalidTtls')] public function testCacheTtlMustBeAPositiveInteger(mixed $ttl): void { diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 9bcf124731..ed657c91e5 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -127,7 +127,8 @@ public function testInvokedProcessPoolCount(): void #[TestWith([false])] #[TestWith([true])] - public function testProcessPoolRollsBackProcessesStartedBeforeCreationFailure(bool $cleanupIsCanceled): void + #[TestWith([true, true], 'wrapped cleanup cancellation')] + public function testProcessPoolRollsBackProcessesStartedBeforeCreationFailure(bool $cleanupIsCanceled, bool $wrapped = false): void { $factory = new class extends Factory { /** @var list */ @@ -139,11 +140,14 @@ public function newPendingProcess(): PendingProcess } }; $cleanupFailure = $cleanupIsCanceled ? new CanceledException('Rollback canceled.') : null; + if ($wrapped) { + $cleanupFailure = new RuntimeException('Rollback cleanup failed.', previous: $cleanupFailure); + } $startedProcess = new class($cleanupFailure) extends FakeInvokedProcess { /** * Create a started child with optional cleanup cancellation. */ - public function __construct(private ?CanceledException $failure) + public function __construct(private ?Throwable $failure) { parent::__construct('first', (new FakeProcessDescription)->runsFor(iterations: 10)); } @@ -204,12 +208,16 @@ public function start(array|string|null $command = null, ?callable $output = nul #[TestWith([false])] #[TestWith([true])] - public function testProcessPoolWaitStopsEverySiblingAndPreservesCancellationPrecedence(bool $cleanupIsCanceled): void + #[TestWith([true, true], 'wrapped cleanup cancellation')] + public function testProcessPoolWaitStopsEverySiblingAndPreservesCancellationPrecedence(bool $cleanupIsCanceled, bool $wrapped = false): void { $waitFailure = new RuntimeException('Unable to wait for the first process.'); $cleanupFailure = $cleanupIsCanceled ? new CanceledException('Stopping the first process was canceled.') : new RuntimeException('Unable to stop the first process.'); + if ($wrapped) { + $cleanupFailure = new RuntimeException('Stop cleanup failed.', previous: $cleanupFailure); + } $failingProcess = new class($waitFailure, $cleanupFailure) extends FakeInvokedProcess { public bool $stopAttempted = false; @@ -265,14 +273,18 @@ public function stop(float $timeout = 10, ?int $signal = null): ?int #[TestWith([false])] #[TestWith([true])] - public function testProcessPoolStopContinuesAfterARunningCheckFailure(bool $cleanupIsCanceled): void + #[TestWith([true, true], 'wrapped cleanup cancellation')] + public function testProcessPoolStopContinuesAfterARunningCheckFailure(bool $cleanupIsCanceled, bool $wrapped = false): void { $runningFailure = new RuntimeException('Unable to inspect the first process.'); $cleanupFailure = $cleanupIsCanceled ? new CanceledException('Stopping the process was canceled.') : null; + if ($wrapped) { + $cleanupFailure = new RuntimeException('Stop cleanup failed.', previous: $cleanupFailure); + } $failingProcess = new class($runningFailure, $cleanupFailure) extends FakeInvokedProcess { public bool $stopAttempted = false; - public function __construct(protected RuntimeException $failure, private ?CanceledException $cleanupFailure) + public function __construct(protected RuntimeException $failure, private ?Throwable $cleanupFailure) { parent::__construct('first', (new FakeProcessDescription)->runsFor(iterations: 10)); } @@ -554,7 +566,9 @@ public function testRealInvokedProcessCanBeStoppedThroughContract(): void #[TestWith([true])] #[TestWith([false])] - public function testStopStillTerminatesTheProcessWhenDatabaseReleaseFails(bool $releaseIsCanceled): void + #[TestWith([true, true], 'wrapped operation cancellation')] + #[TestWith([false, true], 'wrapped cleanup cancellation')] + public function testStopStillTerminatesTheProcessWhenDatabaseReleaseFails(bool $releaseIsCanceled, bool $wrapped = false): void { $failure = $releaseIsCanceled ? new CanceledException('Database release canceled.') @@ -562,6 +576,13 @@ public function testStopStillTerminatesTheProcessWhenDatabaseReleaseFails(bool $ $cleanupFailure = $releaseIsCanceled ? new RuntimeException('Cleanup failed.') : new CanceledException('Cleanup canceled.'); + if ($wrapped) { + if ($releaseIsCanceled) { + $failure = new RuntimeException('Release cleanup failed.', previous: $failure); + } else { + $cleanupFailure = new RuntimeException('Stop cleanup failed.', previous: $cleanupFailure); + } + } $lease = m::mock(ConnectionLease::class); $lease->shouldReceive('releaseIfIdle')->once()->andThrow($failure); CoroutineContext::set('__process.test.lease', $lease); @@ -572,7 +593,7 @@ public function testStopStillTerminatesTheProcessWhenDatabaseReleaseFails(bool $ try { (new InvokedProcess($process))->stop(5, 15); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $caught = $exception; } finally { CoroutineContext::forget('__process.test.lease'); @@ -605,7 +626,8 @@ public function testInterruptedStopFinishesEmergencyCleanup(float $timeout): voi #[TestWith(['run'])] #[TestWith(['start'])] - public function testPendingProcessStopsTheChildWhenExecutionFailsBeforeHandoff(string $method): void + #[TestWith(['start', true], 'wrapped cleanup cancellation')] + public function testPendingProcessStopsTheChildWhenExecutionFailsBeforeHandoff(string $method, bool $cleanupIsCanceled = false): void { $failure = $method === 'run' ? new CanceledException('Run canceled.') @@ -613,7 +635,14 @@ public function testPendingProcessStopsTheChildWhenExecutionFailsBeforeHandoff(s $process = m::mock(SymfonyProcess::class); $process->shouldReceive('getCommandline')->once()->andReturn('test-command'); $process->shouldReceive($method)->once()->with(null)->andThrow($failure); - $process->shouldReceive('stop')->once()->with(0, null)->andReturn(0); + $cleanupFailure = $cleanupIsCanceled + ? new RuntimeException('Stop cleanup failed.', previous: new CanceledException) + : null; + if ($cleanupFailure !== null) { + $process->shouldReceive('stop')->twice()->with(0, null)->andThrow($cleanupFailure); + } else { + $process->shouldReceive('stop')->once()->with(0, null)->andReturn(0); + } $pending = new class(new Factory, $process) extends PendingProcess { /** * Create a pending process with a controlled execution failure. @@ -639,7 +668,7 @@ protected function toSymfonyProcess(array|string|null $command): SymfonyProcess $caught = $exception; } - $this->assertSame($failure, $caught); + $this->assertSame($cleanupFailure ?? $failure, $caught); } public function testOutputGuardPreservesCancellationDuringEmergencyStop(): void diff --git a/tests/Redis/ConcurrencyLimiterBuilderTest.php b/tests/Redis/ConcurrencyLimiterBuilderTest.php index 7943ac96a9..d4f7a672a3 100644 --- a/tests/Redis/ConcurrencyLimiterBuilderTest.php +++ b/tests/Redis/ConcurrencyLimiterBuilderTest.php @@ -10,7 +10,10 @@ use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; /** * Tests for ConcurrencyLimiterBuilder. @@ -130,28 +133,38 @@ public function testThenPropagatesReleaseFailureAfterSuccessfulCallback(): void } } - public function testThenPreservesCallbackFailureWhenReleaseAlsoFails(): void + #[TestWith(['ordinary'])] + #[TestWith(['direct cancellation'])] + #[TestWith(['wrapped cancellation'])] + public function testThenPreservesCallbackFailureWhenReleaseAlsoFails(string $kind): void { $redis = $this->mockRedis(); $callbackException = new RuntimeException('callback failed'); + $releaseException = match ($kind) { + 'direct cancellation' => new CanceledException, + 'wrapped cancellation' => new RuntimeException('Release cleanup failed.', previous: new CanceledException), + default => new RuntimeException('release failed'), + }; $this->expectSlotClaim($redis, 'test-key1'); $redis->shouldReceive('eval') ->once() - ->andThrow(new RuntimeException('release failed')); + ->andThrow($releaseException); $builder = new ConcurrencyLimiterBuilder($redis, 'test-key'); $builder->limit(5)->block(0); + $caught = null; + try { $builder->then(function () use ($callbackException): never { throw $callbackException; }); - - $this->fail('Expected the callback failure to be thrown.'); - } catch (RuntimeException $exception) { - $this->assertSame($callbackException, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($kind === 'ordinary' ? $callbackException : $releaseException, $caught); } public function testThenCallsFailureCallbackOnTimeout(): void diff --git a/tests/Redis/ConcurrencyLimiterTest.php b/tests/Redis/ConcurrencyLimiterTest.php index 144c5e9d97..6de14191f3 100644 --- a/tests/Redis/ConcurrencyLimiterTest.php +++ b/tests/Redis/ConcurrencyLimiterTest.php @@ -13,7 +13,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class ConcurrencyLimiterTest extends TestCase { @@ -105,26 +108,36 @@ public function testBlockPropagatesReleaseFailureAfterSuccessfulCallback(): void } } - public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(): void + #[TestWith(['ordinary'])] + #[TestWith(['direct cancellation'])] + #[TestWith(['wrapped cancellation'])] + public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(string $kind): void { $redis = $this->mockRedis(); $callbackException = new RuntimeException('callback failed'); + $releaseException = match ($kind) { + 'direct cancellation' => new CanceledException, + 'wrapped cancellation' => new RuntimeException('Release cleanup failed.', previous: new CanceledException), + default => new RuntimeException('release failed'), + }; $this->expectSlotClaim($redis, 'test-lock1'); $redis->expects('eval') - ->andThrow(new RuntimeException('release failed')); + ->andThrow($releaseException); $limiter = new ConcurrencyLimiter($redis, 'test-lock', 3, 60); + $caught = null; + try { $limiter->block(5, function () use ($callbackException): never { throw $callbackException; }); - - $this->fail('Expected the callback failure to be thrown.'); - } catch (RuntimeException $exception) { - $this->assertSame($callbackException, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($kind === 'ordinary' ? $callbackException : $releaseException, $caught); } public function testBlockThrowsTimeoutExceptionWhenCannotAcquire(): void diff --git a/tests/Redis/RedisCancellationTest.php b/tests/Redis/RedisCancellationTest.php index d0399a540f..83cec0dfaf 100644 --- a/tests/Redis/RedisCancellationTest.php +++ b/tests/Redis/RedisCancellationTest.php @@ -7,11 +7,13 @@ use Hypervel\Engine\Coroutine; use Hypervel\Redis\RedisCancellation; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use RedisClusterException; use RedisException; use RuntimeException; use Swoole\Coroutine\CanceledException; use Swoole\Coroutine\Channel; +use Throwable; class RedisCancellationTest extends TestCase { @@ -29,6 +31,25 @@ public function testItDoesNotClassifyOrdinaryFailures(): void $this->assertNull(RedisCancellation::cancellationFrom(new RuntimeException('failed'), 'boundary canceled')); } + #[TestWith([true, false], 'operation cancellation')] + #[TestWith([false, true], 'cleanup cancellation')] + #[TestWith([true, true], 'first cancellation')] + #[TestWith([false, false], 'ordinary cleanup precedence')] + public function testFailureSelectionPreservesWrappedCancellation(bool $operationCanceled, bool $cleanupCanceled): void + { + $operation = new RuntimeException('Operation failed.', previous: $operationCanceled ? new CanceledException : null); + $cleanup = new RuntimeException('Cleanup failed.', previous: $cleanupCanceled ? new CanceledException : null); + $caught = null; + + try { + RedisCancellation::throwOperationOrCleanupFailure($operation, $cleanup); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($operationCanceled ? $operation : $cleanup, $caught); + } + public function testItNormalizesOnlyNativePhpRedisFailuresAtTheCancellationBoundary(): void { $blocker = new Channel(1); diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index c1efa22938..337e1c2b80 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -28,6 +28,7 @@ use Hypervel\Tests\Redis\Fixtures\RespServer; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use Redis as PhpRedis; use RedisCluster; use RedisClusterException; @@ -170,9 +171,14 @@ public function testSubscriberLoopConvertsNonThrowingMessageWaitCancellation(): } } - public function testSubscriberLoopPreservesCallbackCancellationOverOrdinaryCloseFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testSubscriberLoopPreservesCallbackCancellationOverOrdinaryCloseFailure(bool $wrapped): void { $cancellation = new CanceledException('callback canceled'); + if ($wrapped) { + $cancellation = new RuntimeException('Callback cleanup failed.', previous: $cancellation); + } $channel = m::mock(Channel::class); $channel->expects('pop')->andReturn(new Message('updates', 'payload')); $subscriber = $this->mockSubscriber($channel, new RuntimeException('close failed')); @@ -183,7 +189,7 @@ public function testSubscriberLoopPreservesCallbackCancellationOverOrdinaryClose throw $cancellation; }); $this->fail('Expected the cancellation to escape.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } @@ -838,9 +844,14 @@ public function testEventDispatchedOnErrorWithExceptionInfo(): void } } - public function testCommandCancellationSkipsFailureEventAndStillReleasesTheConnection(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCommandCancellationSkipsFailureEventAndStillReleasesTheConnection(bool $wrapped): void { $cancellation = new CanceledException('command canceled'); + if ($wrapped) { + $cancellation = new RuntimeException('Command cleanup failed.', previous: $cancellation); + } $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldNotReceive('hasListeners'); $connection = $this->createMockRedisConnection( @@ -1033,9 +1044,14 @@ public function testCommandFailureRemainsPrimaryOverCleanupFailure(): void } } - public function testCleanupCancellationSupersedesOrdinaryCommandFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCleanupCancellationSupersedesOrdinaryCommandFailure(bool $wrapped): void { $cancellation = new CanceledException('cleanup canceled'); + if ($wrapped) { + $cancellation = new RuntimeException('Release cleanup failed.', previous: $cancellation); + } $connection = $this->createMockRedisConnection( 'get', exception: new RuntimeException('Command failed.'), @@ -1050,9 +1066,14 @@ public function testCleanupCancellationSupersedesOrdinaryCommandFailure(): void } } - public function testEventCancellationRemainsPrimaryOverOrdinaryCleanupFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testEventCancellationRemainsPrimaryOverOrdinaryCleanupFailure(bool $wrapped): void { $cancellation = new CanceledException('event canceled'); + if ($wrapped) { + $cancellation = new RuntimeException('Event cleanup failed.', previous: $cancellation); + } $dispatcher = m::mock(Dispatcher::class); $dispatcher->expects('hasListeners')->with(CommandExecuted::class)->andReturnTrue(); $dispatcher->expects('dispatch')->andThrow($cancellation); @@ -1303,9 +1324,14 @@ public function testWithConnectionReleasesOnException(): void }); } - public function testWithConnectionPreservesCallbackCancellationOverCleanupFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testWithConnectionPreservesCallbackCancellationOverCleanupFailure(bool $wrapped): void { $cancellation = new CanceledException('Callback canceled.'); + if ($wrapped) { + $cancellation = new RuntimeException('Callback cleanup failed.', previous: $cancellation); + } $connection = $this->mockConnection(); $connection->expects('release')->andThrow(new RuntimeException('Cleanup failed.')); @@ -1918,9 +1944,14 @@ public function testClusterDiscoveryNormalizesWrappedCancellationAndStillRelease $this->assertSame($nativeFailure, $exception->getPrevious()); } - public function testClusterDiscoveryReleaseCancellationSupersedesOrdinaryDiscoveryFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testClusterDiscoveryReleaseCancellationSupersedesOrdinaryDiscoveryFailure(bool $wrapped): void { $cancellation = new CanceledException('Release canceled.'); + if ($wrapped) { + $cancellation = new RuntimeException('Release cleanup failed.', previous: $cancellation); + } $connection = m::mock(PhpRedisClusterConnection::class); $connection->expects('getConnection')->andReturnSelf(); $connection->expects('masters')->andThrow(new RuntimeException('Master discovery failed.')); diff --git a/tests/Saloon/OAuthAccountTest.php b/tests/Saloon/OAuthAccountTest.php index a0f58722ee..e9cfe6d3a6 100644 --- a/tests/Saloon/OAuthAccountTest.php +++ b/tests/Saloon/OAuthAccountTest.php @@ -862,6 +862,44 @@ public function testCleanupAndReportingFailuresDoNotReplaceCancellation(): void $this->assertNotNull($account->fresh()->refresh_claim); } + #[DefineEnvironment('configureCleanupFailingAccountModel')] + #[TestWith([false])] + #[TestWith([true])] + public function testCancellationDuringClaimCleanupStopsRenewal(bool $duringReporting): void + { + Sleep::fake(); + $cancellation = new RuntimeException('Logging cleanup failed.', previous: new CanceledException); + $handler = $this->createMock(ExceptionHandler::class); + if ($duringReporting) { + $handler->expects($this->once())->method('report')->willReturnCallback(static function (Throwable $failure) use ($cancellation): never { + self::assertSame('Claim cleanup failed.', $failure->getMessage()); + + throw $cancellation; + }); + } else { + $handler->expects($this->never())->method('report'); + } + $this->app->instance(ExceptionHandler::class, $handler); + $account = $this->refreshableAccount(); + if (! $duringReporting) { + CleanupFailingAccountBuilder::$failure = $cancellation; + } + $providerFailure = new RuntimeException('Provider failed.'); + $authenticator = $account->authenticator(static fn () => throw $providerFailure); + $caught = null; + + try { + $this->authorization($authenticator); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + CleanupFailingAccountBuilder::$failure = null; + } + + $this->assertSame($cancellation, $caught); + Sleep::assertNeverSlept(); + } + public function testConnectorConvenienceDisablesNestedRetriesAfterTheCallerModifier(): void { Sleep::fake(); @@ -912,7 +950,9 @@ public function testStreamedInvalidGrantOverridesServerStatusAndRetainsItsRespon $this->assertNull($account->fresh()->refresh_claim); } - public function testGuzzleResponseTransferFailuresRetryWithoutReadingTheIncompleteBody(): void + #[TestWith([false])] + #[TestWith([true])] + public function testGuzzleResponseTransferFailuresRetryWithoutReadingTheIncompleteBody(bool $canceled): void { Sleep::fake(); $account = $this->refreshableAccount(); @@ -923,7 +963,7 @@ public function testGuzzleResponseTransferFailuresRetryWithoutReadingTheIncomple return false; }); - $failure = new ResponseTransferException('Transfer failed.', new PsrRequest('POST', 'https://example.test/token'), new PsrResponse(200, [], $body)); + $failure = new ResponseTransferException('Transfer failed.', new PsrRequest('POST', 'https://example.test/token'), new PsrResponse(200, [], $body), $canceled ? new CanceledException : null); $authenticator = $account->authenticator(function () use ($failure, &$calls): never { ++$calls; throw $failure; @@ -936,9 +976,12 @@ public function testGuzzleResponseTransferFailuresRetryWithoutReadingTheIncomple $this->assertSame($failure, $exception); } - $this->assertSame(3, $calls); + $this->assertSame($canceled ? 1 : 3, $calls); $this->assertSame(0, $reads); $this->assertNull($account->fresh()->refresh_claim); + if ($canceled) { + Sleep::assertNeverSlept(); + } } #[TestWith([false])] @@ -1062,13 +1105,15 @@ public function newEloquentBuilder(QueryBuilder $query): Builder class CleanupFailingAccountBuilder extends Builder { + public static ?Throwable $failure = null; + /** * Allow claiming, then fail the cleanup write. */ public function update(array $values): int { if (array_key_exists('refresh_claim', $values) && $values['refresh_claim'] === null) { - throw new RuntimeException('Claim cleanup failed.'); + throw static::$failure ?? new RuntimeException('Claim cleanup failed.'); } return parent::update($values); diff --git a/tests/Session/RedisSessionHandlerTest.php b/tests/Session/RedisSessionHandlerTest.php index 31ba558feb..aca6e756df 100644 --- a/tests/Session/RedisSessionHandlerTest.php +++ b/tests/Session/RedisSessionHandlerTest.php @@ -20,7 +20,10 @@ use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; use UnexpectedValueException; use function Hypervel\Coroutine\parallel; @@ -989,10 +992,17 @@ public function testClusterBulkDestroyRethrowsPayloadFailureAfterSuccessfulClean } } - public function testClusterBulkDestroyRetainsCleanupFailureWhenBothOperationsFail(): void + #[TestWith(['ordinary'])] + #[TestWith(['direct cancellation'])] + #[TestWith(['wrapped cancellation'])] + public function testClusterBulkDestroyRetainsCleanupFailureWhenBothOperationsFail(string $kind): void { $owner = $this->ownerDigest('users', 'user-1'); - $payloadFailure = new UnexpectedValueException('payload failed'); + $payloadFailure = match ($kind) { + 'direct cancellation' => new CanceledException, + 'wrapped cancellation' => new RuntimeException('Payload cleanup failed.', previous: new CanceledException), + default => new UnexpectedValueException('payload failed'), + }; $connection = $this->expectConnection(cluster: true); $connection->shouldReceive('hKeys')->once()->andReturn(['invalid', self::SESSION_ID]); $connection->shouldReceive('evalWithShaCache')->once()->andThrow($payloadFailure); @@ -1001,14 +1011,21 @@ public function testClusterBulkDestroyRetainsCleanupFailureWhenBothOperationsFai ->with('sessions:_users:' . $owner . ':sessions', 'invalid') ->andReturnFalse(); + $caught = null; try { $this->handler(tracked: true)->destroyUserSessions('users', 'user-1'); - $this->fail('Expected the combined failure to propagate.'); - } catch (RuntimeException $exception) { - $this->assertStringContainsString(UnexpectedValueException::class, $exception->getMessage()); - $this->assertStringContainsString('payload failed', $exception->getMessage()); - $this->assertInstanceOf(UnexpectedValueException::class, $exception->getPrevious()); - $this->assertSame('Redis failed to clean the user session index.', $exception->getPrevious()->getMessage()); + } catch (Throwable $exception) { + $caught = $exception; + } + + if ($kind === 'ordinary') { + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertStringContainsString(UnexpectedValueException::class, $caught->getMessage()); + $this->assertStringContainsString('payload failed', $caught->getMessage()); + $this->assertInstanceOf(UnexpectedValueException::class, $caught->getPrevious()); + $this->assertSame('Redis failed to clean the user session index.', $caught->getPrevious()->getMessage()); + } else { + $this->assertSame($payloadFailure, $caught); } } diff --git a/tests/Socialite/EmailLookupTest.php b/tests/Socialite/EmailLookupTest.php new file mode 100644 index 0000000000..9233da4687 --- /dev/null +++ b/tests/Socialite/EmailLookupTest.php @@ -0,0 +1,70 @@ + new CanceledException, + 'wrapped' => new RuntimeException('HTTP cleanup failed.', previous: new CanceledException), + default => new RuntimeException('Email lookup failed.'), + }; + $provider = new $providerClass(Request::create('/'), 'client', 'secret', 'redirect'); + $http = m::mock(Client::class); + $provider->setHttpClient($http); + $userData = $providerClass === GithubProvider::class + ? ['id' => 'user-id', 'node_id' => 'node-id', 'login' => 'taylor', 'avatar_url' => 'https://example.com/avatar'] + : ['uuid' => 'user-id', 'username' => 'taylor']; + $http->shouldReceive('get')->once()->ordered()->andReturn(new Response(body: json_encode($userData))); + $http->shouldReceive('get')->once()->ordered()->andThrow($failure); + $caught = null; + $user = null; + + try { + $user = $provider->userFromToken('token'); + } catch (Throwable $exception) { + $caught = $exception; + } + + if ($kind === 'ordinary') { + $this->assertNull($caught); + $this->assertSame('user-id', $user->getId()); + $this->assertNull($user->getEmail()); + } else { + $this->assertSame($failure, $caught); + $this->assertNull($user); + } + } + + /** + * Provide cancellation and ordinary failures for both optional email lookups. + */ + public static function emailFailures(): array + { + return [ + 'GitHub ordinary' => [GithubProvider::class, 'ordinary'], + 'GitHub direct cancellation' => [GithubProvider::class, 'direct'], + 'GitHub wrapped cancellation' => [GithubProvider::class, 'wrapped'], + 'Bitbucket ordinary' => [BitbucketProvider::class, 'ordinary'], + 'Bitbucket direct cancellation' => [BitbucketProvider::class, 'direct'], + 'Bitbucket wrapped cancellation' => [BitbucketProvider::class, 'wrapped'], + ]; + } +} diff --git a/tests/Support/SafeCallerTest.php b/tests/Support/SafeCallerTest.php index ab1101b086..f054070443 100644 --- a/tests/Support/SafeCallerTest.php +++ b/tests/Support/SafeCallerTest.php @@ -11,8 +11,10 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class SafeCallerTest extends TestCase { @@ -96,9 +98,15 @@ public function testCallReturnsDefaultWhenExceptionReportingFails(): void } } - public function testCallPreservesCancellationFromCallback(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testCallPreservesCancellationFromCallback(bool $wrapped): void { $cancellation = new CanceledException('canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('Callback cleanup failed.', previous: $cancellation); + } $container = m::mock(Container::class); $container->shouldNotReceive('has'); $caller = new SafeCaller($container); @@ -107,15 +115,22 @@ public function testCallPreservesCancellationFromCallback(): void $caller->call(fn () => throw $cancellation); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } - public function testCallPreservesCancellationFromExceptionReporter(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testCallPreservesCancellationFromExceptionReporter(bool $wrapped): void { $failure = new RuntimeException('failed'); $cancellation = new CanceledException('canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('Reporter cleanup failed.', previous: $cancellation); + } + $defaultCalled = false; $handler = m::mock(ExceptionHandlerContract::class); $handler->shouldReceive('report')->once()->with($failure)->andThrow($cancellation); @@ -133,7 +148,7 @@ function () use (&$defaultCalled): void { ); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 2aa6502840..2da3f1e012 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -18,9 +18,12 @@ use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; +use Throwable; use TypeError; use ValueError; @@ -1226,11 +1229,18 @@ public function testReplaceArray(): void $this->assertSame('?', Str::replaceArray('?', [(object) ['foo' => 'bar']], '?')); } - public function testReplaceArrayPreservesCancellationDuringStringConversion(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testReplaceArrayPreservesCancellationDuringStringConversion(bool $wrapped): void { $cancellation = new CanceledException('canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('String conversion cleanup failed.', previous: $cancellation); + } + $replacement = new class($cancellation) { - public function __construct(private CanceledException $cancellation) + public function __construct(private Throwable $cancellation) { } @@ -1244,7 +1254,7 @@ public function __toString(): string Str::replaceArray('?', [$replacement], '?'); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } diff --git a/tests/Support/SupportTimeboxTest.php b/tests/Support/SupportTimeboxTest.php index 498008c114..ad91f2bc82 100644 --- a/tests/Support/SupportTimeboxTest.php +++ b/tests/Support/SupportTimeboxTest.php @@ -8,7 +8,10 @@ use Hypervel\Support\Timebox; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class SupportTimeboxTest extends TestCase { @@ -87,16 +90,23 @@ public function testMakeShouldNotSleepWhenEarlyReturnHasBeenFlaggedAndExceptionI } } - public function testMakeDoesNotPadCancellationFromCallback(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testMakeDoesNotPadCancellationFromCallback(bool $wrapped): void { $cancellation = new CanceledException('canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('Callback cleanup failed.', previous: $cancellation); + } + $mock = m::spy(Timebox::class)->shouldAllowMockingProtectedMethods()->makePartial(); try { $mock->call(fn () => throw $cancellation, 10000); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } finally { $mock->shouldNotHaveReceived('usleep'); diff --git a/tests/Validation/ValidationNotPwnedVerifierTest.php b/tests/Validation/ValidationNotPwnedVerifierTest.php index 6011b3bff6..26332bee99 100644 --- a/tests/Validation/ValidationNotPwnedVerifierTest.php +++ b/tests/Validation/ValidationNotPwnedVerifierTest.php @@ -15,6 +15,10 @@ use Hypervel\Testbench\TestCase; use Hypervel\Validation\NotPwnedVerifier; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class ValidationNotPwnedVerifierTest extends TestCase { @@ -121,4 +125,27 @@ public function testDnsDown(): void $exceptionHandler->assertReported(ConnectionException::class); } + + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCancellationDoesNotBecomeSuccessfulValidation(bool $wrapped): void + { + $cancellation = new CanceledException; + $failure = $wrapped ? new RuntimeException('HTTP cleanup failed.', previous: $cancellation) : $cancellation; + $exceptionHandler = new ExceptionHandlerFake(new Handler($this->app)); + $this->app->instance(ExceptionHandler::class, $exceptionHandler); + $http = new HttpFactory; + $http->fake(static fn () => throw $failure); + $verifier = new NotPwnedVerifier($http); + $caught = null; + + try { + $verifier->verify(['value' => 'password', 'threshold' => 0]); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $exceptionHandler->assertNothingReported(); + } } diff --git a/tests/View/ViewTest.php b/tests/View/ViewTest.php index deb728ff22..e347032ea9 100755 --- a/tests/View/ViewTest.php +++ b/tests/View/ViewTest.php @@ -18,6 +18,7 @@ use Hypervel\View\View; use Hypervel\View\ViewFinderInterface; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Throwable; @@ -526,9 +527,14 @@ public function testFirstRenderedObserverFailureIsThrownAfterEveryObserverRuns() $this->assertSame(['first', 'second', 'third'], $order); } - public function testPreRenderCancellationSkipsCompletionObserversAndClearsState(): void + #[TestWith([false])] + #[TestWith([true])] + public function testPreRenderCancellationSkipsCompletionObserversAndClearsState(bool $wrapped): void { $cancellation = new CanceledException; + if ($wrapped) { + $cancellation = new RuntimeException('Render cleanup failed.', previous: $cancellation); + } $factory = new Factory( m::mock(EngineResolver::class), m::mock(ViewFinderInterface::class), @@ -540,8 +546,9 @@ public function testPreRenderCancellationSkipsCompletionObserversAndClearsState( $factory->observeRendering(function () use ($cancellation): never { throw $cancellation; }); - $factory->observeRendered(function (): void { - $this->fail('Completion observers must not run after cancellation.'); + $completionObserved = false; + $factory->observeRendered(function () use (&$completionObserved): void { + $completionObserved = true; }); $engine = m::mock(Engine::class); @@ -551,26 +558,33 @@ public function testPreRenderCancellationSkipsCompletionObserversAndClearsState( try { (new View($factory, $engine, 'test', 'path'))->render(); $this->fail('Expected rendering to be cancelled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } $this->assertFalse($factory->hasSection('content')); $this->assertSame('', $factory->yieldPushContent('scripts')); $this->assertTrue($factory->doneRendering()); + $this->assertFalse($completionObserved); } - public function testRenderSectionsEngineCancellationSkipsCompletionObservers(): void + #[TestWith([false])] + #[TestWith([true])] + public function testRenderSectionsEngineCancellationSkipsCompletionObservers(bool $wrapped): void { $cancellation = new CanceledException; + if ($wrapped) { + $cancellation = new RuntimeException('Engine cleanup failed.', previous: $cancellation); + } $factory = new Factory( m::mock(EngineResolver::class), m::mock(ViewFinderInterface::class), $events = m::mock(Dispatcher::class), ); - $factory->observeRendered(function (): void { - $this->fail('Completion observers must not run after cancellation.'); + $completionObserved = false; + $factory->observeRendered(function () use (&$completionObserved): void { + $completionObserved = true; }); $engine = m::mock(Engine::class); @@ -580,16 +594,22 @@ public function testRenderSectionsEngineCancellationSkipsCompletionObservers(): try { (new View($factory, $engine, 'test', 'path'))->renderSections(); $this->fail('Expected rendering to be cancelled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } $this->assertTrue($factory->doneRendering()); + $this->assertFalse($completionObserved); } - public function testRenderedObserverCancellationStopsRemainingObservers(): void + #[TestWith([false])] + #[TestWith([true])] + public function testRenderedObserverCancellationStopsRemainingObservers(bool $wrapped): void { $cancellation = new CanceledException; + if ($wrapped) { + $cancellation = new RuntimeException('Observer cleanup failed.', previous: $cancellation); + } $factory = new Factory( m::mock(EngineResolver::class), m::mock(ViewFinderInterface::class), @@ -597,6 +617,7 @@ public function testRenderedObserverCancellationStopsRemainingObservers(): void ); $order = []; + $factory->observeRendered(static fn () => throw new RuntimeException('Earlier observer failed.')); $factory->observeRendered(function () use ($cancellation, &$order): never { $order[] = 'first'; @@ -613,17 +634,22 @@ public function testRenderedObserverCancellationStopsRemainingObservers(): void try { (new View($factory, $engine, 'test', 'path'))->render(); $this->fail('Expected completion to be cancelled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } $this->assertSame(['first'], $order); } - public function testRenderedObserverCancellationSupersedesRenderingFailure(): void + #[TestWith([false])] + #[TestWith([true])] + public function testRenderedObserverCancellationSupersedesRenderingFailure(bool $wrapped): void { $renderingException = new RuntimeException('rendering failed'); $cancellation = new CanceledException; + if ($wrapped) { + $cancellation = new RuntimeException('Observer cleanup failed.', previous: $cancellation); + } $factory = new Factory( m::mock(EngineResolver::class), m::mock(ViewFinderInterface::class), @@ -646,7 +672,7 @@ public function testRenderedObserverCancellationSupersedesRenderingFailure(): vo try { (new View($factory, $engine, 'test', 'path'))->render(); $this->fail('Expected completion to be cancelled.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } }