From 319f8d94f2afe9225757f5e851f616b50480c096 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:29 +0000 Subject: [PATCH 01/39] Preserve cancellation precedence across chained failures Cleanup can replace cancellation with another exception, and Google's SDK stores its cause outside PHP's previous-exception chain. Recognize the SDK's public cause field while retaining foreign-coroutine boundaries. Add a shared helper that preserves the first failure unless an ordinary error is superseded by this coroutine's cancellation, and document its use. Validation: CoroutineTest passes, including cleanup reporting, alternate causes, foreign ownership and failure precedence. --- src/coroutine/src/Coroutine.php | 18 ++++++++- src/docs/coroutines.md | 2 + tests/Coroutine/CoroutineTest.php | 63 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index 2e78fdc43..392dfbd55 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,26 @@ public static function causedByCancellation(Throwable $exception): bool if ($exception instanceof CanceledException) { return true; } - } while (($exception = $exception->getPrevious()) !== null); + + // Google Cloud stores the request failure outside PHP's previous-exception chain. + $exception = $exception instanceof ServiceException + ? $exception->getServiceException() ?? $exception->getPrevious() + : $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/docs/coroutines.md b/src/docs/coroutines.md index ffcc1fda2..b1e7d2ded 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/tests/Coroutine/CoroutineTest.php b/tests/Coroutine/CoroutineTest.php index 6231ed03c..ced6ba9ef 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; @@ -51,6 +52,8 @@ public static function cancellationExceptions(): array '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], 'independent child' => [$child, false], 'wrapped independent child' => [new RuntimeException(previous: $child), false], 'grandchild boundary' => [new ChildCancellationException(previous: $child), false], @@ -60,6 +63,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 +187,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; From 63a5a34858c3ec262e469aeff11247057fb40654 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:29 +0000 Subject: [PATCH 02/39] Keep cancellation out of logger serialization fallbacks String conversion and JSON serialization can execute application code whose cleanup hides cancellation in an exception chain. Propagate those failures instead of writing fallback log values. Preserve the existing fallback behavior for ordinary serialization errors. Validation: StdoutLoggerTest passes with direct and wrapped cancellation cases. --- src/core/src/Logger/StdoutLogger.php | 18 +++++++++++------- tests/Core/StdoutLoggerTest.php | 28 ++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/core/src/Logger/StdoutLogger.php b/src/core/src/Logger/StdoutLogger.php index b4e4280c1..391f03953 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/tests/Core/StdoutLoggerTest.php b/tests/Core/StdoutLoggerTest.php index 41978545e..ab79cca2f 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); } } From 71767b7b1bb832da0d46a30aef992e6f0f67cb45 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:29 +0000 Subject: [PATCH 03/39] Preserve cancellation through safe-call reporting SafeCaller could swallow cancellation when a callback or its exception reporter raised a cleanup error. Check the full cancellation cause before reporting or returning a fallback. Ordinary callback and reporting failures retain their existing behavior. Validation: SafeCallerTest passes, including callback and reporter cancellation. --- src/support/src/SafeCaller.php | 14 +++++++++----- tests/Support/SafeCallerTest.php | 23 +++++++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/support/src/SafeCaller.php b/src/support/src/SafeCaller.php index fab5efda7..90e81f20c 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/tests/Support/SafeCallerTest.php b/tests/Support/SafeCallerTest.php index ab1101b08..f05407044 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); } From 1376b5f6889851ab34606c5c4488bb1b82626b62 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:29 +0000 Subject: [PATCH 04/39] Propagate cancellation from string replacements Fallback string conversion can run user code and previously treated cancellation wrapped by cleanup as an ordinary conversion error. Preserve cancellation instead of substituting fallback text, without changing ordinary conversion fallback semantics. Validation: SupportStrTest passes, including direct and wrapped replacement conversion failures. --- src/support/src/Str.php | 10 ++++++---- tests/Support/SupportStrTest.php | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 568f88ab0..7ecd79508 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/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 2aa650284..2da3f1e01 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); } } From 5ec7de053fdab42b1a0921aa1f4dc71d230e18f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:29 +0000 Subject: [PATCH 05/39] Avoid padding canceled timeboxed operations A cleanup exception carrying cancellation could make Timebox wait out its minimum duration after the operation had been canceled. Recognize the full cancellation chain before entering the normal delay path. Keep timing behavior for ordinary callback failures unchanged. Validation: SupportTimeboxTest passes and verifies that cancellation does not invoke the padding sleep. --- src/support/src/Timebox.php | 8 +++++--- tests/Support/SupportTimeboxTest.php | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/support/src/Timebox.php b/src/support/src/Timebox.php index 430c6ab0a..c3e0b001a 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/tests/Support/SupportTimeboxTest.php b/tests/Support/SupportTimeboxTest.php index 498008c11..ad91f2bc8 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'); From 9b26c0e8c316c88c36f44d903a24c92cb06cbcfc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:30 +0000 Subject: [PATCH 06/39] Preserve cancellation while restoring cached objects Cache reads and unserialization can execute application code that is canceled during cleanup. Treating that as corrupt data could return a miss or delete a valid cached value. Propagate cancellation from storage reads and object restoration, retaining ordinary read and corruption fallbacks. Validation: FileStore and StorageStore regressions cover direct and wrapped restoration cancellation and verify that cached entries remain present. --- src/cache/src/FileStore.php | 9 +++-- src/cache/src/StorageStore.php | 18 +++++---- tests/Cache/CacheFileStoreTest.php | 21 ++++++++++ tests/Cache/CacheStorageStoreTest.php | 38 +++++++++++++++++-- .../Fixtures/CanceledDeserialization.php | 32 ++++++++++++++++ 5 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 tests/Cache/Fixtures/CanceledDeserialization.php diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 49d79caea..80ba09a04 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; @@ -540,9 +541,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/StorageStore.php b/src/cache/src/StorageStore.php index 733baf9a3..a87efd780 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/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 0f52cec3d..770f82899 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,6 +195,25 @@ public function testRemainingSecondsPreservesCancellationFromFilesystemRead(): v } } + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cleanup wraps cancellation')] + public function testCanceledDeserializationDoesNotDeleteTheCachedValue(bool $wrapped): void + { + $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')); + } + public function testGetPreservesCancellationFromSerializableClassPolicy(): void { $cancellation = new CanceledException('policy canceled'); diff --git a/tests/Cache/CacheStorageStoreTest.php b/tests/Cache/CacheStorageStoreTest.php index 9118c5ed4..e1210ba36 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/Fixtures/CanceledDeserialization.php b/tests/Cache/Fixtures/CanceledDeserialization.php new file mode 100644 index 000000000..38e611233 --- /dev/null +++ b/tests/Cache/Fixtures/CanceledDeserialization.php @@ -0,0 +1,32 @@ +wrapped) { + throw new RuntimeException('Restoration cleanup failed.'); + } + } + } +} From ca5b5eda46384ddcb72da77db06792842c8f8b92 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:30 +0000 Subject: [PATCH 07/39] Preserve cancellation when releasing cache locks A lock-release cleanup error could hide cancellation behind an earlier ordinary callback failure. Use shared cancellation precedence when release also fails, preserving the original callback failure for ordinary cleanup errors. Validation: CacheLockTest covers get and block, ordinary failures, and direct or wrapped release cancellation. --- src/cache/src/Lock.php | 10 +++------- tests/Cache/CacheLockTest.php | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php index 863b9221e..b626ef017 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/tests/Cache/CacheLockTest.php b/tests/Cache/CacheLockTest.php index 907f1ff64..72b92ff7f 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); } } From 7a6b3b5aa286b0e9caf53d3764cc34cf1c059dce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:30 +0000 Subject: [PATCH 08/39] Preserve cancellation when releasing cache concurrency leases Cache concurrency callbacks and lease release can both fail. Wrapped cancellation from release previously lost precedence to an ordinary callback error. Apply the shared failure-selection rule at both limiter callback boundaries while retaining normal cleanup and callback behavior. Validation: ConcurrencyLimiterTest passes for block and funnel callbacks with direct and wrapped release cancellation. --- src/cache/src/Limiters/ConcurrencyLimiter.php | 10 +++----- .../Limiters/ConcurrencyLimiterBuilder.php | 10 +++----- tests/Cache/ConcurrencyLimiterTest.php | 23 +++++++++++++++---- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/cache/src/Limiters/ConcurrencyLimiter.php b/src/cache/src/Limiters/ConcurrencyLimiter.php index 4072201e4..963175df0 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 aa3d79d8f..da8de9a7f 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/tests/Cache/ConcurrencyLimiterTest.php b/tests/Cache/ConcurrencyLimiterTest.php index d3cd735b4..1151daec6 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); } } From 28baacbae395b93278b03fae26ed7f052fb767a8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:30 +0000 Subject: [PATCH 09/39] Exclude wrapped cancellation from cache failure events Repository reads and writes could dispatch ordinary failure events after cancellation was wrapped by cleanup. Recognize cancellation before emitting retrieval, write, deletion or flush failures. Preserve original exception identity and ordinary event behavior. Validation: CacheEventsTest covers each repository operation, including authoritative reads and lock flushing, with direct and wrapped cancellation. --- src/cache/src/Repository.php | 38 +++++++++------------------------ tests/Cache/CacheEventsTest.php | 20 +++++++++++++---- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 304bc805a..2c467f2df 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/tests/Cache/CacheEventsTest.php b/tests/Cache/CacheEventsTest.php index 58990560c..02c28fb2a 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 From 9f9679c9619506f00b3bfb4f0bb463112dfe2a2e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:30 +0000 Subject: [PATCH 10/39] Stop tagged cache work when cancellation is wrapped Tagged cache operations could emit failure events or continue invalidating later tags after cancellation. An earlier ordinary tag error could then hide the cancellation entirely. Stop tag iteration immediately on cancellation and exclude it from tagged failure-event paths, retaining ordinary failure collection and reporting. Validation: TaggedCache and StackStoreTags tests pass, including cancellation after an earlier ordinary error and checks that later layers are not visited. --- src/cache/src/AnyModeTaggedCache.php | 10 ++--- src/cache/src/StackTaggedCache.php | 10 ++--- src/cache/src/TagSet.php | 8 ++-- src/cache/src/TaggedCache.php | 6 +-- tests/Cache/CacheStackStoreTagsTest.php | 56 +++++++++++++++++++------ tests/Cache/CacheTaggedCacheTest.php | 23 +++++++--- 6 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/cache/src/AnyModeTaggedCache.php b/src/cache/src/AnyModeTaggedCache.php index 16570e146..91df4c8c6 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/StackTaggedCache.php b/src/cache/src/StackTaggedCache.php index 1586c45d7..1271872fe 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/TagSet.php b/src/cache/src/TagSet.php index 52b1bff58..fb627f139 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 664933567..26846277a 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/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php index e69b741d9..e63f3ed72 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/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index 0e84f8ed3..176edb72f 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); } } From 03d915fe8b48427492a4d811047b5a0b3db9837e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:30 +0000 Subject: [PATCH 11/39] Preserve cancellation in Redis tagged cache operations Redis tagged-cache writes and flushes skipped failure listeners only for bare cancellation exceptions. Check the full cause before emitting ordinary failure events in both any-tag and all-tag operations. Keep normal write and flush failure events intact. Validation: Both Redis tagged-cache test files pass with wrapped cancellation rows for each affected operation; no Redis service is required. --- src/cache/src/Redis/AllTaggedCache.php | 18 ++++-------- src/cache/src/Redis/AnyTaggedCache.php | 14 +++------ tests/Cache/Redis/AllTaggedCacheTest.php | 37 +++++++++++++++++++----- tests/Cache/Redis/AnyTaggedCacheTest.php | 29 +++++++++++++++---- 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index 0f8a0cfbd..0cb09fd36 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 07638ac54..4529ddc19 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/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index 26001c056..ae2b696d1 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 fe932fca0..82cad7382 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); } From b3efaa0190ef95077f011b8d5c5a827877f3f7f4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:31 +0000 Subject: [PATCH 12/39] Stop user cache invalidation on cancellation Model-event cache invalidation collected every failure and could continue through remaining stores after cancellation, eventually throwing an earlier ordinary error. Propagate cancellation immediately while still exhausting stores after ordinary failures. Declare the direct coroutine dependency. Validation: EloquentUserProviderCacheTest passes on SQLite and verifies that the third store is not visited after cancellation. --- src/auth/composer.json | 1 + src/auth/src/EloquentUserProvider.php | 7 +++- .../Auth/EloquentUserProviderCacheTest.php | 40 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/auth/composer.json b/src/auth/composer.json index e415d300b..822d360f4 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 c1c636b9a..926234d8e 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/tests/Integration/Auth/EloquentUserProviderCacheTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTest.php index 0bba06163..b8fe61408 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(); From 1fa4030bc92f90ea4f7631a6ba81db2f9c3d0f12 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:31 +0000 Subject: [PATCH 13/39] Preserve cancellation during Passport token issuance cleanup Token issuance could lose cancellation when rollback cleanup failed, or classify a wrapped commit cancellation as an ordinary post-commit error. Use shared failure precedence for rollback and recognize cancellation before commit-failure recovery. Declare the direct coroutine dependency. Validation: TokenIssuanceTest passes on SQLite with commit and rollback cancellation cases, including throwing cleanup and context restoration. --- src/passport/composer.json | 1 + src/passport/src/Bridge/TokenIssuance.php | 16 +++++------- tests/Passport/TokenIssuanceTest.php | 31 ++++++++++++++++------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/passport/composer.json b/src/passport/composer.json index fcdfa7314..9f1294c96 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 fa5176d61..a3819445d 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/tests/Passport/TokenIssuanceTest.php b/tests/Passport/TokenIssuanceTest.php index 58858b76c..d2658d3aa 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(); From dded19bc09d2c0f667f3bb8e5813165f4a7eb0f4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:31 +0000 Subject: [PATCH 14/39] Stop Passport cache invalidation on wrapped cancellation Passport batch invalidation could retain an earlier ordinary cache error while continuing after cancellation hidden by cleanup. Stop immediately on cancellation and preserve its original exception. Ordinary invalidation failures still allow remaining entries to be attempted. Validation: PassportCacheTest passes on SQLite, including a three-token case that verifies no third invalidation after cancellation. --- src/passport/src/PassportCache.php | 8 +++--- tests/Passport/PassportCacheTest.php | 37 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/passport/src/PassportCache.php b/src/passport/src/PassportCache.php index 400a8b3b0..eb20dab28 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/tests/Passport/PassportCacheTest.php b/tests/Passport/PassportCacheTest.php index dc99a25f6..c34a98fcb 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]); From b68bbafc37125b3d67a5d3a6fc08b159e64766de Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:31 +0000 Subject: [PATCH 15/39] Stop feature-cache rotation on wrapped cancellation Stored feature-value invalidation could continue through targets after cancellation and return an earlier ordinary failure instead. Recognize cancellation before collecting ordinary failures, and declare the direct coroutine dependency used by that decision. Validation: StoredValueCacheTest passes, including an ordinary first failure followed by cancellation and an untouched final target. --- src/pennant/composer.json | 1 + src/pennant/src/Drivers/StoredValueCache.php | 8 +++-- tests/Pennant/StoredValueCacheTest.php | 31 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/pennant/composer.json b/src/pennant/composer.json index 1b4563f29..76780fb71 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 a3d3e0670..13d018e7f 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/tests/Pennant/StoredValueCacheTest.php b/tests/Pennant/StoredValueCacheTest.php index 49b0445a9..05ce17a35 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 { From 99a67cfe330458528ab37f537b81610f20e6de64 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:31 +0000 Subject: [PATCH 16/39] Add a cancellation-aware PSR stream resource bridge Guzzle's resource bridge converts RuntimeException into stream fallback results, which can conceal cancellation carried by producer cleanup. Adapt the bridge with cancellation propagation and explicit borrowed versus owned resources. Keep a native Stream detach fast path, ordinary stream return conventions, and protocol registration caching. Include upstream license attribution. Validation: All applicable Guzzle PSR7 3.2.0 StreamWrapper tests were ported, with additional ownership and cancellation coverage for all seven fallible stream operations. --- src/http/LICENSE.md | 9 +- src/http/src/StreamWrapper.php | 364 ++++++++++++++++++++++++ tests/Http/StreamWrapperTest.php | 474 +++++++++++++++++++++++++++++++ 3 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 src/http/src/StreamWrapper.php create mode 100644 tests/Http/StreamWrapperTest.php diff --git a/src/http/LICENSE.md b/src/http/LICENSE.md index 68de13f42..45a72d4b3 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/StreamWrapper.php b/src/http/src/StreamWrapper.php new file mode 100644 index 000000000..92e4ce199 --- /dev/null +++ b/src/http/src/StreamWrapper.php @@ -0,0 +1,364 @@ +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 + { + try { + $stream = clone $this->stream; + $resource = $stream->detach(); + } catch (RuntimeException $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + + return false; + } + + return $resource ?? 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/tests/Http/StreamWrapperTest.php b/tests/Http/StreamWrapperTest.php new file mode 100644 index 000000000..f6200f5ed --- /dev/null +++ b/tests/Http/StreamWrapperTest.php @@ -0,0 +1,474 @@ + 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 + { + $stream = $this->createStub(StreamInterface::class); + $stream->method('isReadable')->willReturn(true); + $stream->method('isWritable')->willReturn(false); + $stream->method('detach')->willThrowException(new RuntimeException('detach failed')); + + $failed = StreamWrapper::getResource($stream); + $valid = StreamWrapper::getResource(Utils::streamFor('foo')); + $streams = [$failed, $valid]; + $write = null; + $except = null; + + self::assertSame(1, @stream_select($streams, $write, $except, 0)); + self::assertSame([$valid], array_values($streams)); + } + + 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'])] + #[TestWith(['detach'])] + 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); + $read = [$resource]; + $write = $except = null; + $caught = null; + + try { + match ($method) { + 'write' => fwrite($resource, 'x'), + 'tell' => fseek($resource, 0), + 'eof' => feof($resource), + 'getSize' => fstat($resource), + 'detach' => stream_select($read, $write, $except, 0), + }; + } 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; + } +} From e27e6838918ad08c87b1217caaeb275c5b9f1174 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:32 +0000 Subject: [PATCH 17/39] Preserve cancellation through HTTP response resources Reading a response through a PHP resource could hide cancellation from its PSR body producer. Use the cancellation-aware borrowed resource bridge so closing the view does not close the response-owned body. Validation: HTTP client resource tests verify readable response content after resource closure and exact propagation of producer cleanup cancellation. --- src/http/src/Client/Response.php | 2 +- tests/Http/HttpClientTest.php | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index de52e9edb..ad90119db 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/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 3a145dcd3..d566c7ce2 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 From 97d9c400ef43130f14e9ebcde78d2959e35fb49c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:32 +0000 Subject: [PATCH 18/39] Preserve filesystem cancellation and stream ownership Filesystem failure policies could swallow wrapped cancellation, and detached decoder streams could lose their producer or remain open after failed writes. Centralize cancellation-aware failure policy, use owned resource bridges where the adapter owns cleanup, and preserve cancellation through range-read closure. Leave callers' detached native handles open as Laravel does. Validation: FilesystemAdapterTest passes, covering failure policies, decoded uploads, writer-owned closure, native-handle compatibility and range cleanup that throws. --- src/filesystem/src/FilesystemAdapter.php | 67 +++++++---- tests/Filesystem/FilesystemAdapterTest.php | 134 +++++++++++++++++++++ 2 files changed, 177 insertions(+), 24 deletions(-) diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index ec8c3bdbf..814b65e61 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/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index cc6d8c8d3..11da6cbeb 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]); From 61b8bd22c973c2f48932304818cbfb4f5a436e0f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:32 +0000 Subject: [PATCH 19/39] Stop read-through promotion after cancellation Best-effort promotion could swallow cancellation wrapped in a filesystem exception and continue serving a normal fallback. Propagate cancellation before applying the promotion failure policy. Use the borrowed resource bridge for upload views so the original stream owner remains responsible for closure. Validation: Read-through tests cover string and stream promotion failures, wrapped cancellation, and owned-resource cleanup. --- .../src/ReadThroughFilesystemAdapter.php | 7 ++- tests/Filesystem/FilesystemManagerTest.php | 43 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/filesystem/src/ReadThroughFilesystemAdapter.php b/src/filesystem/src/ReadThroughFilesystemAdapter.php index d81032a2d..abb3050fd 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/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 4daeb1bf3..7cc412a91 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 { From 3198de79cee4a8e7e49edfae7447f297445bd60a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:32 +0000 Subject: [PATCH 20/39] Preserve cancellation and decoded-body ownership in S3 reads S3 SDK errors can carry cancellation, and decoder bodies cannot safely be converted by detaching their underlying stream. Propagate SDK cancellation before local filesystem wrapping and return an owning resource bridge that closes the full decoded body. Keep ordinary disk failure policy unchanged. Validation: AwsS3V3AdapterTest passes with real S3Exception cause shapes, decoded-body cancellation, ownership and invalid-stream cases. --- src/filesystem/src/AwsS3V3Adapter.php | 21 +++----- tests/Filesystem/AwsS3V3AdapterTest.php | 70 ++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/filesystem/src/AwsS3V3Adapter.php b/src/filesystem/src/AwsS3V3Adapter.php index 11a279d5d..311e7bddb 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/tests/Filesystem/AwsS3V3AdapterTest.php b/tests/Filesystem/AwsS3V3AdapterTest.php index 2f2af753f..3180c9c1b 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([ From 85796f7248d32c105506f5ffe19d4157f91021ce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:32 +0000 Subject: [PATCH 21/39] Preserve cancellation and decoded-body ownership in GCS reads Google SDK causes and decoded stream producers require cancellation-aware handling before the disk's ordinary failure policy. Propagate recognized SDK cancellation and retain full decoded-body ownership through the resource bridge. Add a verified regression for the SDK's separate resumable-upload cause loss, skipped with a TODO until the dependency fixes it. Validation: GoogleCloudStorageAdapterTest passes with one explicitly skipped dependency regression; that regression was run and confirmed failing before the skip. Tests use injected handlers without network traffic. --- .../src/GoogleCloudStorageAdapter.php | 21 ++- .../GoogleCloudStorageAdapterTest.php | 129 ++++++++++++++++-- 2 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/filesystem/src/GoogleCloudStorageAdapter.php b/src/filesystem/src/GoogleCloudStorageAdapter.php index 1615d4d7c..7465166ad 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/tests/Filesystem/GoogleCloudStorageAdapterTest.php b/tests/Filesystem/GoogleCloudStorageAdapterTest.php index 6f915399a..b9bfe1d98 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( From 9c507dc564c966be6fdee0358b6db9bfd357eb13 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:33 +0000 Subject: [PATCH 22/39] Preserve cancellation precedence during pooled database release Connection release can encounter several failures from listeners, logging and pool cleanup. Separate ordinary and bare-cancellation tracking missed wrapped cancellation. Use one retained failure with shared cancellation precedence while preserving connection cleanup and existing ordinary-error behavior. Validation: Focused PooledConnectionTest cases pass on SQLite, covering earlier logging failure versus later wrapped release cancellation. --- src/database/src/Pool/PooledConnection.php | 32 ++++++------------- .../Database/PooledConnectionTest.php | 8 +++-- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index 5ee4bdc21..034f8ec99 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/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 99106080f..642292b15 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()); From 1c43b906eed0a10769c7b1bef441c866df4aad2f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:33 +0000 Subject: [PATCH 23/39] Preserve wrapped cancellation across Redis operations and cleanup Redis command listeners, subscription callbacks and connection cleanup can hide cancellation in exception chains. Recognize those chains before failure events or failure selection. Keep native PhpRedis cancellation normalization and existing ordinary cleanup precedence. Validation: RedisCancellationTest and RedisProxyTest cover command, event, subscription, callback and discovery cleanup boundaries with direct and wrapped cancellation. --- src/redis/src/RedisCancellation.php | 9 +++--- src/redis/src/RedisProxy.php | 21 ++++++------- tests/Redis/RedisCancellationTest.php | 21 +++++++++++++ tests/Redis/RedisProxyTest.php | 45 ++++++++++++++++++++++----- 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/src/redis/src/RedisCancellation.php b/src/redis/src/RedisCancellation.php index 60f25ced9..20228a56d 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 c4f74775c..78bd50ccb 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/tests/Redis/RedisCancellationTest.php b/tests/Redis/RedisCancellationTest.php index d0399a540..83cec0dfa 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 c1efa2293..337e1c2b8 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.')); From 4ebd70d2c8e20d206c7c860724811cc7e92973c3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:33 +0000 Subject: [PATCH 24/39] Preserve cancellation from Redis concurrency lease cleanup An ordinary callback error could hide cancellation raised while releasing a Redis concurrency lease. Use shared cancellation precedence in limiter block and builder then paths. Keep callback errors primary when cleanup fails ordinarily. Validation: Both Redis limiter test files pass with ordinary, direct cancellation and wrapped cancellation release cases. --- src/redis/src/Limiters/ConcurrencyLimiter.php | 5 ++-- .../Limiters/ConcurrencyLimiterBuilder.php | 5 ++-- tests/Redis/ConcurrencyLimiterBuilderTest.php | 25 ++++++++++++++----- tests/Redis/ConcurrencyLimiterTest.php | 25 ++++++++++++++----- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/redis/src/Limiters/ConcurrencyLimiter.php b/src/redis/src/Limiters/ConcurrencyLimiter.php index 49b2faa28..89c26b39d 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 e74611e88..5ed9d89b3 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/tests/Redis/ConcurrencyLimiterBuilderTest.php b/tests/Redis/ConcurrencyLimiterBuilderTest.php index 7943ac96a..d4f7a672a 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 144c5e9d9..6de14191f 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 From b597b4d95507e87658a1a1df93adf0b5fb704ed0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:33 +0000 Subject: [PATCH 25/39] Preserve cancellation during process and pool cleanup Stopping processes can invoke output callbacks, so emergency cleanup can raise cancellation behind another exception. Use shared failure precedence for individual stop, pending-start cleanup and pool rollback/wait/stop. Continue required sibling cleanup and retain the existing output-callback reentry guard. Declare the direct coroutine dependency. Validation: Focused ProcessTest cases pass using controlled processes and fakes, including wrapped operation and cleanup cancellation. --- src/process/composer.json | 1 + src/process/src/InvokedProcess.php | 6 ++-- src/process/src/InvokedProcessPool.php | 16 +++------ src/process/src/PendingProcess.php | 6 ++-- src/process/src/Pool.php | 6 ++-- tests/Process/ProcessTest.php | 49 ++++++++++++++++++++------ 6 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src/process/composer.json b/src/process/composer.json index 922d5f6f7..e72041044 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 b9245e224..ec9f5131c 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 4d902851a..1bff88914 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 d65668e51..d435f57c1 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 252231dd5..ab5cfd7d9 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/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 9bcf12473..ed657c91e 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 From d5d411c3b893f8d0d7dd923f27a3275d4cdf8441 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:33 +0000 Subject: [PATCH 26/39] Preserve cancellation in batch callbacks and option restoration Batch progress callbacks and serialized application options can be canceled, but broad error handling could report or replace that cancellation and continue work. Propagate cancellation from callback and restoration failures while preserving ordinary reporting and the existing empty-options fallback. Validation: BusBatchTest regressions cover stopped callback iteration and ordinary versus canceled stored-option restoration. --- src/bus/src/Batch.php | 5 ++ src/bus/src/DatabaseBatchRepository.php | 7 +- tests/Bus/BusBatchTest.php | 92 +++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php index 21f8be2a7..9aa10ad6b 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 2e6632528..451863046 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/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php index 155bb7d0e..5a79f604c 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; From 13d723831e3a5faf949d369c567533ceb43e86cf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:34 +0000 Subject: [PATCH 27/39] Clean up canceled batch dispatch without losing its cause Cancellation from before callbacks could leave a saved batch row before dispatch had obtained ownership. Deletion failures could also hide dispatch cancellation. Let store clean up failures escaping before-callback processing, including reporting failures, and use native exception chaining for deletion during failed dispatch. Validation: BusPendingBatchTest passes for direct and wrapped cancellation, after-response dispatch, throwing deletion and cancellation from reporting. --- src/bus/src/PendingBatch.php | 47 +++++++++++----- tests/Bus/BusPendingBatchTest.php | 90 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 14 deletions(-) diff --git a/src/bus/src/PendingBatch.php b/src/bus/src/PendingBatch.php index dd00e2d17..74d9495ba 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/tests/Bus/BusPendingBatchTest.php b/tests/Bus/BusPendingBatchTest.php index a53b03582..1c7cde0d5 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 { From 0662741c2b041d4db167a0e988385585874c1735 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:34 +0000 Subject: [PATCH 28/39] Preserve cancellation while releasing debounce state Acquisition rollback and multi-record debounce cleanup could retain an ordinary error instead of a later cancellation. Use the shared selection rule while still attempting the owned cleanup records. Keep ordinary acquisition and cleanup precedence unchanged. Validation: BusDebounceLockTest verifies rollback cancellation and cancellation from the second cleanup operation. --- src/bus/src/DebounceLock.php | 7 ++++--- tests/Bus/BusDebounceLockTest.php | 28 +++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/bus/src/DebounceLock.php b/src/bus/src/DebounceLock.php index 5d4822e3f..70591dc11 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/tests/Bus/BusDebounceLockTest.php b/tests/Bus/BusDebounceLockTest.php index 7db7d2d82..e3f52c0e8 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)] From 1bf97fd0a89b0ee9ce580effb442eda657f31c00 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:34 +0000 Subject: [PATCH 29/39] Preserve cancellation while releasing dispatch locks Releasing unique and debounce locks can produce multiple failures. A later canceled cleanup must not be hidden by an earlier ordinary release error. Retain cancellation while attempting both owned locks and clearing the dispatch snapshot. Validation: DispatchLockContextTest verifies both release attempts, original cancellation identity and removal of the dispatch context. --- src/bus/src/DispatchLockContext.php | 3 ++- tests/Bus/DispatchLockContextTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/bus/src/DispatchLockContext.php b/src/bus/src/DispatchLockContext.php index d8e28d5aa..5896ca77d 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/tests/Bus/DispatchLockContextTest.php b/tests/Bus/DispatchLockContextTest.php index b59d2cb2c..ec2550c54 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']); From 79e342a529716c8d76dbedbf801e8639f2d4ddcf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:34 +0000 Subject: [PATCH 30/39] Keep notification cancellation through failure observers A NotificationFailed listener could replace delivery cancellation with an ordinary error, allowing retry code to repeat delivery. Dispatch failure observers while the original exception is in flight so PHP retains it as the previous exception if reporting also fails. Preserve event serialization and notification failure-state cleanup. Validation: NotificationSenderTest verifies that a throwing observer retains cancellation and retry invokes delivery only once. --- src/notifications/src/NotificationSender.php | 25 +++++++++------- .../Notifications/NotificationSenderTest.php | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/notifications/src/NotificationSender.php b/src/notifications/src/NotificationSender.php index fc0fe6b4d..3eabaf178 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/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index c0c074866..fcc86d459 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'); From fe9e677c444e2719fe26d7c6932d0c67144982ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:34 +0000 Subject: [PATCH 31/39] Stop OAuth account renewal retries after cancellation OAuth renewal could classify wrapped cancellation as a transient transfer failure and retry. Cancellation during claim cleanup or error reporting could also be swallowed. Skip provider-error classification on cancellation, check it at the retry boundary, and preserve cancellation while releasing claims and reporting ordinary cleanup failures. Validation: Focused OAuthAccountTest cases pass for transient retries, wrapped transfer cancellation, direct cleanup and reporting cancellation, with no sleep after cancellation. --- src/saloon/src/OAuthAccounts.php | 26 +++++++++------ tests/Saloon/OAuthAccountTest.php | 53 ++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/saloon/src/OAuthAccounts.php b/src/saloon/src/OAuthAccounts.php index 670c3a2c5..a420c289c 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/tests/Saloon/OAuthAccountTest.php b/tests/Saloon/OAuthAccountTest.php index a0f58722e..e9cfe6d3a 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); From 0bddc8d95e2f506bd84d019b156d03a95fd13e3d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:34 +0000 Subject: [PATCH 32/39] Preserve session deletion cancellation through cleanup errors Redis session deletion could replace payload cancellation with a combined ordinary cleanup error. Keep payload cancellation primary while retaining the existing combined error for ordinary failures. Declare the direct coroutine dependency. Validation: RedisSessionHandlerTest covers ordinary, direct cancellation and wrapped cancellation when payload deletion and index cleanup both fail. --- src/session/composer.json | 1 + src/session/src/RedisSessionHandler.php | 5 ++++ tests/Session/RedisSessionHandlerTest.php | 33 +++++++++++++++++------ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/session/composer.json b/src/session/composer.json index ad70f102d..9aebda387 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 da19f97bf..eb06611fb 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/tests/Session/RedisSessionHandlerTest.php b/tests/Session/RedisSessionHandlerTest.php index 31ba558fe..aca6e756d 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); } } From 65fd36896eabdf496d437f3f4b70cb21d993e3d8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:35 +0000 Subject: [PATCH 33/39] Propagate cancellation from optional social email lookups GitHub and Bitbucket email lookup fallbacks could swallow cancellation and continue authentication with a missing email. Propagate cancellation before returning the ordinary optional-email fallback. Declare the direct coroutine dependency. Validation: Public userFromToken tests cover ordinary, direct and wrapped lookup failures for both providers. --- src/socialite/composer.json | 1 + src/socialite/src/Two/BitbucketProvider.php | 7 ++- src/socialite/src/Two/GithubProvider.php | 7 ++- tests/Socialite/EmailLookupTest.php | 70 +++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/Socialite/EmailLookupTest.php diff --git a/src/socialite/composer.json b/src/socialite/composer.json index 8350178f8..a77207284 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 9207cea2d..8642db905 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 137e62805..ac3cea193 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/tests/Socialite/EmailLookupTest.php b/tests/Socialite/EmailLookupTest.php new file mode 100644 index 000000000..9233da468 --- /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'], + ]; + } +} From a0ea12dd5c4a3d90789085782019ed9249a3202c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:35 +0000 Subject: [PATCH 34/39] Preserve cancellation during compromised-password checks The password compromise lookup could report cancellation as a connectivity error and return successful validation. Propagate cancellation before ordinary reporting and fallback behavior, and declare the direct coroutine dependency. Validation: ValidationNotPwnedVerifierTest passes and verifies that direct and wrapped cancellation neither succeeds nor gets reported as an ordinary lookup failure. --- src/validation/composer.json | 1 + src/validation/src/NotPwnedVerifier.php | 5 ++++ .../ValidationNotPwnedVerifierTest.php | 27 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/validation/composer.json b/src/validation/composer.json index c5425eeb1..18a24651b 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 931e20488..42562215d 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/tests/Validation/ValidationNotPwnedVerifierTest.php b/tests/Validation/ValidationNotPwnedVerifierTest.php index 6011b3bff..26332bee9 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(); + } } From e4b7b272868f9829b981875440fd51c14506ca54 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:35 +0000 Subject: [PATCH 35/39] Preserve cancellation through view rendering observers Wrapped cancellation could run completion observers or be hidden behind an earlier rendering or observer failure. Recognize cancellation before completion notification, stop remaining observers when canceled, and preserve its precedence over ordinary rendering failure. Declare the direct coroutine dependency. Validation: ViewTest passes with direct and wrapped cases. Observer execution is asserted outside the callbacks so failure aggregation cannot swallow the test's own assertion. --- src/view/composer.json | 1 + src/view/src/Factory.php | 8 ++++--- src/view/src/View.php | 12 +++++----- tests/View/ViewTest.php | 50 ++++++++++++++++++++++++++++++---------- 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/view/composer.json b/src/view/composer.json index 4647b4fec..7ac51091b 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 d5db8d8b0..02b948075 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 5d78a62b8..87a764365 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/View/ViewTest.php b/tests/View/ViewTest.php index deb728ff2..e347032ea 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); } } From 78298c98d0e631b49e61526e7e10c97357c61edc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:43:28 +0000 Subject: [PATCH 36/39] Preserve decorated stream ownership during resource casting Cloning a PSR-7 decorator retains its inner stream, so detaching the clone also detaches the retained body. Returning its raw descriptor additionally bypasses range limits and decoding. Only expose a descriptor for an exact native Guzzle Stream. Keep decorated resources usable through their normal operations and close their owned handles when the bridge closes. Remove the now-unreachable decorator detach exception path. Extend the existing selection regression with a limited stream to verify selection removal, retained range reads and owned handle closure. The regression fails before the fix; the complete StreamWrapper test file and targeted source analysis pass. --- src/http/src/StreamWrapper.php | 13 ++++-------- tests/Http/StreamWrapperTest.php | 35 +++++++++++++++++++++----------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/http/src/StreamWrapper.php b/src/http/src/StreamWrapper.php index 92e4ce199..5ef6fffde 100644 --- a/src/http/src/StreamWrapper.php +++ b/src/http/src/StreamWrapper.php @@ -250,18 +250,13 @@ public function stream_seek(int $offset, int $whence): bool */ public function stream_cast(int $castAs): mixed { - try { - $stream = clone $this->stream; - $resource = $stream->detach(); - } catch (RuntimeException $e) { - if (Coroutine::causedByCancellation($e)) { - throw $e; - } - + // 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 $resource ?? false; + return (clone $this->stream)->detach() ?? false; } /** diff --git a/tests/Http/StreamWrapperTest.php b/tests/Http/StreamWrapperTest.php index f6200f5ed..ef5c31427 100644 --- a/tests/Http/StreamWrapperTest.php +++ b/tests/Http/StreamWrapperTest.php @@ -8,6 +8,7 @@ use GuzzleHttp\Handler\InflateBodyStream; use GuzzleHttp\Psr7\CachingStream; use GuzzleHttp\Psr7\FnStream; +use GuzzleHttp\Psr7\LimitStream; use GuzzleHttp\Psr7\PumpStream; use GuzzleHttp\Psr7\Utils; use Hypervel\Http\StreamWrapper; @@ -95,19 +96,33 @@ public function testStreamCast(): void public function testStreamCastFailureRemovesStreamFromSelection(): void { - $stream = $this->createStub(StreamInterface::class); - $stream->method('isReadable')->willReturn(true); - $stream->method('isWritable')->willReturn(false); - $stream->method('detach')->willThrowException(new RuntimeException('detach failed')); - - $failed = StreamWrapper::getResource($stream); + $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; - self::assertSame(1, @stream_select($streams, $write, $except, 0)); - self::assertSame([$valid], array_values($streams)); + 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 @@ -428,7 +443,6 @@ public function testProducerCancellationKeepsItsOriginalException(bool $seek): v #[TestWith(['tell'])] #[TestWith(['eof'])] #[TestWith(['getSize'])] - #[TestWith(['detach'])] public function testOtherStreamOperationsPreserveCancellation(string $method): void { $failure = new RuntimeException('Stream cleanup failed.', previous: new CanceledException); @@ -437,8 +451,6 @@ public function testOtherStreamOperationsPreserveCancellation(string $method): v $body->method('isWritable')->willReturn(true); $body->method($method)->willThrowException($failure); $resource = StreamWrapper::getResource($body); - $read = [$resource]; - $write = $except = null; $caught = null; try { @@ -447,7 +459,6 @@ public function testOtherStreamOperationsPreserveCancellation(string $method): v 'tell' => fseek($resource, 0), 'eof' => feof($resource), 'getSize' => fstat($resource), - 'detach' => stream_select($read, $write, $except, 0), }; } catch (Throwable $exception) { $caught = $exception; From 768493c726d78511b432950c8d9b7be6a0e64c31 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:43:39 +0000 Subject: [PATCH 37/39] Inspect both Google exception causes for cancellation Google ServiceException stores its request cause separately from the previous exception that PHP attaches during finally unwinding. Following only the service cause can miss cancellation and allow normal work to continue. Check the service cause recursively before walking the PHP previous chain. Preserve foreign and child cancellation ownership independently on each branch, without adding state to ordinary exception traversal. Extend the classification provider with the real finally-unwinding shape. The new case fails before the fix; the complete Coroutine test file, Google Cloud Storage adapter tests and targeted source analysis pass. The separately documented resumable-upload dependency limitation remains skipped. --- src/coroutine/src/Coroutine.php | 12 ++++++++---- tests/Coroutine/CoroutineTest.php | 12 ++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index 392dfbd55..d2d57e2cd 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -257,10 +257,14 @@ public static function causedByCancellation(Throwable $exception): bool return true; } - // Google Cloud stores the request failure outside PHP's previous-exception chain. - $exception = $exception instanceof ServiceException - ? $exception->getServiceException() ?? $exception->getPrevious() - : $exception->getPrevious(); + // 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; diff --git a/tests/Coroutine/CoroutineTest.php b/tests/Coroutine/CoroutineTest.php index ced6ba9ef..a3d1317a0 100644 --- a/tests/Coroutine/CoroutineTest.php +++ b/tests/Coroutine/CoroutineTest.php @@ -47,6 +47,17 @@ 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], @@ -54,6 +65,7 @@ public static function cancellationExceptions(): array '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], From c49010f9cd632e5714e2b0059b45338d0d456ea1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:43:47 +0000 Subject: [PATCH 38/39] Propagate cancellation from file lock owner deserialization Reading a file lock owner invokes the protected unserialize hook and may evaluate a supplied serialization policy. Cleanup exceptions from canceled callbacks were treated as ordinary read failures, incorrectly returning no owner. Recognize cancellation through the existing shared predicate and rethrow the original exception. Keep native-only file reads unchanged. Extend the existing serialization-policy regression across value and lock-owner reads with direct and cleanup-wrapped cancellation. The wrapped owner case fails before the correction; the complete FileStore test file and targeted analysis pass. --- src/cache/src/FileStore.php | 8 +++++--- tests/Cache/CacheFileStoreTest.php | 22 +++++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 80ba09a04..662f57e02 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -193,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; } } diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 770f82899..da4139d13 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -214,9 +214,15 @@ public function testCanceledDeserializationDoesNotDeleteTheCachedValue(bool $wra $this->assertFileExists($store->path('foo')); } - public function testGetPreservesCancellationFromSerializableClassPolicy(): void - { - $cancellation = new CanceledException('policy canceled'); + #[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') @@ -228,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 From 9995a297310120224d5f4b83fcc9b90aed386377 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:43:58 +0000 Subject: [PATCH 39/39] Finalize cache serialization policy in custom server processes Custom server processes dispatch BeforeProcessHandle rather than AfterWorkerStart. Their serialization policy therefore remained unfinalized, repeating policy resolution during reads and accepting contributions after startup. Use the existing finalization listener for both lifecycle events and resolve the manager at event time. Custom processes inherit master configuration; request and task workers still finalize after configuration reload. Document all three finalization boundaries on the resolver registration method. Extend the existing server lifecycle test with a custom-process case. It fails before the fix and passes afterward. The complete provider test file, targeted source analysis and facade docblock checks pass; regenerating the Cache facade produces no changes. --- src/cache/src/CacheManager.php | 7 ++++--- src/cache/src/CacheServiceProvider.php | 5 +++-- tests/Cache/CacheServiceProviderTest.php | 26 ++++++++++++++++++------ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 1c40928dd..8c2e8aa53 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 ab4635f31..c01a61d22 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/tests/Cache/CacheServiceProviderTest.php b/tests/Cache/CacheServiceProviderTest.php index 486007967..9048a4ccf 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 => []);