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 '