From f9709b10494d0c0a6a888884d2daff48a97a1808 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:26:49 +0000 Subject: [PATCH 01/26] Preserve cancellation when AI operation failure observers throw A failure-event factory or listener could replace an operation's cancellation with an ordinary exception, allowing retry() to run the canceled operation again. Dispatch failure events from finally while the original exception is in flight. PHP retains that original cause if event construction or dispatch fails, without changing successful operations or optional-listener checks. Validation: OperationEventsTest passes all 15 tests, including a throwing failure observer that must not cause a retry. Targeted source analysis and formatting pass. --- src/ai/src/Providers/Provider.php | 17 ++++++++++------- tests/Ai/Feature/OperationEventsTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/ai/src/Providers/Provider.php b/src/ai/src/Providers/Provider.php index 3e57aa3c5..c5e3e1c60 100644 --- a/src/ai/src/Providers/Provider.php +++ b/src/ai/src/Providers/Provider.php @@ -94,14 +94,17 @@ protected function dispatchingFailure(Closure $call, Closure $failed): mixed try { return $call(); } catch (Throwable $exception) { - $event = $failed($exception); - - // Passive observers alone must not trigger optional failure events; hasListeners() excludes them. - if ($this->events->hasListeners($event::class)) { - $this->events->dispatch($event); + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $event = $failed($exception); + + // Passive observers alone must not trigger optional failure events; hasListeners() excludes them. + if ($this->events->hasListeners($event::class)) { + $this->events->dispatch($event); + } } - - throw $exception; } } diff --git a/tests/Ai/Feature/OperationEventsTest.php b/tests/Ai/Feature/OperationEventsTest.php index f4659cf5e..ab9940826 100644 --- a/tests/Ai/Feature/OperationEventsTest.php +++ b/tests/Ai/Feature/OperationEventsTest.php @@ -30,6 +30,7 @@ use Hypervel\Tests\Ai\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Throwable; class OperationEventsTest extends TestCase @@ -64,6 +65,29 @@ public function testOperationFailuresDispatchAnEventAndRethrowTheOriginalExcepti $this->assertSame($exception, $failed->exception); } + public function testThrowingFailureListenerDoesNotAllowRetryAfterCancellation(): void + { + $cancellation = new CanceledException; + $observerFailure = new RuntimeException('Failure observer failed.'); + $attempts = 0; + Embeddings::fake(static function () use ($cancellation, &$attempts): never { + ++$attempts; + throw $cancellation; + }); + Event::listen(EmbeddingsFailed::class, static fn () => throw $observerFailure); + $caught = null; + + try { + retry(2, fn () => Embeddings::for(['Hello'])->generate()); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame(1, $attempts); + $this->assertSame($observerFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + } + #[DataProvider('operations')] public function testSuccessfulOperationsDoNotDispatchFailureEvents(Closure $fake, Closure $invoke, string $startingEvent, string $failureEvent): void { From 932a2b9ead4f78cde198558a85b199c5fb00f95d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:26:49 +0000 Subject: [PATCH 02/26] Preserve AI generation cancellation and child ownership Throwing agent, step or tool failure observers could erase the original cancellation. Separately, cancellation of one concurrent tool was exposed as cancellation of the still-active caller. Run failure reporting from finally to retain the original exception chain. Mark independently canceled tool failures with the existing ChildCancellationException boundary, preserving sibling cancellation and joining before the caller receives the failure. Validation: seven focused AgentEvents cases and all nine ConcurrentTools tests pass. Coverage includes prompt and stream failure reporting, middleware after generation, tool observers, ordinary tool failures and direct or wrapped child cancellation. Targeted source analysis and formatting pass. --- src/ai/src/Gateway/Concerns/InvokesTools.php | 9 +- src/ai/src/Gateway/TextGenerationLoop.php | 41 ++++-- .../src/Providers/Concerns/GeneratesText.php | 9 +- src/ai/src/Providers/Concerns/StreamsText.php | 9 +- tests/Ai/ConcurrentToolsTest.php | 27 +++- tests/Ai/Feature/AgentEventsTest.php | 119 ++++++++++++++++++ 6 files changed, 188 insertions(+), 26 deletions(-) diff --git a/src/ai/src/Gateway/Concerns/InvokesTools.php b/src/ai/src/Gateway/Concerns/InvokesTools.php index d8cc88e57..65bdccf5a 100644 --- a/src/ai/src/Gateway/Concerns/InvokesTools.php +++ b/src/ai/src/Gateway/Concerns/InvokesTools.php @@ -40,9 +40,12 @@ protected function executeTool(Tool $tool, array $arguments, ?string $toolCallId // Validation failures are returned to the model so it can correct the arguments and retry... $result = implode(' ', $exception->validator->errors()->all()) ?: $exception->getMessage(); } catch (Throwable $exception) { - $context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt)); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt)); + } } $context?->toolInvoked($tool, $arguments, $result, $toolInvocationId, $this->elapsedMilliseconds($startedAt)); diff --git a/src/ai/src/Gateway/TextGenerationLoop.php b/src/ai/src/Gateway/TextGenerationLoop.php index 9ce51b3ac..52270d4ea 100644 --- a/src/ai/src/Gateway/TextGenerationLoop.php +++ b/src/ai/src/Gateway/TextGenerationLoop.php @@ -66,6 +66,7 @@ use Hypervel\Ai\Tools\ToolNameResolver; use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\Exceptions\ChildCancellationException; use Hypervel\Coroutine\WaitConcurrent; use Hypervel\Database\ConnectionResolver; use Hypervel\Engine\Channel; @@ -197,9 +198,12 @@ public function generate( $lastResult = $stepResult->response(); } catch (Throwable $exception) { - $this->stepFailed($context, $attempt, $exception); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $this->stepFailed($context, $attempt, $exception); + } } $prepared = $attempt->step ?? $pending; @@ -387,9 +391,12 @@ public function stream( $result->reasoning = ReasoningDelta::combine($reasoningDeltas); } } catch (Throwable $exception) { - $this->stepFailed($context, $attempt, $exception); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $this->stepFailed($context, $attempt, $exception); + } } // A provider may report an error in the stream itself rather than throwing, which still ends the step. The error event travels on the exception so its type and metadata are not lost... @@ -542,9 +549,12 @@ protected function attempt(PendingStep $step, ?PendingStep $previous, array $his try { $source = $call($stepContext); } catch (Throwable $exception) { - $context?->stepFailed($stepContext, $exception, $this->elapsedMilliseconds($startedAt), $step->model); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $context?->stepFailed($stepContext, $exception, $this->elapsedMilliseconds($startedAt), $step->model); + } } return new StepResult($source, $step, $stepContext, $startedAt); @@ -859,9 +869,12 @@ protected function executeAgentToolStreaming(AgentTool $tool, array $arguments, $result = (string) $events->getReturn(); } catch (Throwable $exception) { - $context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt)); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $context?->toolFailed($tool, $arguments, $exception, $toolInvocationId, $this->elapsedMilliseconds($startedAt)); + } } $context?->toolInvoked($tool, $arguments, $result, $toolInvocationId, $this->elapsedMilliseconds($startedAt)); @@ -956,7 +969,9 @@ private function concurrentToolResults(array $resolved, int $max, Closure $execu try { $runner === null ? $work() : $runner($work); } catch (Throwable $exception) { - $failure ??= $exception; + $failure ??= Coroutine::causedByCancellation($exception) + ? new ChildCancellationException('An AI tool was canceled while its owner remained active.', previous: $exception) + : $exception; $events->close(); } }, [self::REPAIR_TOOL_CALLS_CONTEXT_KEY, ParentInvocation::PARENT_INVOCATION_CONTEXT_KEY, AiManager::ON_DEMAND_PROVIDERS_CONTEXT_KEY, ContextRepository::CONTEXT_KEY, ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY]); diff --git a/src/ai/src/Providers/Concerns/GeneratesText.php b/src/ai/src/Providers/Concerns/GeneratesText.php index 71dd1f1d3..2ea071347 100644 --- a/src/ai/src/Providers/Concerns/GeneratesText.php +++ b/src/ai/src/Providers/Concerns/GeneratesText.php @@ -130,9 +130,12 @@ public function prompt(AgentPrompt $prompt): AgentResponse return $agentResponse; }); } catch (Throwable $exception) { - $this->recordAgentFailure($invocationId, $prompt, $exception); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $this->recordAgentFailure($invocationId, $prompt, $exception); + } } if ($this->events->hasListeners(AgentPrompted::class)) { diff --git a/src/ai/src/Providers/Concerns/StreamsText.php b/src/ai/src/Providers/Concerns/StreamsText.php index 4a9f61614..c0d6f8c25 100644 --- a/src/ai/src/Providers/Concerns/StreamsText.php +++ b/src/ai/src/Providers/Concerns/StreamsText.php @@ -131,9 +131,12 @@ function () use ($invocationId, $prompt, $agent, $messages, $tools, $approval, $ return $streamable; }); } catch (Throwable $exception) { - $this->recordAgentFailure($invocationId, $prompt, $exception); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $this->recordAgentFailure($invocationId, $prompt, $exception); + } } // Avoid retaining the response through its own failure callback. diff --git a/tests/Ai/ConcurrentToolsTest.php b/tests/Ai/ConcurrentToolsTest.php index 232a9248a..b2da4596e 100644 --- a/tests/Ai/ConcurrentToolsTest.php +++ b/tests/Ai/ConcurrentToolsTest.php @@ -32,6 +32,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\Exceptions\ChildCancellationException; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Support\Facades\Context; @@ -39,8 +40,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; class ConcurrentToolsTest extends TestCase { @@ -137,9 +140,17 @@ public function testInvalidConcurrencyFailsClearlyThroughTheLoop(): void (new TextGenerationLoop($gateway))->generate($provider, 'model', '', options: new TextGenerationOptions(agent: new InvalidConcurrentToolsTestAgent('', [], []))); } - public function testToolFailureCancelsAndJoinsTheOtherTools(): void + #[TestWith([false, false], 'ordinary tool failure')] + #[TestWith([true, false], 'direct child cancellation')] + #[TestWith([true, true], 'wrapped child cancellation')] + public function testToolFailureCancelsAndJoinsTheOtherTools(bool $canceled, bool $cleanupThrows): void { - $failure = new RuntimeException('The tool failed.'); + $failure = $canceled ? new CanceledException : new RuntimeException('The tool failed.'); + + if ($cleanupThrows) { + $failure = new RuntimeException('Tool cleanup failed.', previous: $failure); + } + $active = 0; $resumed = false; $tool = m::mock(Tool::class); @@ -166,11 +177,19 @@ public function testToolFailureCancelsAndJoinsTheOtherTools(): void $provider->shouldReceive('name')->andReturn('provider'); $results = []; $gateway = $this->gateway(false, [new ToolCall('first', 'tool', []), new ToolCall('second', 'tool', [])], $results, completes: false); + $caught = null; try { (new TextGenerationLoop($gateway))->generate($provider, 'model', '', tools: [$tool], options: new TextGenerationOptions(agent: new ConcurrentToolsTestAgent('', [], []))); - $this->fail('The original tool failure must reach the caller.'); - } catch (RuntimeException $caught) { + } catch (Throwable $exception) { + $caught = $exception; + } + + if ($canceled) { + $this->assertInstanceOf(ChildCancellationException::class, $caught); + $this->assertSame($failure, $caught->getPrevious()); + $this->assertFalse(Coroutine::causedByCancellation($caught)); + } else { $this->assertSame($failure, $caught); } diff --git a/tests/Ai/Feature/AgentEventsTest.php b/tests/Ai/Feature/AgentEventsTest.php index 03e5593f6..236375204 100644 --- a/tests/Ai/Feature/AgentEventsTest.php +++ b/tests/Ai/Feature/AgentEventsTest.php @@ -38,6 +38,7 @@ use Hypervel\Ai\Responses\Data\ToolCall; use Hypervel\Ai\Streaming\Events\Error; use Hypervel\Ai\Tools\AgentTool; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Http\IterableStreamedResponse; use Hypervel\Support\Facades\Context; @@ -63,6 +64,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Tests\Ai\Fixtures\fakeGroqResponse; use function Hypervel\Tests\Ai\Fixtures\fakeGroqStreamErrorResponse; @@ -755,6 +757,31 @@ public function testASingleProviderStreamWithNoFailoverStillReportsAFailoverable Event::assertNotDispatched(AgentFailedOver::class); } + #[DataProvider('publicInvocationMethods')] + public function testFailureRecordingPreservesCancellationBeforeGeneration(string $method): void + { + $cancellation = new CanceledException; + $observerFailure = new RuntimeException('Agent failure observer failed.'); + Event::listen(AgentFailed::class, static fn () => throw $observerFailure); + Http::preventStrayRequests(); + $agent = (new AssistantAgent)->withMiddleware([static fn () => throw $cancellation]); + $caught = null; + + try { + $response = $agent->{$method}('Hello'); + + if ($method === 'stream') { + iterator_to_array($response); + } + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($observerFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + public function testAFailingGatewayDispatchesStepFailedAndAgentFailedInsteadOfAgentPrompted(): void { Event::fake(); @@ -777,6 +804,98 @@ public function testAFailingGatewayDispatchesStepFailedAndAgentFailedInsteadOfAg $this->assertSame($agentFailed->invocationId, $stepFailed->invocationId); } + #[DataProvider('publicInvocationMethods')] + public function testThrowingStepFailureObserverPreservesCancellation(string $method): void + { + $cancellation = new CanceledException; + $observerFailure = new RuntimeException('Step failure observer failed.'); + AssistantAgent::fake(static fn () => throw $cancellation); + Event::listen(StepFailed::class, static fn () => throw $observerFailure); + $caught = null; + + try { + $response = (new AssistantAgent)->{$method}('Hello'); + + if ($method === 'stream') { + iterator_to_array($response); + } + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($observerFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + + public function testThrowingStepFailureObserverPreservesMiddlewareCancellationAfterGeneration(): void + { + $cancellation = new CanceledException; + $observerFailure = new RuntimeException('Step failure observer failed.'); + AssistantAgent::fake(['Hello']); + Event::listen(StepFailed::class, static fn () => throw $observerFailure); + $agent = (new AssistantAgent)->withMiddleware([ + static function (PendingStep $step, Closure $next) use ($cancellation): never { + $next($step); + + throw $cancellation; + }, + ]); + $caught = null; + + try { + $agent->prompt('Hello'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($observerFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + + public function testThrowingToolFailureObserverPreservesToolCancellation(): void + { + $cancellation = new CanceledException; + $observerFailure = new RuntimeException('Tool failure observer failed.'); + $tool = m::mock(NamedTool::class, ['cancel_tool'])->makePartial(); + $tool->shouldReceive('handle')->once()->andThrow($cancellation); + ToolUsingAgent::fake([new ToolCall('call_1', 'cancel_tool', [])]); + $agent = (new ToolUsingAgent)->withTools([$tool]); + Event::listen(ToolFailed::class, static fn () => throw $observerFailure); + $caught = null; + + try { + $agent->prompt('Generate'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($observerFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + + public function testThrowingStreamingToolFailureObserverPreservesCancellation(): void + { + $cancellation = new CanceledException; + $observerFailure = new RuntimeException('Tool failure observer failed.'); + DelegatingAgent::fake([new ToolCall('research', 'research_agent', ['task' => 'Research'])]); + ResearchAgent::fake(static fn () => throw $cancellation); + Event::listen(ToolFailed::class, static fn () => throw $observerFailure); + $caught = null; + + try { + iterator_to_array((new DelegatingAgent)->stream('Research')); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($observerFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + public function testAFailingStreamDispatchesAgentFailedInsteadOfAgentStreamed(): void { Event::fake(); From 52b5cd702e2483b06e5169becb77d95b30393955 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:26:49 +0000 Subject: [PATCH 03/26] Keep producer cancellation through AI stream failure callbacks A public stream catch callback that throws could hide cancellation from the failed producer, making an interrupted stream look like an ordinary failure. Invoke the already-cleared callback list from finally while the producer exception remains in flight. Preserve callback execution context and once-only handling while retaining both failures through PHP's exception chain. Validation: StreamableAgentResponseTest passes all nine tests, including throwing catch callbacks and repeated stream iteration. Targeted source analysis and formatting pass. --- .../src/Responses/StreamableAgentResponse.php | 17 ++++++++------- .../Responses/StreamableAgentResponseTest.php | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/ai/src/Responses/StreamableAgentResponse.php b/src/ai/src/Responses/StreamableAgentResponse.php index ae63ec13a..19132c7db 100644 --- a/src/ai/src/Responses/StreamableAgentResponse.php +++ b/src/ai/src/Responses/StreamableAgentResponse.php @@ -383,13 +383,16 @@ public function getIterator(): Traversable $this->catchCallbacks = []; - $this->runInContext(function () use ($callbacks, $exception): void { - foreach ($callbacks as $callback) { - $callback($exception); - } - }); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure reporting throws. + try { + throw $exception; + } finally { + $this->runInContext(function () use ($callbacks, $exception): void { + foreach ($callbacks as $callback) { + $callback($exception); + } + }); + } } $this->events = new Collection($events); diff --git a/tests/Ai/Unit/Responses/StreamableAgentResponseTest.php b/tests/Ai/Unit/Responses/StreamableAgentResponseTest.php index c19742ee5..2869802f0 100644 --- a/tests/Ai/Unit/Responses/StreamableAgentResponseTest.php +++ b/tests/Ai/Unit/Responses/StreamableAgentResponseTest.php @@ -16,9 +16,11 @@ use Hypervel\Ai\Streaming\Events\TextDelta; use Hypervel\Ai\Streaming\Events\ToolCall as ToolCallEvent; use Hypervel\Ai\Streaming\Events\ToolResult as ToolResultEvent; +use Hypervel\Coroutine\Coroutine; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Throwable; class StreamableAgentResponseTest extends TestCase @@ -89,6 +91,25 @@ public function testAFailureIsReportedToTheCatchCallbacksOnceHoweverOftenTheStre $this->assertSame(['Boom.', 'Boom.'], $iterationFailures); } + public function testThrowingCatchCallbackPreservesProducerCancellation(): void + { + $cancellation = new CanceledException; + $callbackFailure = new RuntimeException('Failure callback failed.'); + $response = new StreamableAgentResponse('invocation', static fn () => throw $cancellation); + $response->catch(static fn () => throw $callbackFailure); + $caught = null; + + try { + iterator_to_array($response); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($callbackFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + #[DataProvider('completionCallbackModes')] public function testCompletionFailurePreservesConversationStateWithoutInvokingPublicCatchCallbacks(bool $immediate): void { From dfc6b6d2dcd9b073f76b8f30c5f816cf72917647 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:26:49 +0000 Subject: [PATCH 04/26] Preserve conversation turns and cancellation during title work Conversation persistence failures could replace cancellation during generation or title work. Independently canceled title generation could also discard a completed caller turn. Preserve cancellation through failed-turn recording and completed-turn persistence using finally. Mark independent title-child cancellation with ChildCancellationException and use the existing fallback title so the completed turn is stored. Cancellation of the caller still propagates after owned persistence. Validation: 13 focused RememberConversation cases pass on SQLite, covering direct and wrapped title-child cancellation, failed-turn storage, caller title cancellation, and title cleanup. Targeted source analysis and formatting pass. --- .../src/Middleware/RememberConversation.php | 100 ++++++++++-------- .../src/Support/PendingConversationTitle.php | 6 +- tests/Ai/Feature/RememberConversationTest.php | 81 ++++++++++++++ 3 files changed, 142 insertions(+), 45 deletions(-) diff --git a/src/ai/src/Middleware/RememberConversation.php b/src/ai/src/Middleware/RememberConversation.php index 7adadd05c..e448c6da2 100644 --- a/src/ai/src/Middleware/RememberConversation.php +++ b/src/ai/src/Middleware/RememberConversation.php @@ -23,6 +23,7 @@ use Hypervel\Ai\Support\ConversationSelection; use Hypervel\Ai\Support\PendingConversationTitle; use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\Exceptions\ChildCancellationException; use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Facades\DB; use Hypervel\Support\Str; @@ -76,9 +77,12 @@ public function handle(AgentPrompt $prompt, Closure $next): AgentResponse|Stream try { $response = $next($prompt); } catch (Throwable $exception) { - $this->rememberFailedTurn($prompt, $exception, $pendingConversationId); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure recording throws. + try { + throw $exception; + } finally { + $this->rememberFailedTurn($prompt, $exception, $pendingConversationId); + } } // Record each failed producer attempt; public catch callbacks run only once per response. @@ -97,9 +101,12 @@ public function handle(AgentPrompt $prompt, Closure $next): AgentResponse|Stream yield from $produce(); } catch (Throwable $exception) { $threw = true; - $this->rememberFailedTurn($prompt, $exception, $pendingConversationId, retryable: ! $responseReference->get()->hasYielded()); - - throw $exception; + // Keep the original exception, including cancellation, as previous if failure recording throws. + try { + throw $exception; + } finally { + $this->rememberFailedTurn($prompt, $exception, $pendingConversationId, retryable: ! $responseReference->get()->hasYielded()); + } } $completed = true; @@ -210,6 +217,8 @@ protected function rememberTurn(AgentPrompt $prompt, AgentResponse $response, ?s if (($exception === null || ! Coroutine::causedByCancellation($exception)) && ! $exception instanceof StreamAbandonedException) { try { $title = $prompt->pendingConversationTitle()?->value() ?? $this->generateTitle($prompt->prompt); + } catch (ChildCancellationException) { + // An independently canceled title must not discard the caller's completed turn. } catch (Throwable $caught) { if (! Coroutine::causedByCancellation($caught)) { throw $caught; @@ -226,53 +235,56 @@ protected function rememberTurn(AgentPrompt $prompt, AgentResponse $response, ?s } } - if ($this->store instanceof StoresConversationTurns) { - $stored = $this->store->storeTurn($conversationId, $title, $participantType, $participantId, $prompt, $response, $exception); - } else { - if ($newConversation) { - $conversationId = $this->store->storeConversation( + // Persist the finished turn if the caller was canceled during title generation; storage failures retain that cancellation. + try { + if ($cancellation !== null) { + throw $cancellation; + } + } finally { + if ($this->store instanceof StoresConversationTurns) { + $stored = $this->store->storeTurn($conversationId, $title, $participantType, $participantId, $prompt, $response, $exception); + } else { + if ($newConversation) { + $conversationId = $this->store->storeConversation( + $participantType, + $participantId, + $title, + $conversationId, + ); + } + + // A resume continues the turn its decisions answer, so it adds no message of its own... + $userMessageId = $prompt->hasApprovalDecisions() ? null : $this->store->storeUserMessage( + $conversationId, $participantType, $participantId, - $title, - $conversationId, + $agent::class, + new UserMessage($prompt->prompt, $prompt->attachments), ); - } - - // A resume continues the turn its decisions answer, so it adds no message of its own... - $userMessageId = $prompt->hasApprovalDecisions() ? null : $this->store->storeUserMessage( - $conversationId, - $participantType, - $participantId, - $agent::class, - new UserMessage($prompt->prompt, $prompt->attachments), - ); - - $assistantMessageId = $this->store->storeAssistantMessage( - $conversationId, - $participantType, - $participantId, - $prompt, - $response, - $exception, - ); - $stored = new StoredTurn($conversationId, $userMessageId, $assistantMessageId); - } + $assistantMessageId = $this->store->storeAssistantMessage( + $conversationId, + $participantType, + $participantId, + $prompt, + $response, + $exception, + ); - if ($newConversation) { - // Completing a lazy operation must not replace a selection the caller changed meanwhile. - if ($agent->currentConversation() === $selection->conversationId && $agent->conversationParticipant() === $participant) { - $agent->continue($stored->conversationId, $participant); + $stored = new StoredTurn($conversationId, $userMessageId, $assistantMessageId); } - $selection->conversationId = $stored->conversationId; - } + if ($newConversation) { + // Completing a lazy operation must not replace a selection the caller changed meanwhile. + if ($agent->currentConversation() === $selection->conversationId && $agent->conversationParticipant() === $participant) { + $agent->continue($stored->conversationId, $participant); + } - $response->withinConversation($stored->conversationId, $participant) - ->withStoredMessages($stored->userMessageId, $stored->assistantMessageId); + $selection->conversationId = $stored->conversationId; + } - if ($cancellation !== null) { - throw $cancellation; + $response->withinConversation($stored->conversationId, $participant) + ->withStoredMessages($stored->userMessageId, $stored->assistantMessageId); } return $stored; diff --git a/src/ai/src/Support/PendingConversationTitle.php b/src/ai/src/Support/PendingConversationTitle.php index 3abb8c621..86aa7f8c2 100644 --- a/src/ai/src/Support/PendingConversationTitle.php +++ b/src/ai/src/Support/PendingConversationTitle.php @@ -7,6 +7,8 @@ use Closure; use Hypervel\Ai\AiManager; use Hypervel\Ai\Gateway\ParentInvocation; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Coroutine\Exceptions\ChildCancellationException; use Hypervel\Coroutine\WaitConcurrent; use Hypervel\Database\ConnectionResolver; use Hypervel\Log\Context\Repository as ContextRepository; @@ -34,7 +36,9 @@ public function __construct(Closure $generate, ?Closure $runner) try { $this->title = $runner === null ? $generate() : $runner($generate); } catch (Throwable $exception) { - $this->failure = $exception; + $this->failure = Coroutine::causedByCancellation($exception) + ? new ChildCancellationException('Conversation title generation was canceled while its owner remained active.', previous: $exception) + : $exception; } }, [ParentInvocation::PARENT_INVOCATION_CONTEXT_KEY, AiManager::ON_DEMAND_PROVIDERS_CONTEXT_KEY, ContextRepository::CONTEXT_KEY, ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY]); } diff --git a/tests/Ai/Feature/RememberConversationTest.php b/tests/Ai/Feature/RememberConversationTest.php index dcf016392..3c3058c5d 100644 --- a/tests/Ai/Feature/RememberConversationTest.php +++ b/tests/Ai/Feature/RememberConversationTest.php @@ -475,6 +475,26 @@ public function testPublicAgentCallsGenerateOneConcurrentTitleThroughTheProvider $this->assertDatabaseHas('agent_conversation_messages', ['id' => $response->assistantMessageId, 'content' => 'Hello back']); } + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testIndependentTitleCancellationPreservesTheCompletedTurn(bool $wrapped): void + { + config(['ai.conversations.generate_title' => true, 'ai.conversations.concurrent_title' => true]); + $prompt = $this->prompt(); + $middleware = m::mock(RememberConversation::class, [new DatabaseConversationStore, $prompt->provider()]) + ->makePartial()->shouldAllowMockingProtectedMethods(); + $failure = $wrapped + ? new RuntimeException('Title cleanup failed.', previous: new CanceledException) + : new CanceledException; + $middleware->shouldReceive('generateTitle')->once()->andThrow($failure); + + $response = $middleware->handle($prompt, fn () => $this->response()); + + $this->assertDatabaseHas('agent_conversations', ['id' => $response->conversationId, 'title' => 'Hello']); + $this->assertDatabaseHas('agent_conversation_messages', ['id' => $response->assistantMessageId, 'content' => 'Hello back', 'status' => 'completed']); + $this->assertSame($response->conversationId, $prompt->agent->currentConversation()); + } + #[DataProvider('executionModes')] public function testUnrecordableFailuresCancelAndJoinAnUnfinishedTitle(bool $streaming): void { @@ -553,6 +573,41 @@ public function testRecordableFailuresUseTheConcurrentTitleOnce(bool $streaming) $this->assertDatabaseHas('agent_conversation_messages', ['role' => 'assistant', 'status' => 'failed']); } + #[DataProvider('executionModes')] + public function testFailedTurnPersistencePreservesCancellation(bool $streaming): void + { + $prompt = $this->prompt(); + $cancellation = new CanceledException; + $storageFailure = new RuntimeException('Conversation storage failed.'); + $store = m::mock(DatabaseConversationStore::class); + $store->shouldReceive('storeTurn')->once()->andThrow($storageFailure); + $middleware = new RememberConversation($store, $prompt->provider()); + $generate = function () use ($prompt, $cancellation): never { + $prompt->setRunContext($context = $this->context($prompt)); + $context->recordStep($this->step()); + + throw $cancellation; + }; + $caught = null; + + try { + if ($streaming) { + $prompt->markAsStreaming(); + $stream = new StreamableAgentResponse('invocation', $generate); + iterator_to_array($middleware->handle($prompt, fn () => $stream)); + } else { + $middleware->handle($prompt, $generate); + } + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($storageFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + $this->assertNull($prompt->runContext()); + } + /** * Provide synchronous and streamed execution. */ @@ -896,6 +951,32 @@ public function testTitleCancellationSavesTheCompletedTurnBeforeRethrowing(bool $this->assertSame($response->conversationId, $prompt->agent->currentConversation()); } + public function testStorageFailurePreservesTitleCancellation(): void + { + config(['ai.conversations.generate_title' => true]); + $prompt = $this->prompt(); + $cancellation = new CanceledException('Title request canceled.'); + $storageFailure = new RuntimeException('Conversation storage failed.'); + $loop = m::mock(TextGenerationLoop::class); + $loop->shouldReceive('generate')->once()->andThrow($cancellation); + $prompt->provider()->shouldReceive('textGenerationLoop')->once()->andReturn($loop); + $prompt->provider()->shouldReceive('cheapestTextModel')->once()->andReturn('title-model'); + $store = m::mock(DatabaseConversationStore::class); + $store->shouldReceive('storeTurn')->once()->andThrow($storageFailure); + $caught = null; + + try { + (new RememberConversation($store, $prompt->provider())) + ->handle($prompt, fn () => $this->response()); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($storageFailure, $caught); + $this->assertSame($cancellation, $caught->getPrevious()); + $this->assertTrue(Coroutine::causedByCancellation($caught)); + } + /** * Create a prompt without making provider requests. */ From 8af6ce8b2980a5442c0d732f43d743d16f6e5b5d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:26:49 +0000 Subject: [PATCH 05/26] Preserve download cancellation over AI attachment size errors When an oversized remote attachment also failed during cancellation cleanup, the size-limit exception could replace the cancellation chain. Use the shared cancellation predicate before replacing a transfer failure with the existing size-limit message. Ordinary oversized responses keep their current behavior; canceled transfers retain their original failure. Validation: both direct and wrapped cancellation cases pass against the bounded loopback transfer fixture. Targeted source analysis and formatting pass. --- src/ai/src/Files/UntrustedUrl.php | 4 ++-- tests/Ai/Feature/Files/UntrustedUrlTest.php | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/ai/src/Files/UntrustedUrl.php b/src/ai/src/Files/UntrustedUrl.php index 0a5c9d690..b0806499f 100644 --- a/src/ai/src/Files/UntrustedUrl.php +++ b/src/ai/src/Files/UntrustedUrl.php @@ -9,6 +9,7 @@ use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Psr7\Utils; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Client\Destinations\DestinationResolutionException; use Hypervel\Http\Client\Destinations\DisallowedDestinationException; use Hypervel\Http\Client\Response; @@ -16,7 +17,6 @@ use InvalidArgumentException; use Psr\Http\Message\ResponseInterface; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Throwable; class UntrustedUrl @@ -101,7 +101,7 @@ public static function fetch(string $url): Response try { $response = $request->get((string) $uri); } catch (Throwable $exception) { - if ($tooLarge && ! $exception instanceof CanceledException) { + if ($tooLarge && ! Coroutine::causedByCancellation($exception)) { throw new InvalidArgumentException("The remote file exceeds the maximum size of {$kilobytes} kilobytes."); } diff --git a/tests/Ai/Feature/Files/UntrustedUrlTest.php b/tests/Ai/Feature/Files/UntrustedUrlTest.php index a388297f4..5682d1ff8 100644 --- a/tests/Ai/Feature/Files/UntrustedUrlTest.php +++ b/tests/Ai/Feature/Files/UntrustedUrlTest.php @@ -20,8 +20,11 @@ use Hypervel\Tests\Http\Fixtures\LoopbackHttpServer; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; use Swoole\Coroutine\Socket; +use Throwable; class UntrustedUrlTest extends TestCase { @@ -434,20 +437,29 @@ public static function invalidSizeLimits(): array ]; } - public function testCancellationDuringTransferCompletionTakesPrecedenceOverTheSizeFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCancellationDuringTransferCompletionTakesPrecedenceOverTheSizeFailure(bool $wrapped): void { config(['ai.remote_files.allowed_hosts' => ['127.0.0.1'], 'ai.remote_files.max_size' => 1]); $server = LoopbackHttpServer::start([['body' => str_repeat('x', 1025)]]); $canceled = new CanceledException('Canceled while publishing transfer statistics.'); + + if ($wrapped) { + $canceled = new RuntimeException('Transfer callback cleanup failed.', previous: $canceled); + } + Http::globalOptions(['on_stats' => static function () use ($canceled): void { throw $canceled; }]); + $caught = null; try { UntrustedUrl::fetch("http://127.0.0.1:{$server->port}/photo.png"); - $this->fail('The cancellation was swallowed.'); - } catch (CanceledException $exception) { - $this->assertSame($canceled, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($canceled, $caught); } } From 82494bfe5fe55695625a53a82950c4f85061a43c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:26 +0000 Subject: [PATCH 06/26] Preserve cancellation through MCP server error responses MCP handlers and generators could convert cancellation carrying a cleanup failure into a protocol error, tool error or empty completion. Debug-mode rethrows concealed the server failure in tests. Check cancellation at the shared server error boundary and the response-conversion and completion fallbacks. Rethrow the original failure while preserving ordinary error responses and the completion catch's original Exception breadth. Validation: ServerTest passes 52 tests, CallToolTest 24 and CompletionCompleteTest 26. Regressions cover debug-disabled server handling, immediate and streamed tool failures, and direct or wrapped completion cancellation. Targeted source analysis and formatting pass. --- src/mcp/src/Server.php | 10 ++-- .../src/Server/Methods/CompletionComplete.php | 10 ++-- .../Concerns/InteractsWithResponses.php | 14 ++++-- tests/Mcp/Unit/Methods/CallToolTest.php | 50 +++++++++++++++++++ .../Unit/Methods/CompletionCompleteTest.php | 14 +++++- tests/Mcp/Unit/ServerTest.php | 23 +++++++-- 6 files changed, 100 insertions(+), 21 deletions(-) diff --git a/src/mcp/src/Server.php b/src/mcp/src/Server.php index 389d0e3ca..17fde4a62 100644 --- a/src/mcp/src/Server.php +++ b/src/mcp/src/Server.php @@ -7,6 +7,7 @@ use Generator; use Hypervel\Container\Container; use Hypervel\Contracts\Config\Repository; +use Hypervel\Coroutine\Coroutine; use Hypervel\Mcp\Enums\ErrorCode; use Hypervel\Mcp\Enums\Extension; use Hypervel\Mcp\Enums\MetaKey; @@ -51,7 +52,6 @@ use InvalidArgumentException; use JsonException; use stdClass; -use Swoole\Coroutine\CanceledException; use Throwable; /** @@ -257,8 +257,6 @@ public function handle(string $rawMessage): void } $this->handleMessage($request, $context); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { $this->sendException($exception, $context, $requestId); } @@ -269,6 +267,10 @@ public function handle(string $rawMessage): void */ protected function sendException(Throwable $exception, ServerContext $context, int|string|null $requestId): void { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + if ($exception instanceof JsonRpcException) { $response = $exception->toJsonRpcResponse(); } else { @@ -417,8 +419,6 @@ protected function streamResponses(iterable $response, JsonRpcRequest $request, $this->send($message, $context, $request); yield; } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { $this->sendException($exception, $context, $request->id); yield; diff --git a/src/mcp/src/Server/Methods/CompletionComplete.php b/src/mcp/src/Server/Methods/CompletionComplete.php index 2f768669f..428390b61 100644 --- a/src/mcp/src/Server/Methods/CompletionComplete.php +++ b/src/mcp/src/Server/Methods/CompletionComplete.php @@ -6,6 +6,7 @@ use Exception; use Hypervel\Container\Container; +use Hypervel\Coroutine\Coroutine; use Hypervel\Mcp\Exceptions\JsonRpcException; use Hypervel\Mcp\Server; use Hypervel\Mcp\Server\Completions\CompletionResponse; @@ -21,7 +22,6 @@ use Hypervel\Mcp\Transport\JsonRpcResponse; use Hypervel\Support\Arr; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; class CompletionComplete implements Method { @@ -93,9 +93,11 @@ public function handle(JsonRpcRequest $request, ServerContext $context): JsonRpc try { $result = $this->invokeCompletion($primitive, $argumentName, $argumentValue, $contextArguments); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $result = CompletionResponse::empty(); } diff --git a/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php b/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php index ddac84d4b..e14781b66 100644 --- a/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php +++ b/src/mcp/src/Server/Methods/Concerns/InteractsWithResponses.php @@ -7,6 +7,7 @@ use Generator; use Hypervel\Auth\Access\AuthorizationException; use Hypervel\Auth\AuthenticationException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Mcp\Exceptions\JsonRpcException; use Hypervel\Mcp\Response; use Hypervel\Mcp\ResponseFactory; @@ -18,7 +19,6 @@ use Hypervel\Support\Arr; use Hypervel\Validation\ValidationException; use InvalidArgumentException; -use Swoole\Coroutine\CanceledException; use Throwable; trait InteractsWithResponses @@ -73,9 +73,11 @@ protected function toJsonRpcStreamedResponse(JsonRpcRequest $request, iterable $ $pendingResponses[] = $response; } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + if ($this instanceof Errable) { yield $this->toJsonRpcResponse( $request, @@ -99,9 +101,11 @@ protected function callHandler(callable $handler, JsonRpcRequest $request): mixe { try { return $handler(); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + if ($this instanceof Errable) { return $this->toErrorResponse($throwable); } diff --git a/tests/Mcp/Unit/Methods/CallToolTest.php b/tests/Mcp/Unit/Methods/CallToolTest.php index b3352a455..3786f28f0 100644 --- a/tests/Mcp/Unit/Methods/CallToolTest.php +++ b/tests/Mcp/Unit/Methods/CallToolTest.php @@ -30,6 +30,10 @@ use Hypervel\Tests\Mcp\Fixtures\ToolWithResultMetaTool; use Hypervel\Tests\Mcp\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class CallToolTest extends TestCase { @@ -778,6 +782,52 @@ public function testReturnsResponseFactoryWithStructuredContentAddedViaWithStruc $this->assertFalse($payload['result']['isError']); } + #[TestWith([false], 'immediate handler')] + #[TestWith([true], 'streamed handler')] + public function testCancellationThroughCleanupIsNotConvertedToAToolError(bool $streamed): void + { + $exception = new RuntimeException('Tool cleanup failed.', previous: new CanceledException); + $tool = new class($exception, $streamed) extends Tool { + /** + * Capture the failure and response mode. + */ + public function __construct(private Throwable $exception, private bool $streamed) + { + } + + /** + * Fail during invocation or while producing a response. + */ + public function handle(): Response|Generator + { + if (! $this->streamed) { + throw $this->exception; + } + + return (function (): Generator { + yield Response::text('partial'); + + throw $this->exception; + })(); + } + }; + $this->app->instance($tool::class, $tool); + $request = new JsonRpcRequest(1, 'tools/call', ['name' => $tool->name()]); + $caught = null; + + try { + $response = (new CallTool)->handle($request, $this->getServerContext(['tools' => [$tool::class]])); + + if ($response instanceof Generator) { + iterator_to_array($response); + } + } catch (Throwable $failure) { + $caught = $failure; + } + + $this->assertSame($exception, $caught); + } + public function testCombinesStreamedFactoriesWithOtherContentAndResultMetadata(): void { $tool = new class extends Tool { diff --git a/tests/Mcp/Unit/Methods/CompletionCompleteTest.php b/tests/Mcp/Unit/Methods/CompletionCompleteTest.php index 3d6343667..1469e2d06 100644 --- a/tests/Mcp/Unit/Methods/CompletionCompleteTest.php +++ b/tests/Mcp/Unit/Methods/CompletionCompleteTest.php @@ -20,7 +20,10 @@ use Hypervel\Mcp\Transport\JsonRpcRequest; use Hypervel\Tests\Mcp\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class CompletionMethodTestServer extends Server { @@ -478,14 +481,21 @@ public static function invalidParameters(): array ]; } - public function testCancellationIsNotConvertedToAnEmptyCompletion(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCancellationIsNotConvertedToAnEmptyCompletion(bool $wrapped): void { $exception = new CanceledException('Completion cancelled.'); + + if ($wrapped) { + $exception = new RuntimeException('Completion cleanup failed.', previous: $exception); + } + $prompt = new class($exception) extends CompletionMethodTestPrompt { /** * Capture the cancellation to raise during completion. */ - public function __construct(private CanceledException $exception) + public function __construct(private Throwable $exception) { } diff --git a/tests/Mcp/Unit/ServerTest.php b/tests/Mcp/Unit/ServerTest.php index 69a00ac94..787116ec2 100644 --- a/tests/Mcp/Unit/ServerTest.php +++ b/tests/Mcp/Unit/ServerTest.php @@ -22,7 +22,10 @@ use Hypervel\Tests\Mcp\Fixtures\ExampleServer; use Hypervel\Tests\Mcp\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Tests\Mcp\Fixtures\callStreamingToolMessage; use function Hypervel\Tests\Mcp\Fixtures\callToolMessage; @@ -841,23 +844,33 @@ public static function terminalFailures(): array ]; } - public function testLazyCancellationRestoresTheRequestWithoutSendingAnError(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testLazyCancellationRestoresTheRequestWithoutSendingAnError(bool $wrapped): void { + config(['app.debug' => false]); + $transport = new ArrayTransport; $server = new ExampleServer($transport); $server->addMethod('test/cancel', CancellationMethod::class); $method = new CancellationMethod; + + if ($wrapped) { + $method->cancellation = new RuntimeException('Request cleanup failed.', previous: $method->cancellation); + } + $this->app->instance(CancellationMethod::class, $method); $outer = new Request(['outer' => true]); CoroutineContext::set(Request::class, $outer); + $caught = null; try { $server->handle('{"jsonrpc":"2.0","id":1,"method":"test/cancel","params":{"arguments":{"value":"inner"}}}'); - $this->fail('Cancellation must propagate.'); - } catch (CanceledException $exception) { - $this->assertSame($method->cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + $this->assertSame($method->cancellation, $caught); $this->assertSame('inner', $method->closedUnder?->get('value')); $this->assertSame($outer, CoroutineContext::get(Request::class)); $this->assertCount(1, $transport->sent); @@ -939,7 +952,7 @@ class CancellationMethod implements Method { public ?Request $closedUnder = null; - public CanceledException $cancellation; + public Throwable $cancellation; /** * Create the cancellation signal. From 85217256be6822ddbd1708e4795d182a2acea883 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:26 +0000 Subject: [PATCH 07/26] Preserve cancellation across MCP handshake fallback and cleanup A remembered legacy handshake could retry after wrapped cancellation, while fallback initialization could relabel cancellation as an additional handshake failure. Cleanup precedence also recognized only bare cancellation. Use the existing cancellation predicate before handshake fallback or failure wrapping and preferCancellation when cleanup throws. Keep authorization errors, ordinary negotiation fallback and original exception identity unchanged. Validation: HandshakeTest passes all 35 tests, including remembered-handshake cancellation, discovery timeout followed by cancellation, and cancellation during disconnect. Targeted source analysis and formatting pass. --- src/mcp/src/Client/Protocol.php | 19 +++++--- tests/Mcp/Unit/Client/HandshakeTest.php | 62 ++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/mcp/src/Client/Protocol.php b/src/mcp/src/Client/Protocol.php index adb1fee45..1822b7ab1 100644 --- a/src/mcp/src/Client/Protocol.php +++ b/src/mcp/src/Client/Protocol.php @@ -194,9 +194,13 @@ protected function handshake(): void $this->connection = $this->initialize($remembered); return; - } catch (OAuthException|CanceledException $oAuthException) { + } catch (OAuthException $oAuthException) { throw $oAuthException; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->connection = null; $this->transport->connect(); @@ -231,9 +235,13 @@ protected function probe(): NegotiatedConnection $this->transport->connect(); return $this->initialize(ProtocolVersion::V2025_11_25); - } catch (OAuthException|CanceledException $oAuthException) { + } catch (OAuthException $oAuthException) { throw $oAuthException; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + throw $rejection instanceof ClientException ? new ClientException(sprintf( '%s The legacy handshake also failed: %s', @@ -658,10 +666,7 @@ private function cleanup(Closure $cleanup, ?Throwable $primary = null): void try { $cleanup(); } catch (Throwable $exception) { - if ($primary === null - || ($exception instanceof CanceledException && ! $primary instanceof CanceledException)) { - throw $exception; - } + throw Coroutine::preferCancellation($primary, $exception); } } diff --git a/tests/Mcp/Unit/Client/HandshakeTest.php b/tests/Mcp/Unit/Client/HandshakeTest.php index 0a1b682fc..f5274579f 100644 --- a/tests/Mcp/Unit/Client/HandshakeTest.php +++ b/tests/Mcp/Unit/Client/HandshakeTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Tests\Mcp\Fixtures\discoverResponse; use function Hypervel\Tests\Mcp\Fixtures\initializeResponse; @@ -106,6 +107,42 @@ public function testFallsBackToTheHandshakeWhenTheDiscoveryProbeTimesOut(): void $this->assertSame('notifications/initialized', json_decode($transport->sent[1], true)['method']); } + public function testLegacyFallbackPreservesCancellationThroughCleanup(): void + { + $failure = new RuntimeException('Handshake cleanup failed.', previous: new CanceledException); + $transport = new class($failure) extends FakeTransport { + /** + * Capture the failure raised by the legacy handshake. + */ + public function __construct(private Throwable $failure) + { + } + + /** + * Cancel while waiting for the legacy handshake response. + */ + public function receive(): string + { + throw $this->failure; + } + }; + $transport->negotiates = true; + $transport->timesOutOnDiscover = true; + $client = new Client($transport); + $caught = null; + + try { + $client->connect(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse($client->connected()); + $this->assertCount(1, $transport->sent); + $this->assertSame('initialize', json_decode($transport->sent[0], true)['method']); + } + public function testNeverSendsADiscoveryEraVersionOrMetadataThroughTheHandshake(): void { $transport = $this->negotiatingTransport(); @@ -480,7 +517,10 @@ public function testSurfacesAnInvalidParamsErrorInsteadOfGuessingTheServerIsLega $this->assertCount(1, $transport->sent); } - public function testRethrowsAnAuthorizationFailureWhenReinitializingARememberedLegacyServer(): void + #[TestWith(['authorization'])] + #[TestWith(['direct cancellation'])] + #[TestWith(['wrapped cancellation'])] + public function testRethrowsAuthorizationAndCancellationWhenReinitializingARememberedLegacyServer(string $kind): void { $transport = $this->negotiatingTransport(); $transport->responses[] = methodNotFoundResponse(); @@ -491,13 +531,17 @@ public function testRethrowsAnAuthorizationFailureWhenReinitializingARememberedL $client->disconnect(); $transport->sent = []; - $failure = new AuthorizationRequiredException; + $failure = match ($kind) { + 'direct cancellation' => new CanceledException, + 'wrapped cancellation' => new RuntimeException('Request cleanup failed.', previous: new CanceledException), + default => new AuthorizationRequiredException, + }; $transport->throwOnNextSend = $failure; $caught = null; try { $client->connect(); - } catch (AuthorizationRequiredException $exception) { + } catch (Throwable $exception) { $caught = $exception; } @@ -594,16 +638,20 @@ public function receive(): string } } - public function testCancellationDuringDisconnectTakesPrecedenceOverARequestFailure(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCancellationDuringDisconnectTakesPrecedenceOverARequestFailure(bool $wrapped): void { - $cancellation = new CanceledException('Disconnect canceled.'); + $cancellation = $wrapped + ? new RuntimeException('Disconnect cleanup failed.', previous: new CanceledException) + : new CanceledException('Disconnect canceled.'); $transport = new class($cancellation) extends FakeTransport { public bool $disconnected = false; /** * Create a transport whose cleanup is cancelled. */ - public function __construct(private CanceledException $cancellation) + public function __construct(private Throwable $cancellation) { } @@ -627,7 +675,7 @@ public function disconnect(): void try { $client->ping(); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $caught = $exception; } From 506879caa8f8ae58d394b67aee97f50c16dce2b5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:27 +0000 Subject: [PATCH 08/26] Preserve cancellation while disconnecting managed MCP clients Managed client teardown could suppress a ClientException carrying cancellation or retain an earlier ordinary failure instead of later cancellation. Preserve cancellation at the client-error suppression boundary and use the shared precedence helper while still visiting every owned client. Ordinary client disconnect failures remain ignored as before. Validation: ClientManagerTest passes all 38 tests, including direct, wrapped and transport-wrapped cancellation and verification that every client is visited once. Targeted source analysis and formatting pass. --- src/mcp/src/Client/ClientManager.php | 11 +++++----- tests/Mcp/Unit/Client/ClientManagerTest.php | 23 +++++++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/mcp/src/Client/ClientManager.php b/src/mcp/src/Client/ClientManager.php index f2f4424e6..b322b7175 100644 --- a/src/mcp/src/Client/ClientManager.php +++ b/src/mcp/src/Client/ClientManager.php @@ -13,7 +13,6 @@ use Hypervel\Mcp\WebClient; use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; -use Swoole\Coroutine\CanceledException; use Throwable; class ClientManager @@ -166,10 +165,7 @@ public function disconnectAll(): void try { $this->disconnect($client); } catch (Throwable $throwable) { - if ($exception === null - || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $throwable; - } + $exception = Coroutine::preferCancellation($exception, $throwable); } } @@ -185,7 +181,10 @@ protected function disconnect(Client $client): void { try { $client->disconnect(); - } catch (ClientException) { + } catch (ClientException $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } } } diff --git a/tests/Mcp/Unit/Client/ClientManagerTest.php b/tests/Mcp/Unit/Client/ClientManagerTest.php index 441d803bc..ef1c4b502 100644 --- a/tests/Mcp/Unit/Client/ClientManagerTest.php +++ b/tests/Mcp/Unit/Client/ClientManagerTest.php @@ -21,9 +21,11 @@ use Hypervel\Tests\Mcp\Fixtures\Client\FakeTransport; use Hypervel\Tests\Mcp\Fixtures\Client\ThrowingTransport; use Hypervel\Tests\Mcp\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; use function Hypervel\Tests\Mcp\Fixtures\discoverResponse; @@ -612,9 +614,16 @@ function () use ($manager, $ready, $closed): Client { } } - public function testTeardownVisitsEveryClientAndCancellationTakesPrecedence(): void + #[TestWith(['direct'])] + #[TestWith(['wrapped'])] + #[TestWith(['transport wrapper'])] + public function testTeardownVisitsEveryClientAndCancellationTakesPrecedence(string $kind): void { - $cancellation = new CanceledException('cleanup cancelled'); + $cancellation = match ($kind) { + 'wrapped' => new RuntimeException('Cleanup failed.', previous: new CanceledException), + 'transport wrapper' => new ClientException('Disconnect failed.', previous: new CanceledException), + default => new CanceledException('cleanup cancelled'), + }; $closed = []; $manager = app(ClientManager::class); @@ -624,7 +633,7 @@ public function testTeardownVisitsEveryClientAndCancellationTakesPrecedence(): v /** * Capture the teardown outcome for this test client. */ - public function __construct(FakeTransport $transport, private string $label, private CanceledException $cancellation, private array &$closed) + public function __construct(FakeTransport $transport, private string $label, private Throwable $cancellation, private array &$closed) { parent::__construct($transport); } @@ -647,13 +656,15 @@ public function disconnect(): void $manager->client($name); } + $caught = null; + try { $manager->disconnectAll(); - $this->fail('Cleanup cancellation must propagate.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + $this->assertSame($cancellation, $caught); $this->assertSame(['ordinary', 'cancelled', 'healthy'], $closed); $manager->disconnectAll(); $this->assertCount(3, $closed); From 8c734039f8373910c0bede53b43829859d181631 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:27 +0000 Subject: [PATCH 09/26] Retain cancellation during MCP HTTP session teardown Session termination treated cleanup-wrapped cancellation as an ignorable DELETE failure, and response cleanup selected cancellation only by its outer exception type. Use the shared predicate for termination failures and the existing precedence helper for owned exchange cleanup. Session state is still reset and owned responses closed; active sibling consumers retain their transfer ownership. Validation: HttpTransportTest passes all 45 tests, including direct and wrapped termination cancellation, response closure and idempotent disconnect. Targeted source analysis and formatting pass. --- .../src/Client/Transport/HttpTransport.php | 21 ++++++------------- .../Client/Transport/HttpTransportTest.php | 17 ++++++++++----- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/mcp/src/Client/Transport/HttpTransport.php b/src/mcp/src/Client/Transport/HttpTransport.php index fc80dfc17..dc16b62dc 100644 --- a/src/mcp/src/Client/Transport/HttpTransport.php +++ b/src/mcp/src/Client/Transport/HttpTransport.php @@ -26,7 +26,6 @@ use Hypervel\Support\Facades\Http; use Psr\Http\Message\StreamInterface; use SensitiveParameter; -use Swoole\Coroutine\CanceledException; use Throwable; use WeakMap; @@ -107,14 +106,7 @@ public function disconnect(): void try { $this->endExchange(); } catch (Throwable $cleanupException) { - if (! isset($exception) - || ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException)) { - $exception = $cleanupException; - } - } - - if (isset($exception)) { - throw $exception; + throw Coroutine::preferCancellation($exception ?? null, $cleanupException); } } } @@ -274,9 +266,7 @@ public function send(string $message, array $headers = []): void try { $this->endExchange(); } catch (Throwable $cleanupException) { - if ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException) { - throw $cleanupException; - } + throw Coroutine::preferCancellation($exception, $cleanupException); } throw $exception; @@ -540,9 +530,10 @@ protected function terminateSession(): void $this->request() ->timeout($this->timeoutSeconds) ->delete($this->url); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } } } diff --git a/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php b/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php index 2eda1c231..fd6e4a89e 100644 --- a/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php @@ -22,8 +22,10 @@ use Hypervel\Tests\Mcp\TestCase; use PHPUnit\Framework\Attributes\TestWith; use Psr\Http\Message\StreamInterface; +use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; use function Hypervel\Tests\Mcp\Fixtures\legacyEndpoint; @@ -732,9 +734,12 @@ public function testJoinsSseDataLinesAtEventBoundaries(string $newline): void } } - public function testCancelledSessionTerminationStillClosesTheResponseAndForgetsTheSession(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCancelledSessionTerminationStillClosesTheResponseAndForgetsTheSession(bool $wrapped): void { - $exception = new CanceledException('DELETE cancelled'); + $cancellation = new CanceledException('DELETE cancelled'); + $exception = $wrapped ? new RuntimeException('DELETE cleanup failed.', previous: $cancellation) : $cancellation; $body = Utils::streamFor('{"jsonrpc":"2.0","id":1,"result":{}}'); $deletes = 0; Http::fake(function (Request $request) use ($body, $exception, &$deletes): PromiseInterface { @@ -749,13 +754,15 @@ public function testCancelledSessionTerminationStillClosesTheResponseAndForgetsT $transport->useProtocol(ProtocolVersion::V2025_11_25); $transport->send('{"jsonrpc":"2.0","id":1,"method":"initialize"}'); + $caught = null; + try { $transport->disconnect(); - $this->fail('Session termination should propagate cancellation.'); - } catch (CanceledException $caught) { - $this->assertSame($exception, $caught); + } catch (Throwable $failure) { + $caught = $failure; } + $this->assertSame($exception, $caught); $this->assertFalse($body->isReadable()); $transport->disconnect(); $this->assertSame(1, $deletes); From 78286e1fbee1decc4a7098439ac47e7606781ae6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:27 +0000 Subject: [PATCH 10/26] Preserve cancellation from MCP stdio cleanup A failed stdio receive could hide cancellation raised through process-stop cleanup because teardown checked only the outer exception type. Use preferCancellation when disconnect cleanup fails. Preserve the original primary failure for ordinary cleanup errors and retain existing process/input detachment before propagating cancellation. Validation: three focused StdioTransport cases pass for direct disconnect, wrapped disconnect and wrapped cleanup after receive failure. Tests use a bounded process mock; targeted source analysis and formatting pass. --- .../src/Client/Transport/StdioTransport.php | 6 ++--- .../Client/Transport/StdioTransportTest.php | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/mcp/src/Client/Transport/StdioTransport.php b/src/mcp/src/Client/Transport/StdioTransport.php index f31da23a0..3455cfa8b 100644 --- a/src/mcp/src/Client/Transport/StdioTransport.php +++ b/src/mcp/src/Client/Transport/StdioTransport.php @@ -4,12 +4,12 @@ namespace Hypervel\Mcp\Client\Transport; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; use Hypervel\Mcp\Client\Contracts\Transport; use Hypervel\Mcp\Client\Exceptions\TimeoutException; use Hypervel\Mcp\Client\Exceptions\TransportException; use Hypervel\Process\InvokedProcess; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Exception\ExceptionInterface; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; @@ -238,9 +238,7 @@ private function disconnectAfterFailure(Throwable $exception): never try { $this->disconnect(); } catch (Throwable $cleanupException) { - if ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException) { - throw $cleanupException; - } + throw Coroutine::preferCancellation($exception, $cleanupException); } throw $exception; diff --git a/tests/Mcp/Unit/Client/Transport/StdioTransportTest.php b/tests/Mcp/Unit/Client/Transport/StdioTransportTest.php index e3436f004..ea0e6f556 100644 --- a/tests/Mcp/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Mcp/Unit/Client/Transport/StdioTransportTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Mcp\Unit\Client\Transport; +use Generator; use Hypervel\Filesystem\Filesystem; use Hypervel\Mcp\Client; use Hypervel\Mcp\Client\Exceptions\TimeoutException; @@ -16,8 +17,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Process; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -221,10 +224,24 @@ public function testZeroTimeoutAllowsADelayedResponseAfterStderr(): void } } - public function testDisconnectDetachesOwnedStateBeforePropagatingCancellation(): void + #[TestWith([false, false], 'direct disconnect')] + #[TestWith([true, false], 'wrapped disconnect')] + #[TestWith([true, true], 'wrapped cleanup after failed receive')] + public function testDisconnectDetachesOwnedStateBeforePropagatingCancellation(bool $wrapped, bool $receive): void { - $cancellation = new CanceledException('Stop canceled.'); + $cancellation = $wrapped + ? new RuntimeException('Stop cleanup failed.', previous: new CanceledException) + : new CanceledException('Stop canceled.'); $process = m::mock(Process::class); + + if ($receive) { + $process->shouldReceive('getLastOutputTime')->once()->andReturn(microtime(true)); + $process->shouldReceive('setIdleTimeout')->once()->andReturnSelf(); + $process->shouldReceive('getIterator')->once()->andReturnUsing(static function (): Generator { + yield from []; + }); + } + $process->shouldReceive('isRunning')->once()->andReturnTrue(); $process->shouldReceive('stop')->once()->with(0.1, null)->ordered()->andThrow($cancellation); $process->shouldReceive('stop')->once()->with(0, null)->ordered()->andReturn(0); @@ -234,8 +251,8 @@ public function testDisconnectDetachesOwnedStateBeforePropagatingCancellation(): $caught = null; try { - $transport->disconnect(); - } catch (CanceledException $exception) { + $receive ? $transport->receive() : $transport->disconnect(); + } catch (Throwable $exception) { $caught = $exception; } From 61a367c51925a845e1c34aa42ddbb2fbc51315d5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:55 +0000 Subject: [PATCH 11/26] Propagate cancellation from OpenTelemetry cache key resolvers A cache-key resolver's cleanup failure could hide cancellation and allow completion telemetry to continue. Use the shared cancellation predicate before logging an ordinary resolver error and omitting the key. Preserve ordinary resolver isolation and the existing metrics and tracing behavior. Validation: CacheInstrumentationTest passes all eight tests, including direct and wrapped cancellation with no completion telemetry. Targeted source analysis and formatting pass. --- .../Instrumentation/CacheInstrumentation.php | 8 +++++--- .../CacheInstrumentationTest.php | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php b/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php index ba425967e..1a5c31cf6 100644 --- a/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php @@ -18,6 +18,7 @@ use Hypervel\Cache\Events\KeyWritten; use Hypervel\Cache\Events\ManyKeysRetrievalFailed; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\OpenTelemetry\OpenTelemetryManager; use OpenTelemetry\API\Behavior\LogsMessagesTrait; use OpenTelemetry\API\Metrics\CounterInterface; @@ -25,7 +26,6 @@ use OpenTelemetry\API\Trace\Span; use OpenTelemetry\API\Trace\SpanInterface; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; -use Swoole\Coroutine\CanceledException; use Throwable; use UnexpectedValueException; @@ -213,9 +213,11 @@ protected function key(string $key, ?string $store): ?string 'The OpenTelemetry cache-key resolver must return a string or null.', ); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + self::logError('OpenTelemetry cache-key resolution failed.', ['exception' => $exception]); return null; diff --git a/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php index 0af91bd71..16a1c1f7e 100644 --- a/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php @@ -41,8 +41,10 @@ use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; use OpenTelemetry\SemConv\Attributes\ErrorAttributes; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class CacheInstrumentationTest extends TestCase { @@ -215,21 +217,26 @@ public function testResolverFailureOmitsTheKeyWithoutSuppressingCompletionTeleme ]); } - public function testResolverCancellationPropagatesWithoutCompletionTelemetry(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testResolverCancellationPropagatesWithoutCompletionTelemetry(bool $wrapped): void { - $cancellation = new CanceledException; + $cancellation = $wrapped ? new RuntimeException('Resolver cleanup failed.', previous: new CanceledException) : new CanceledException; $resolver = static function () use ($cancellation): never { throw $cancellation; }; $this->instrumentation($resolver)->register($this->options(['key' => true])); $this->withinSpan(function () use ($cancellation): void { + $caught = null; + try { $this->events->dispatch(new CacheHit('array', 'customer:1', 'value')); - $this->fail('Expected the resolver cancellation to propagate.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($cancellation, $caught); }); $this->assertCount(0, $this->spanExporter->getSpans()[0]->getEvents()); From fe6e6bfcbbf85ccc599ec8c9cefdfc2762c8ca57 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:55 +0000 Subject: [PATCH 12/26] Stop OpenTelemetry enrichment after cancellation Exception enrichment could continue through later callbacks after an enricher threw a cleanup error carrying cancellation. Rethrow cancellation chains before logging and continuing past ordinary enricher failures. The original failure remains intact and later enrichers are not invoked for canceled work. Validation: ExceptionInstrumentationTest passes all 12 tests. Regression cases cover direct and wrapped cancellation, later-callback suppression and no emitted record. Targeted source analysis and formatting pass. --- .../ExceptionInstrumentation.php | 8 +++--- .../ExceptionInstrumentationTest.php | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php b/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php index ffdff5e11..45cce0e99 100644 --- a/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Coroutine\Coroutine; use Hypervel\OpenTelemetry\OpenTelemetryManager; use Hypervel\OpenTelemetry\Support\ExceptionContextRegistry; use Hypervel\OpenTelemetry\Support\OperationOrigin; @@ -24,7 +25,6 @@ use OpenTelemetry\SDK\Common\Exception\StackTraceFormatter; use OpenTelemetry\SemConv\Attributes\CodeAttributes; use OpenTelemetry\SemConv\Attributes\ExceptionAttributes; -use Swoole\Coroutine\CanceledException; use Throwable; class ExceptionInstrumentation extends AbstractInstrumentation @@ -200,9 +200,11 @@ protected function record(Throwable $exception): void foreach ($this->openTelemetry->exceptionEnrichers() as $enricher) { try { $enricher($builder, $exception); - } catch (CanceledException $cancellation) { - throw $cancellation; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + self::logError('OpenTelemetry exception enrichment failed.', ['exception' => $throwable]); } } diff --git a/tests/OpenTelemetry/Instrumentation/ExceptionInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/ExceptionInstrumentationTest.php index 7c6791267..9f39b3d6d 100644 --- a/tests/OpenTelemetry/Instrumentation/ExceptionInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/ExceptionInstrumentationTest.php @@ -44,9 +44,12 @@ use OpenTelemetry\SemConv\Attributes\CodeAttributes; use OpenTelemetry\SemConv\Attributes\ExceptionAttributes; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class ExceptionInstrumentationTest extends TestCase { @@ -299,6 +302,29 @@ function (LogRecordBuilderInterface $record): void { $this->assertTrue($attributes->get('enricher.last')); } + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testEnrichmentCancellationStopsLaterCallbacks(bool $wrapped): void + { + $failure = $wrapped ? new RuntimeException('Enricher cleanup failed.', previous: new CanceledException) : new CanceledException; + $continued = false; + $this->instrumentation(enrichers: [ + static fn () => throw $failure, + static function () use (&$continued): void { $continued = true; }, + ])->register($this->options()); + $caught = null; + + try { + $this->handler->report(new RuntimeException('Application failed.')); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse($continued); + $this->assertCount(0, $this->records); + } + public function testMetricsOnlyModeDoesNotEnableDirectLogDeduplication(): void { $this->instrumentation()->register($this->options([ From bd5235707b1c777fc1f23351b973faa2d89b5aeb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:55 +0000 Subject: [PATCH 13/26] Preserve cancellation from HTTP telemetry URL resolvers A URL-template resolver could swallow cleanup-wrapped cancellation and proceed with a physical HTTP request. Use the cancellation predicate at the resolver fallback boundary. Keep terminal error reporting unchanged so a genuine cleanup error remains observable while cancellation stops the request. Validation: HttpClientInstrumentationTest passes all 13 tests. Both cancellation forms prevent the physical send; the wrapped cleanup failure retains its error span and duration metric. Targeted source analysis and formatting pass. --- .../HttpClientInstrumentation.php | 7 +++-- .../HttpClientInstrumentationTest.php | 28 ++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php b/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php index c415fb28a..a737b5047 100644 --- a/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php @@ -7,6 +7,7 @@ use Closure; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Client\Factory; use Hypervel\Http\Client\PendingRequest; use Hypervel\OpenTelemetry\Context\PsrRequestHeadersSetter; @@ -360,9 +361,11 @@ protected function applyUrlTemplate( 'The OpenTelemetry URL-template resolver must return a string or null.', ); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + self::logError('OpenTelemetry URL-template resolution failed.', ['exception' => $exception]); return; diff --git a/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php index 5b003664a..7f31230d8 100644 --- a/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php @@ -48,8 +48,10 @@ use OpenTelemetry\SemConv\Incubating\Attributes\HttpIncubatingAttributes; use OpenTelemetry\SemConv\Incubating\Attributes\UrlIncubatingAttributes; use OpenTelemetry\SemConv\Metrics\HttpMetrics; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class HttpClientInstrumentationTest extends TestCase { @@ -323,9 +325,11 @@ public function testPromiseCancellationPropagatesWithoutCompletionTelemetry(): v $this->assertSame(2_000_000_000, $this->clock->timestamp); } - public function testUrlTemplateResolverCancellationStopsBeforeThePhysicalSend(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testUrlTemplateResolverCancellationStopsBeforeThePhysicalSend(bool $wrapped): void { - $cancellation = new CanceledException; + $cancellation = $wrapped ? new RuntimeException('Resolver cleanup failed.', previous: new CanceledException) : new CanceledException; $physicalSends = 0; $this->urlTemplateResolver = static function () use ($cancellation): never { throw $cancellation; @@ -337,17 +341,27 @@ public function testUrlTemplateResolverCancellationStopsBeforeThePhysicalSend(): }); $this->instrumentation()->register($this->options()); + $caught = null; + try { $this->factory->get('https://example.test'); - $this->fail('The resolver cancellation was not rethrown.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + $this->assertSame($cancellation, $caught); $this->assertSame(0, $physicalSends); - $this->assertSame([], $this->spanExporter->getSpans()); + $spans = $this->spanExporter->getSpans(); + $this->assertCount($wrapped ? 1 : 0, $spans); + + if ($wrapped) { + // Cancellation stops the request, but its real cleanup failure remains observable. + $this->assertSame(StatusCode::STATUS_ERROR, $spans[0]->getStatus()->getCode()); + $this->assertSame(RuntimeException::class, $spans[0]->getAttributes()->get(ErrorAttributes::ERROR_TYPE)); + } + $this->metricReader->collect(); - $this->assertSame([], $this->metric(HttpMetrics::HTTP_CLIENT_REQUEST_DURATION)->data->dataPoints); + $this->assertCount($wrapped ? 1 : 0, $this->metric(HttpMetrics::HTTP_CLIENT_REQUEST_DURATION)->data->dataPoints); } public function testManualModeTracesOnlyMarkedRequestsWhileMetricsRemainIndependent(): void From 297fe00d3cb58957103c6a264dfc3f6c6b88df8f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:55 +0000 Subject: [PATCH 14/26] Propagate cancellation from Redis telemetry query resolvers Redis query-text resolution could hide cancellation behind a cleanup error and emit misleading completion telemetry. Recognize cancellation chains before applying the existing ordinary-error logging and omitted-query fallback. Leave normal command telemetry and resolver error isolation unchanged. Validation: RedisInstrumentationTest passes all 11 tests, including direct and wrapped resolver cancellation with no completion telemetry. Targeted source analysis and formatting pass. --- .../src/Instrumentation/RedisInstrumentation.php | 8 +++++--- .../Instrumentation/RedisInstrumentationTest.php | 15 ++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php b/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php index be5382717..a79c21a04 100644 --- a/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php +++ b/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php @@ -5,6 +5,7 @@ namespace Hypervel\OpenTelemetry\Instrumentation; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\OpenTelemetry\OpenTelemetryManager; use Hypervel\OpenTelemetry\Support\ExceptionContextRegistry; use Hypervel\OpenTelemetry\Support\OperationOrigin; @@ -26,7 +27,6 @@ use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Incubating\Attributes\DbIncubatingAttributes; use OpenTelemetry\SemConv\Metrics\DbMetrics; -use Swoole\Coroutine\CanceledException; use Throwable; use UnexpectedValueException; @@ -257,9 +257,11 @@ protected function queryText( 'The OpenTelemetry Redis query-text resolver must return a string or null.', ); } - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + self::logError('OpenTelemetry Redis query-text resolution failed.', ['exception' => $exception]); return null; diff --git a/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php index ebc0b6fd8..3090f03f6 100644 --- a/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php +++ b/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php @@ -42,8 +42,10 @@ use OpenTelemetry\SemConv\Attributes\ErrorAttributes; use OpenTelemetry\SemConv\Attributes\ExceptionAttributes; use OpenTelemetry\SemConv\Metrics\DbMetrics; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class RedisInstrumentationTest extends TestCase { @@ -263,21 +265,24 @@ public function testResolverFailureIsIsolatedFromCommandTelemetry(): void $this->assertCount(1, $this->metric(DbMetrics::DB_CLIENT_OPERATION_DURATION)->data->dataPoints); } - public function testResolverCancellationPropagatesWithoutCompletionTelemetry(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testResolverCancellationPropagatesWithoutCompletionTelemetry(bool $wrapped): void { - $cancellation = new CanceledException; + $cancellation = $wrapped ? new RuntimeException('Resolver cleanup failed.', previous: new CanceledException) : new CanceledException; $resolver = static function () use ($cancellation): never { throw $cancellation; }; $this->instrumentation(resolver: $resolver)->register($this->options()); + $caught = null; try { $this->events->dispatch(new CommandExecuted('GET', ['customer:1'], 1, $this->connection())); - $this->fail('Expected the resolver cancellation to propagate.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + $this->assertSame($cancellation, $caught); $this->assertCount(0, $this->spanExporter->getSpans()); $this->metricReader->collect(); $this->assertCount(0, $this->metric(DbMetrics::DB_CLIENT_OPERATION_DURATION)->data->dataPoints); From 64fc32f68313b5edf96c1d409dabd7fb48e25c4f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:27:56 +0000 Subject: [PATCH 15/26] Preserve cancellation during telemetry user resolution A user-context resolver's cleanup failure could be treated as an ordinary lookup error even when the operation was canceled. Use the shared predicate before recording a resolver failure. Propagate the original cancellation chain and retain the existing ordinary-error fallback. Validation: both UserContextResolverTest cases pass, exercising direct and wrapped cancellation from the configured resolver. Targeted source analysis and formatting pass. --- .../src/Support/UserContextResolver.php | 8 +++++--- .../Support/UserContextResolverTest.php | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/opentelemetry/src/Support/UserContextResolver.php b/src/opentelemetry/src/Support/UserContextResolver.php index f70276a48..2d9c591ab 100644 --- a/src/opentelemetry/src/Support/UserContextResolver.php +++ b/src/opentelemetry/src/Support/UserContextResolver.php @@ -5,10 +5,10 @@ namespace Hypervel\OpenTelemetry\Support; use Hypervel\Contracts\Auth\Factory; +use Hypervel\Coroutine\Coroutine; use Hypervel\OpenTelemetry\OpenTelemetryManager; use OpenTelemetry\API\Behavior\LogsMessagesTrait; use OpenTelemetry\SemConv\Incubating\Attributes\UserIncubatingAttributes; -use Swoole\Coroutine\CanceledException; use Throwable; use Traversable; use UnexpectedValueException; @@ -68,9 +68,11 @@ public function resolve(RequestTelemetryState $state): array } $state->userAttributes = $attributes; - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + self::logError('OpenTelemetry user-context resolution failed.', ['exception' => $exception]); } diff --git a/tests/OpenTelemetry/Support/UserContextResolverTest.php b/tests/OpenTelemetry/Support/UserContextResolverTest.php index d22596c9c..231e2ca0d 100644 --- a/tests/OpenTelemetry/Support/UserContextResolverTest.php +++ b/tests/OpenTelemetry/Support/UserContextResolverTest.php @@ -13,13 +13,18 @@ use Hypervel\OpenTelemetry\Support\UserContextResolver; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class UserContextResolverTest extends TestCase { - public function testCustomResolverRethrowsCoroutineCancellation(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCustomResolverRethrowsCoroutineCancellation(bool $wrapped): void { - $cancellation = new CanceledException; + $cancellation = $wrapped ? new RuntimeException('Resolver cleanup failed.', previous: new CanceledException) : new CanceledException; $user = m::mock(Authenticatable::class); $guard = m::mock(Guard::class); $guard->shouldReceive('hasUser')->once()->andReturnTrue(); @@ -42,11 +47,14 @@ public function testCustomResolverRethrowsCoroutineCancellation(): void false, ); + $caught = null; + try { $resolver->resolve($state); - $this->fail('Expected the user-context resolver to propagate coroutine cancellation.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($cancellation, $caught); } } From add5a8775d8bd677f675a9510a6b7329ee6dda1b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:28:25 +0000 Subject: [PATCH 16/26] Preserve cancellation from Sentry application callbacks Breadcrumb and sampling callbacks could replace cancellation with a cleanup failure, causing the Hub to log the error and continue. Use the cancellation predicate before the existing callback-error fallback in breadcrumb, trace-sampler and profile-sampler handling. Keep configured sampling fallback and ordinary callback error logging unchanged. Validation: HubTest passes all 14 tests with explicit direct and wrapped cancellation rows for each callback. Targeted source analysis and formatting pass. --- src/sentry/src/Hub.php | 21 +++++++++++++-------- tests/Sentry/HubTest.php | 30 ++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/sentry/src/Hub.php b/src/sentry/src/Hub.php index 26dd0816b..976e5393c 100644 --- a/src/sentry/src/Hub.php +++ b/src/sentry/src/Hub.php @@ -7,6 +7,7 @@ use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Coroutine\Coroutine; use Psr\Log\NullLogger; use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; @@ -26,7 +27,6 @@ use Sentry\Tracing\Span; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; -use Swoole\Coroutine\CanceledException; use Throwable; use function sprintf; @@ -197,10 +197,11 @@ public function addBreadcrumb(Breadcrumb $breadcrumb): bool try { $breadcrumb = $beforeBreadcrumbCallback($breadcrumb); - } catch (CanceledException $exception) { - // Application callbacks may perform cancellable I/O. - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $options->getLoggerOrNullLogger()->error(sprintf('The "before_breadcrumb" callback failed with exception: "%s".', $exception->getMessage())); return false; @@ -283,9 +284,11 @@ public function startTransaction(TransactionContext $context, array $customSampl try { $sampleRate = $tracesSampler($samplingContext); $sampleSource = 'config:traces_sampler'; - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $logger->error(sprintf('The "traces_sampler" callback failed with exception: "%s".', $exception->getMessage())); $sampleRate = $options->getTracesSampleRate() ?? 0; $sampleSource = 'config:traces_sampler_error_fallback'; @@ -379,9 +382,11 @@ public function startTransaction(TransactionContext $context, array $customSampl try { $profilesSampleRate = $profilesSampler($samplingContext); $profilesSampleSource = 'config:profiles_sampler'; - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $logger->error(sprintf('The "profiles_sampler" callback failed with exception: "%s".', $exception->getMessage())); $profilesSampleRate = $options->getProfilesSampleRate() ?? 0; $profilesSampleSource = 'config:profiles_sampler_error_fallback'; diff --git a/tests/Sentry/HubTest.php b/tests/Sentry/HubTest.php index e97e43d43..49550f2f8 100644 --- a/tests/Sentry/HubTest.php +++ b/tests/Sentry/HubTest.php @@ -24,6 +24,7 @@ use Sentry\Tracing\TransactionContext; use Swoole\Coroutine\CanceledException; use Swoole\Coroutine\Channel; +use Throwable; class HubTest extends TestCase { @@ -197,21 +198,28 @@ public function testFailingSamplersFallBackToTheConfiguredRates(): void } #[DataProvider('cancellableCallbacks')] - public function testCallbackCancellationPropagatesUnchanged(string $option, Closure $action): void + public function testCallbackCancellationPropagatesUnchanged(string $option, Closure $action, bool $wrapped): void { $cancellation = new CanceledException('Canceled.'); + + if ($wrapped) { + $cancellation = new RuntimeException('Callback cleanup failed.', previous: $cancellation); + } + $client = m::mock(ClientInterface::class); $client->shouldReceive('getOptions')->andReturn(new Options([ 'traces_sample_rate' => 1.0, $option => static fn (): never => throw $cancellation, ])); + $caught = null; try { $action(new Hub($client)); - $this->fail('The callback cancellation was not propagated.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($cancellation, $caught); } /** @@ -221,13 +229,19 @@ public static function cancellableCallbacks(): array { $startTransaction = static fn (Hub $hub): Transaction => $hub->startTransaction(new TransactionContext('test')); - return [ + $callbacks = [ 'before_breadcrumb' => ['before_breadcrumb', static fn (Hub $hub): bool => $hub->addBreadcrumb( new Breadcrumb(Breadcrumb::LEVEL_INFO, Breadcrumb::TYPE_DEFAULT, 'test') - )], - 'traces_sampler' => ['traces_sampler', $startTransaction], - 'profiles_sampler' => ['profiles_sampler', $startTransaction], + ), false], + 'traces_sampler' => ['traces_sampler', $startTransaction, false], + 'profiles_sampler' => ['profiles_sampler', $startTransaction, false], ]; + + foreach ($callbacks as $name => [$option, $action]) { + $callbacks[$name . ' with cleanup failure'] = [$option, $action, true]; + } + + return $callbacks; } /** From f5aeaca13da328395bb2a44e6b1acca323958325 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:28:25 +0000 Subject: [PATCH 17/26] Propagate cancellation through Sentry event handlers Application accessors invoked by Sentry event handling can perform cancellable work. A cleanup exception carrying cancellation was ignored as an ordinary instrumentation failure. Use the shared predicate at the event-dispatch catch, preserving ordinary failure isolation and the original cancellation exception chain. Validation: AuthEventsTest passes all 12 tests, including direct and wrapped cancellation through a user email accessor. Targeted source analysis and formatting pass. --- src/sentry/src/EventHandler.php | 10 ++++----- tests/Sentry/EventHandler/AuthEventsTest.php | 22 +++++++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/sentry/src/EventHandler.php b/src/sentry/src/EventHandler.php index 0b9a1143f..9aa1e6563 100644 --- a/src/sentry/src/EventHandler.php +++ b/src/sentry/src/EventHandler.php @@ -29,7 +29,6 @@ use Sentry\DataCollection\DataCollectionPolicy; use Sentry\SentrySdk; use Sentry\State\Scope; -use Swoole\Coroutine\CanceledException; use Throwable; class EventHandler @@ -142,10 +141,11 @@ public function __call(string $method, array $arguments): void try { $this->{$handlerMethod}(...$arguments); - } catch (CanceledException $exception) { - // Cancellation from application accessors or callbacks must propagate. - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // Ignore } } diff --git a/tests/Sentry/EventHandler/AuthEventsTest.php b/tests/Sentry/EventHandler/AuthEventsTest.php index 5efaa4063..94a03234c 100644 --- a/tests/Sentry/EventHandler/AuthEventsTest.php +++ b/tests/Sentry/EventHandler/AuthEventsTest.php @@ -12,7 +12,10 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; use Hypervel\Tests\Sentry\TestCase; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; class AuthEventsTest extends TestCase { @@ -59,26 +62,35 @@ public function testAuthenticatedEventInAChildCoroutineUsesTheInheritedRequestAd $this->assertSame('203.0.113.10', $ipAddress); } - public function testAuthenticatedEventPropagatesCancellationFromUserAccessor(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testAuthenticatedEventPropagatesCancellationFromUserAccessor(bool $wrapped): void { $cancellation = new CanceledException('Request canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('Accessor cleanup failed.', previous: $cancellation); + } + $user = new class extends AuthEventsTestUserModel { /** * Simulate cancellation during an email accessor's I/O. */ - public function getEmailAttribute(CanceledException $value): never + public function getEmailAttribute(Throwable $value): never { throw $value; } }; $user->forceFill(['email' => $cancellation]); + $caught = null; try { $this->dispatchHypervelEvent(new Authenticated('test', $user)); - $this->fail('The event handler swallowed coroutine cancellation.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($cancellation, $caught); } public function testAuthenticatedEventFillsUserOnScopeWhenUsernameIsNotAString(): void From f209a398321b3781bb148d848100352881713ba2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:28:25 +0000 Subject: [PATCH 18/26] Preserve cancellation in Sentry tracing event dispatch Tracing event dispatch ignored cleanup failures containing cancellation and could continue an interrupted operation. Recognize cancellation chains at the existing event-handler catch. Ordinary instrumentation errors remain isolated from application execution. Validation: tracing EventHandlerTest passes all nine tests, including direct and wrapped cancellation and ordinary instrumentation failure handling. Targeted source analysis and formatting pass. --- src/sentry/src/Tracing/EventHandler.php | 10 +++++----- tests/Sentry/Tracing/EventHandlerTest.php | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/sentry/src/Tracing/EventHandler.php b/src/sentry/src/Tracing/EventHandler.php index 3a0233d18..564aca890 100644 --- a/src/sentry/src/Tracing/EventHandler.php +++ b/src/sentry/src/Tracing/EventHandler.php @@ -18,7 +18,6 @@ use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanStatus; -use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -113,10 +112,11 @@ public function __call(string $method, array $arguments): void try { $this->{$handlerMethod}(...$arguments); - } catch (CanceledException $exception) { - // Cancellation during instrumentation, such as origin resolution's file reads, must propagate. - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // Ignore to prevent bubbling up errors in the SDK } } diff --git a/tests/Sentry/Tracing/EventHandlerTest.php b/tests/Sentry/Tracing/EventHandlerTest.php index 42d31d58d..11c1cf606 100644 --- a/tests/Sentry/Tracing/EventHandlerTest.php +++ b/tests/Sentry/Tracing/EventHandlerTest.php @@ -20,6 +20,7 @@ use Hypervel\Tests\Sentry\TestCase; use Mockery as m; use PDO; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; use Sentry\SentrySdk; @@ -28,6 +29,7 @@ use Swoole\Coroutine\CanceledException; use Swoole\Coroutine\Channel; use Symfony\Component\HttpFoundation\Response; +use Throwable; class EventHandlerTest extends TestCase { @@ -147,21 +149,31 @@ public function testThrowableFromInstrumentationDoesNotReachApplicationCode(): v $this->addToAssertionCount(1); } - public function testCancellationFromInstrumentationReachesApplicationCode(): void + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'wrapped cancellation')] + public function testCancellationFromInstrumentationReachesApplicationCode(bool $wrapped): void { $this->startTransaction(); $cancellation = new CanceledException('Request canceled'); + + if ($wrapped) { + $cancellation = new RuntimeException('Instrumentation cleanup failed.', previous: $cancellation); + } + $connection = m::mock(Connection::class); $connection->shouldReceive('getName')->once()->andReturn('canceled'); $connection->shouldReceive('getDatabaseName')->once()->andThrow($cancellation); $handler = new EventHandler(config()->array('sentry.tracing')); + $caught = null; + try { $handler->queryExecuted(new QueryExecuted('select 1', [], 1.0, $connection)); - $this->fail('The tracing event handler swallowed coroutine cancellation.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); + } catch (Throwable $exception) { + $caught = $exception; } + + $this->assertSame($cancellation, $caught); } public function testCoroutineExitFinishesOnlyAbandonedResponseAndTransactionSpans(): void From 5bea4a5978ce8a604c9316ab4c610355dbd492c4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:28:26 +0000 Subject: [PATCH 19/26] Propagate cancellation from Sentry AI tool schema hooks User-defined tool schemas may perform cancellable work. Cleanup-wrapped cancellation was treated as an ignorable schema resolution failure. Use the shared cancellation predicate before retaining the existing ordinary schema-error fallback. No schema invocation or successful tracing behavior changes. Validation: the two focused AiIntegration cases pass, verifying that ordinary schema failures remain isolated while wrapped cancellation escapes tracing. Targeted source analysis and formatting pass. --- src/sentry/src/Features/AiIntegration.php | 9 +++--- tests/Sentry/Features/AiIntegrationTest.php | 36 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/sentry/src/Features/AiIntegration.php b/src/sentry/src/Features/AiIntegration.php index 6c3548068..aaf1597fb 100644 --- a/src/sentry/src/Features/AiIntegration.php +++ b/src/sentry/src/Features/AiIntegration.php @@ -47,6 +47,7 @@ use Hypervel\Ai\Responses\Data\ToolResult; use Hypervel\Ai\Responses\TextResponse; use Hypervel\Ai\Tools\ToolNameResolver; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\UploadedFile; use Hypervel\JsonSchema\JsonSchemaTypeFactory; use Hypervel\JsonSchema\Types\ObjectType; @@ -65,7 +66,6 @@ use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanStatus; -use Swoole\Coroutine\CanceledException; use Throwable; use WeakReference; @@ -919,10 +919,11 @@ private function resolveToolDefinition(Tool $tool): array $objectType = new ObjectType($properties); $definition['parameters'] = $objectType->toArray(); } - } catch (CanceledException $exception) { - // User-defined schema hooks may perform cancellable I/O. - throw $exception; } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // Ignore schema resolution failures. } diff --git a/tests/Sentry/Features/AiIntegrationTest.php b/tests/Sentry/Features/AiIntegrationTest.php index c59f773e4..196da889b 100644 --- a/tests/Sentry/Features/AiIntegrationTest.php +++ b/tests/Sentry/Features/AiIntegrationTest.php @@ -155,12 +155,15 @@ public function handle(Request $request): string use JsonSerializable; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanStatus; use Sentry\Tracing\Transaction; use stdClass; +use Swoole\Coroutine\CanceledException; +use Throwable; class AiIntegrationTest extends TestCase { @@ -666,6 +669,39 @@ public function testStructuredToolPayloadsAreBoundedWithoutCuttingTheirJson(): v $this->assertCount(1, $output); } + #[TestWith([false], 'ordinary schema failure')] + #[TestWith([true], 'wrapped cancellation')] + public function testToolSchemaCancellationEscapesTracing(bool $canceled): void + { + $this->resetApplicationWithConfig(['sentry.send_default_pii' => true]); + $this->startTransaction(); + [$prompt] = $this->makePromptAndResponse(); + $failure = new RuntimeException('Schema failed.', previous: $canceled ? new CanceledException : null); + $tool = m::mock(WeatherLookup::class)->makePartial(); + $tool->shouldReceive('schema')->once()->andThrow($failure); + $this->dispatchHypervelEvent(new PromptingAgent('schema', $prompt)); + $caught = null; + + try { + $this->dispatchHypervelEvent(new StartingStep( + 'schema', + 0, + $prompt->agent, + $prompt->provider, + $prompt->model, + false, + [new UserMessage('Hello')], + new TextGenerationOptions, + 'Instructions', + [$tool], + )); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($canceled ? $failure : null, $caught); + } + public function testResolvedStepMetadataIsCollectedOnceAndToolDefinitionsAreBounded(): void { $this->resetApplicationWithConfig(['sentry.send_default_pii' => true]); From 6ae89a388ef7c2df78ce61dd51f8c015df1fd7c1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:28:26 +0000 Subject: [PATCH 20/26] Preserve cancellation during Sentry session key lookup Session-store resolution during cache tracing caught every failure, including cancellation raised by application bindings or cleanup. Rethrow cancellation chains before the existing missing-session fallback. Preserve the reentry guard's cleanup and ordinary command tracing when session resolution fails normally. Validation: both focused CacheIntegration cases pass for ordinary resolution failure and wrapped cancellation. Targeted source analysis and formatting pass. --- src/sentry/src/Features/CacheIntegration.php | 6 ++++- .../Sentry/Features/CacheIntegrationTest.php | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/sentry/src/Features/CacheIntegration.php b/src/sentry/src/Features/CacheIntegration.php index 58bcd4e2c..f70309c49 100644 --- a/src/sentry/src/Features/CacheIntegration.php +++ b/src/sentry/src/Features/CacheIntegration.php @@ -444,7 +444,11 @@ private function getSessionKey(): ?string } finally { CoroutineContext::forget(self::SESSION_KEY_RESOLUTION_CONTEXT_KEY); } - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + // We can assume the session store is not available here so there is no session key to retrieve // We capture a generic exception to avoid breaking the application because some code paths can // result in an exception other than the expected `BindingResolutionException` diff --git a/tests/Sentry/Features/CacheIntegrationTest.php b/tests/Sentry/Features/CacheIntegrationTest.php index 80df893dd..d10d4044e 100644 --- a/tests/Sentry/Features/CacheIntegrationTest.php +++ b/tests/Sentry/Features/CacheIntegrationTest.php @@ -39,6 +39,8 @@ use Sentry\State\HubInterface; use Sentry\Tracing\Span; use Sentry\Tracing\SpanStatus; +use Swoole\Coroutine\CanceledException; +use Throwable; class CacheIntegrationTest extends TestCase { @@ -659,15 +661,30 @@ public function testRedisSessionKeyFallbackIsReentrySafe(): void $this->assertSame('GET {sessionKey}', $spans[2]->getDescription()); } - public function testRedisSessionResolutionThrowableDoesNotBreakCommandTracing(): void + #[TestWith([false], 'ordinary resolution failure')] + #[TestWith([true], 'wrapped cancellation')] + public function testRedisSessionResolutionFailureStopsTracingOnlyForCancellation(bool $canceled): void { $this->registerObservedRedisPool(); - $this->app->bind('session.store', static fn (): never => throw new Error('Session resolution failed.')); + $failure = $canceled + ? new RuntimeException('Session cleanup failed.', previous: new CanceledException) + : new Error('Session resolution failed.'); + $this->app->bind('session.store', static fn (): never => throw $failure); $transaction = $this->startTransaction(); - $this->dispatchHypervelEvent(new CommandExecuted('GET', ['test-key'], 0.005, $this->createRedisConnection('default'))); + $caught = null; - $this->assertSame('GET test-key', $transaction->getSpanRecorder()->getSpans()[1]->getDescription()); + try { + $this->dispatchHypervelEvent(new CommandExecuted('GET', ['test-key'], 0.005, $this->createRedisConnection('default'))); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($canceled ? $failure : null, $caught); + + if (! $canceled) { + $this->assertSame('GET test-key', $transaction->getSpanRecorder()->getSpans()[1]->getDescription()); + } } public function testRedisCommandWithoutParentSpanDoesNotCreateSpan(): void From 2bb529b52a6757fed978ac2cf2e56d128b6fdb52 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:13 +0000 Subject: [PATCH 21/26] Preserve cancellation during Telescope value serialization An application serializer's cancellation could be replaced with Telescope's purged-value fallback. Check the exception chain before purging serialization failures. Preserve ordinary malformed-value handling while allowing canceled application work to stop. Validation: ExtractPropertiesTest passes all five tests, including a throwing serializer carrying cancellation and the existing ordinary-failure purge behavior. Targeted source analysis and formatting pass. --- src/telescope/src/JsonNormalizer.php | 9 ++++++-- tests/Telescope/ExtractPropertiesTest.php | 25 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/telescope/src/JsonNormalizer.php b/src/telescope/src/JsonNormalizer.php index 35cefaeea..fbbcf8993 100644 --- a/src/telescope/src/JsonNormalizer.php +++ b/src/telescope/src/JsonNormalizer.php @@ -4,13 +4,14 @@ namespace Hypervel\Telescope; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Json; use Throwable; class JsonNormalizer { /** - * Normalize an observed value without allowing serialization errors to escape. + * Normalize an observed value, purging serialization failures except for cancellation. */ public static function normalize(mixed $value): mixed { @@ -26,7 +27,11 @@ public static function normalize(mixed $value): mixed } return Json::decode($json); - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return Telescope::PURGED_VALUE; } } diff --git a/tests/Telescope/ExtractPropertiesTest.php b/tests/Telescope/ExtractPropertiesTest.php index 833da8bba..69eedcc93 100644 --- a/tests/Telescope/ExtractPropertiesTest.php +++ b/tests/Telescope/ExtractPropertiesTest.php @@ -9,6 +9,8 @@ use Hypervel\Tests\TestCase; use JsonSerializable; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; class ExtractPropertiesTest extends TestCase { @@ -54,6 +56,20 @@ public function testThrowingSerializersArePurged(): void ], $properties['value']); } + public function testSerializationCancellationEscapesInsteadOfPurgingTheValue(): void + { + $failure = new RuntimeException('Serialization cleanup failed.', previous: new CanceledException); + $caught = null; + + try { + ExtractProperties::from(new PropertyTarget(new ThrowingJsonSerializable($failure))); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + } + private function nestedValue(int $depth): array { $value = 'leaf'; @@ -75,8 +91,15 @@ public function __construct(public mixed $value) class ThrowingJsonSerializable implements JsonSerializable { + /** + * Supply a failure from an application's serializer. + */ + public function __construct(private ?Throwable $failure = null) + { + } + public function jsonSerialize(): mixed { - throw new RuntimeException('Serialization failed.'); + throw $this->failure ?? new RuntimeException('Serialization failed.'); } } From bfb8284d9ab726bf6157d7d19973cf88f01cbe95 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:13 +0000 Subject: [PATCH 22/26] Stop Telescope monitored-tag fallback after cancellation Loading monitored tags could hide cancellation and replace the existing tags with an empty list. Rethrow cancellation before applying the ordinary storage-error fallback. Preserve existing tags when the load is interrupted and keep the empty fallback for ordinary failures. Validation: the two cancellation/fallback rows and neighboring unique-tag storage test pass on SQLite. Targeted source analysis and formatting pass. --- .../src/Storage/DatabaseEntriesRepository.php | 7 +++++- .../Storage/DatabaseEntriesRepositoryTest.php | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/telescope/src/Storage/DatabaseEntriesRepository.php b/src/telescope/src/Storage/DatabaseEntriesRepository.php index 6a942030d..03c0b4256 100644 --- a/src/telescope/src/Storage/DatabaseEntriesRepository.php +++ b/src/telescope/src/Storage/DatabaseEntriesRepository.php @@ -6,6 +6,7 @@ use DateTimeInterface; use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\Query\Builder; use Hypervel\Database\UniqueConstraintViolationException; @@ -365,7 +366,11 @@ public function loadMonitoredTags(): void { try { $this->setMonitorTags($this->monitoring()); - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + $this->setMonitorTags([]); } } diff --git a/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php b/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php index 931f46b8e..292ea8fb7 100644 --- a/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php +++ b/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php @@ -17,6 +17,11 @@ use Hypervel\Telescope\Storage\EntryQueryOptions; use Hypervel\Tests\Telescope\FeatureTestCase; use JsonException; +use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; use TypeError; class DatabaseEntriesRepositoryTest extends FeatureTestCase @@ -126,6 +131,26 @@ public function testMonitorStoresEveryUniqueNewTag(): void ); } + #[TestWith([false], 'ordinary storage failure')] + #[TestWith([true], 'wrapped cancellation')] + public function testMonitoredTagFallbackPreservesCancellation(bool $canceled): void + { + $failure = new RuntimeException('Monitoring failed.', previous: $canceled ? new CanceledException : null); + $repository = m::mock(DatabaseEntriesRepository::class, ['testing'])->makePartial(); + $repository->shouldReceive('monitoring')->once()->andThrow($failure); + $repository->setMonitorTags(['existing']); + $caught = null; + + try { + $repository->loadMonitoredTags(); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($canceled ? $failure : null, $caught); + $this->assertSame($canceled ? ['existing'] : [], $repository->getMonitorTags()); + } + public function testPruneOrdersDeletesToAvoidDeadlocks(): void { EntryModelFactory::new()->create(['created_at' => now()->subDays(2)]); From 7ced71872c19ddb0cca20bb249a41a9aa9f2b997 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:13 +0000 Subject: [PATCH 23/26] Preserve cancellation while starting and recording Telescope entries Recording-state cache lookups and application user accessors could swallow cancellation, enabling recording or queuing entries after the operation had stopped. Recognize cancellation chains at both existing fallback catches. Preserve ordinary lookup-error behavior and the coroutine-local recording state. Validation: six focused SQLite cases pass, covering pause lookup, user resolution, later recording after callback failures and command-start recording. Targeted source analysis and formatting pass. --- src/telescope/src/Telescope.php | 9 ++++- tests/Telescope/Telescope/TelescopeTest.php | 45 +++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/telescope/src/Telescope.php b/src/telescope/src/Telescope.php index 73aef5ed1..0ed6bc19b 100644 --- a/src/telescope/src/Telescope.php +++ b/src/telescope/src/Telescope.php @@ -242,7 +242,10 @@ public static function startRecording(): void $recordingPaused = static::withoutRecording( fn () => cache('telescope:pause-recording', false) ); - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } } CoroutineContext::set(static::SHOULD_RECORD_CONTEXT_KEY, ! $recordingPaused); @@ -318,6 +321,10 @@ protected static function record(string $type, IncomingEntry $entry): void $entry->user(Auth::user()); } } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + // Do nothing. } diff --git a/tests/Telescope/Telescope/TelescopeTest.php b/tests/Telescope/Telescope/TelescopeTest.php index 908a6cb6b..e51703142 100644 --- a/tests/Telescope/Telescope/TelescopeTest.php +++ b/tests/Telescope/Telescope/TelescopeTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Telescope\Telescope; +use Hypervel\Cache\CacheManager; use Hypervel\Console\Command; use Hypervel\Console\Events\BeforeHandle; use Hypervel\Console\Events\ScheduledTaskStarting; @@ -17,6 +18,7 @@ use Hypervel\Http\Request; use Hypervel\HttpServer\Events\RequestReceived; use Hypervel\Log\Events\MessageLogged; +use Hypervel\Support\Facades\Auth; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Storage\EntryModel; @@ -27,7 +29,9 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Input\ArrayInput; +use Throwable; #[WithConfig('telescope.watchers', [ QueryWatcher::class => [ @@ -110,6 +114,25 @@ public function testAfterRecordingCallbackCanStoreAndFlush(): void $this->assertCount(1, Telescope::getEntriesQueue()); } + public function testRecordingPreservesCancellationFromUserResolution(): void + { + $failure = new RuntimeException('User lookup cleanup failed.', previous: new CanceledException); + $original = Auth::getFacadeRoot(); + Auth::shouldReceive('hasUser')->once()->andThrow($failure); + $caught = null; + + try { + Telescope::recordLog(IncomingEntry::make(['message' => 'entry'])); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + Auth::swap($original); + } + + $this->assertSame($failure, $caught); + $this->assertSame([], Telescope::getEntriesQueue()); + } + public function testThrowingTagCallbackDoesNotPoisonLaterRecording(): void { Telescope::tag(fn () => throw new RuntimeException('tag failed')); @@ -274,6 +297,28 @@ public function testFlushStateClearsShouldListenCallback(): void $this->assertTrue(Telescope::shouldListen()); } + public function testStartingRecordingPreservesCacheCancellation(): void + { + Telescope::stopRecording(); + $failure = new RuntimeException('Pause lookup cleanup failed.', previous: new CanceledException); + $original = $this->app->make('cache'); + $cache = m::mock(CacheManager::class); + $cache->shouldReceive('get')->once()->with('telescope:pause-recording', false)->andThrow($failure); + $this->app->instance('cache', $cache); + $caught = null; + + try { + Telescope::startRecording(); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + $this->app->instance('cache', $original); + } + + $this->assertSame($failure, $caught); + $this->assertFalse(Telescope::isRecording()); + } + public function testResolvedCommandStartsRecording(): void { Telescope::stopRecording(); From 829b8aaf537ff1d9df6c02b1574e197d0a9ab292 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:14 +0000 Subject: [PATCH 24/26] Propagate cancellation from Telescope HTTP body producers Telescope could turn cancellation from a request or response body producer into a purged payload and continue recording. Rethrow cancellation chains before the existing payload-error fallback. Preserve owned stream-position restoration and ordinary unreadable-payload handling. Validation: request-body and cached-response cancellation cases and the neighboring raw payload test pass on SQLite. Targeted source analysis and formatting pass. --- .../src/Watchers/ClientRequestWatcher.php | 9 ++++++ .../Watchers/ClientRequestWatcherTest.php | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/telescope/src/Watchers/ClientRequestWatcher.php b/src/telescope/src/Watchers/ClientRequestWatcher.php index 8f3b7854a..3ef69d447 100644 --- a/src/telescope/src/Watchers/ClientRequestWatcher.php +++ b/src/telescope/src/Watchers/ClientRequestWatcher.php @@ -10,6 +10,7 @@ use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\TransferStats; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Client\PendingRequest; use Hypervel\Support\Json; use Hypervel\Support\Str; @@ -292,6 +293,10 @@ protected function getRequestPayload(RequestInterface $request): array|string return $content; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return Telescope::PURGED_VALUE . ': ' . $e->getMessage(); } finally { if ($stream->isSeekable()) { @@ -369,6 +374,10 @@ protected function getResponsePayload(ResponseInterface $response): array|string return 'Empty Response'; } } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return Telescope::PURGED_VALUE . ': ' . $e->getMessage(); } finally { if ($stream->isSeekable()) { diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php index 7454c9e6b..cc790e260 100644 --- a/tests/Telescope/Watchers/ClientRequestWatcherTest.php +++ b/tests/Telescope/Watchers/ClientRequestWatcherTest.php @@ -11,6 +11,8 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\CachingStream; +use GuzzleHttp\Psr7\PumpStream; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\TransferStats; @@ -25,8 +27,11 @@ use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Psr\Http\Message\RequestInterface; use RuntimeException; +use Swoole\Coroutine\CanceledException; +use Throwable; enum ClientRequestWatcherTestIntTag: int { @@ -287,6 +292,29 @@ public function testRawBodyUsesThePsrPayloadFallback(): void ], $entry->content['payload']); } + #[TestWith([false], 'request body')] + #[TestWith([true], 'cached response body')] + public function testBodyProducerCancellationEscapesRecording(bool $response): void + { + $failure = new RuntimeException('Body cleanup failed.', previous: new CanceledException); + $body = new PumpStream(static fn () => throw $failure); + $client = $this->makeClient([ + new Response(200, ['Content-Type' => 'text/plain'], $response ? new CachingStream($body) : 'ok'), + ]); + $request = new Request('POST', 'https://example.com', [], $response ? '' : $body); + $caught = null; + + try { + $this->executeTransfer($client, $request); + } catch (Throwable $exception) { + $caught = $exception; + } finally { + $body->close(); + } + + $this->assertSame($failure, $caught); + } + public function testRawJsonRequestRetainsAndMasksAtTheEntryContentChildLimit(): void { Telescope::hideRequestParameters(['password']); From d76309f6b010c414ffc41a9ec35056313c2c7ed7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:14 +0000 Subject: [PATCH 25/26] Preserve cancellation during Telescope dump recording checks A canceled cache lookup for the dump-watcher flag was treated as an unavailable flag and delegated the dump as normal work. Use the shared cancellation predicate before returning the existing false fallback. Ordinary cache failures still delegate; cancellation propagates without recording or delegation. Validation: both focused DumpWatcher fallback cases pass on SQLite. The complete file also passed at the integration checkpoint. Targeted source analysis and formatting pass. --- src/telescope/src/Watchers/DumpWatcher.php | 7 ++++++- tests/Telescope/Watchers/DumpWatcherTest.php | 21 ++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/telescope/src/Watchers/DumpWatcher.php b/src/telescope/src/Watchers/DumpWatcher.php index c882d32a0..f5f53e1a0 100644 --- a/src/telescope/src/Watchers/DumpWatcher.php +++ b/src/telescope/src/Watchers/DumpWatcher.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Coroutine\Coroutine; use Hypervel\Telescope\IncomingDumpEntry; use Hypervel\Telescope\Telescope; use Symfony\Component\VarDumper\Cloner\VarCloner; @@ -82,7 +83,11 @@ protected function shouldRecordDump(): bool try { return (bool) $this->cache->store()->get('telescope:dump-watcher'); - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return false; } } diff --git a/tests/Telescope/Watchers/DumpWatcherTest.php b/tests/Telescope/Watchers/DumpWatcherTest.php index 04cbcdee7..2a14e836a 100644 --- a/tests/Telescope/Watchers/DumpWatcherTest.php +++ b/tests/Telescope/Watchers/DumpWatcherTest.php @@ -12,9 +12,12 @@ use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; use RuntimeException; +use Swoole\Coroutine\CanceledException; use Symfony\Component\VarDumper\VarDumper; +use Throwable; #[WithConfig('telescope.watchers', [ DumpWatcher::class => true, @@ -77,8 +80,11 @@ public function testDumpDelegatesWithoutResolvingCacheWhenTelescopeIsNotRecordin $this->assertSame([], Telescope::getEntriesQueue()); } - public function testCacheFailureDelegatesTheDump(): void + #[TestWith([false], 'ordinary cache failure')] + #[TestWith([true], 'wrapped cancellation')] + public function testCacheFailureDelegatesTheDumpUnlessCanceled(bool $canceled): void { + $failure = new RuntimeException('Cache unavailable.', previous: $canceled ? new CanceledException : null); $cache = m::mock(CacheFactory::class); $repository = m::mock(CacheRepository::class); $cache->shouldReceive('store') @@ -87,7 +93,7 @@ public function testCacheFailureDelegatesTheDump(): void $repository->shouldReceive('get') ->once() ->with('telescope:dump-watcher') - ->andThrow(new RuntimeException('Cache unavailable.')); + ->andThrow($failure); $delegated = []; $this->installWatcher( $cache, @@ -96,9 +102,16 @@ public function testCacheFailureDelegatesTheDump(): void }, ); - VarDumper::dump('cache-failure', 'failure-label'); + $caught = null; - $this->assertSame([['cache-failure', 'failure-label']], $delegated); + try { + VarDumper::dump('cache-failure', 'failure-label'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($canceled ? $failure : null, $caught); + $this->assertSame($canceled ? [] : [['cache-failure', 'failure-label']], $delegated); $this->assertCount(0, $this->loadTelescopeEntries()); } From b10c3bcccc917df5a26e331035863f5b0458a480 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:14 +0000 Subject: [PATCH 26/26] Remove the completed cancellation audit task The requested audit of cancellation-sensitive callback, fallback and I/O catches is complete across the framework and integration groups. Remove its completed task while retaining the separate proposal to investigate a focused PHPStan rule. The owner-approved Google SDK cause-loss limitation remains represented by its skipped regression and separate handoff. Validation: checked the remaining todo entries and the final reviewed audit scope. --- docs/todo.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index 1fc9323f5..e352267f8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -32,7 +32,6 @@ - Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again. - Add PHPStan Eloquent extensions that preserve `Eloquent\Builder` for non-passthrough methods forwarded to `Query\Builder`, and expose model named scopes on Eloquent builders and relations. The query-builder mixin currently gives fluent calls the wrong builder type, while named scopes are treated as nonexistent methods; these gaps force scopes to split mutation from return and leave `HasDatabaseNotifications` with `method.notFound` suppressions. This will be the repository's first PHPStan extension, so use Larastan as prior art and wire the extensions into `phpstan.neon.dist` without maintaining duplicate query-method or scope lists in `@method` annotations. - Investigate static analysis that turns the coroutine-safety and worker-lifetime rules in `AGENTS.md` into automated checks, so mistakes no longer depend on agent steering and review alone. Decide which tool fits each check: PHPStan rules for detection with type information, Rector in dry-run mode, or another linter. Start with property writes outside the constructor on container-resolved classes (auto-singletons and singletons) that are not `Transient`, scoped, or execution-scoped, since per-request data stored there leaks to concurrent requests in the same worker. Intentional worker-lifetime state such as manager driver caches, registries, and resolved configuration needs an explicit way to opt out, so measure false positives across `src/` before choosing that mechanism. Then evaluate other patterns: static property writes outside boot code and `flushState()`; static caches keyed by request-derived values; request state captured in constructors of classes that are not execution-scoped; `Config::set()` during request handling; child coroutines started inside `DB::transaction()`, which borrow their own pooled connection and therefore run outside the transaction; and raw `sleep()`/`usleep()` in source instead of `Sleep`. Ship the useful rules so applications and private packages can enable the same checks. -- Audit remaining cancellation catches in callback, fallback and I/O paths for cleanup failures that hide cancellation in an exception chain. Trace supported execution paths, fix confirmed cases that retry or continue work after cancellation, and add focused regression tests. Preserve valid native-only checks, cleanup-error reporting and foreign-cancellation ownership; an exact exception check alone is not evidence of a defect. - Investigate a focused PHPStan rule for cancellation checks that miss cleanup exception chains. Detect bare `CanceledException` checks around callbacks or listeners, including `releaseIdleConnections()`, where `Coroutine::causedByCancellation()` is needed to prevent retries or continued work after cancellation. Use the cancellation regression cases to assess detection and false positives; preserve valid native-only checks, cleanup-error reporting and foreign-cancellation boundaries. Integrate useful checks with the coroutine-safety analysis above rather than attempting to infer every ownership decision automatically. - Audit typed input accessors across `InteractsWithData`, Support `DataObject`, and Hypervel Data. Define the accepted integer, float, and boolean forms for each public contract. Extract a neutral Support conversion primitive only if `InteractsWithData` and `DataObject` converge on exactly the same strict semantics, and decide separately whether Hypervel Data should adopt those semantics rather than changing its tested permissive casts as a side effect.