diff --git a/docs/todo.md b/docs/todo.md index 1fc9323f59..e352267f81 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. diff --git a/src/ai/src/Files/UntrustedUrl.php b/src/ai/src/Files/UntrustedUrl.php index 0a5c9d6900..b0806499f2 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/src/ai/src/Gateway/Concerns/InvokesTools.php b/src/ai/src/Gateway/Concerns/InvokesTools.php index d8cc88e575..65bdccf5a7 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 9ce51b3aca..52270d4ead 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/Middleware/RememberConversation.php b/src/ai/src/Middleware/RememberConversation.php index 7adadd05c8..e448c6da2e 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/Providers/Concerns/GeneratesText.php b/src/ai/src/Providers/Concerns/GeneratesText.php index 71dd1f1d3d..2ea0713471 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 4a9f61614e..c0d6f8c252 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/src/ai/src/Providers/Provider.php b/src/ai/src/Providers/Provider.php index 3e57aa3c5a..c5e3e1c60d 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/src/ai/src/Responses/StreamableAgentResponse.php b/src/ai/src/Responses/StreamableAgentResponse.php index ae63ec13ae..19132c7dbd 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/src/ai/src/Support/PendingConversationTitle.php b/src/ai/src/Support/PendingConversationTitle.php index 3abb8c621f..86aa7f8c2e 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/src/mcp/src/Client/ClientManager.php b/src/mcp/src/Client/ClientManager.php index f2f4424e6e..b322b7175f 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/src/mcp/src/Client/Protocol.php b/src/mcp/src/Client/Protocol.php index adb1fee453..1822b7ab15 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/src/mcp/src/Client/Transport/HttpTransport.php b/src/mcp/src/Client/Transport/HttpTransport.php index fc80dfc177..dc16b62dcf 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/src/mcp/src/Client/Transport/StdioTransport.php b/src/mcp/src/Client/Transport/StdioTransport.php index f31da23a00..3455cfa8bc 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/src/mcp/src/Server.php b/src/mcp/src/Server.php index 389d0e3ca3..17fde4a626 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 2f768669fa..428390b619 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 ddac84d4b9..e14781b664 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/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php b/src/opentelemetry/src/Instrumentation/CacheInstrumentation.php index ba425967e8..1a5c31cf6c 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/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php b/src/opentelemetry/src/Instrumentation/ExceptionInstrumentation.php index ffdff5e11f..45cce0e991 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/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php b/src/opentelemetry/src/Instrumentation/HttpClientInstrumentation.php index c415fb28a8..a737b50478 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/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php b/src/opentelemetry/src/Instrumentation/RedisInstrumentation.php index be5382717e..a79c21a04b 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/src/opentelemetry/src/Support/UserContextResolver.php b/src/opentelemetry/src/Support/UserContextResolver.php index f70276a485..2d9c591ab4 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/src/sentry/src/EventHandler.php b/src/sentry/src/EventHandler.php index 0b9a1143fb..9aa1e65637 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/src/sentry/src/Features/AiIntegration.php b/src/sentry/src/Features/AiIntegration.php index 6c35480688..aaf1597fba 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/src/sentry/src/Features/CacheIntegration.php b/src/sentry/src/Features/CacheIntegration.php index 58bcd4e2cb..f70309c499 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/src/sentry/src/Hub.php b/src/sentry/src/Hub.php index 26dd0816b0..976e5393c2 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/src/sentry/src/Tracing/EventHandler.php b/src/sentry/src/Tracing/EventHandler.php index 3a0233d18e..564aca8902 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/src/telescope/src/JsonNormalizer.php b/src/telescope/src/JsonNormalizer.php index 35cefaeea4..fbbcf8993a 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/src/telescope/src/Storage/DatabaseEntriesRepository.php b/src/telescope/src/Storage/DatabaseEntriesRepository.php index 6a942030d3..03c0b42565 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/src/telescope/src/Telescope.php b/src/telescope/src/Telescope.php index 73aef5ed18..0ed6bc19be 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/src/telescope/src/Watchers/ClientRequestWatcher.php b/src/telescope/src/Watchers/ClientRequestWatcher.php index 8f3b7854af..3ef69d4472 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/src/telescope/src/Watchers/DumpWatcher.php b/src/telescope/src/Watchers/DumpWatcher.php index c882d32a01..f5f53e1a03 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/Ai/ConcurrentToolsTest.php b/tests/Ai/ConcurrentToolsTest.php index 232a9248a1..b2da4596ee 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 03e5593f6c..236375204e 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(); diff --git a/tests/Ai/Feature/Files/UntrustedUrlTest.php b/tests/Ai/Feature/Files/UntrustedUrlTest.php index a388297f49..5682d1ff82 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); } } diff --git a/tests/Ai/Feature/OperationEventsTest.php b/tests/Ai/Feature/OperationEventsTest.php index f4659cf5e9..ab99408266 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 { diff --git a/tests/Ai/Feature/RememberConversationTest.php b/tests/Ai/Feature/RememberConversationTest.php index dcf0163926..3c3058c5d1 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. */ diff --git a/tests/Ai/Unit/Responses/StreamableAgentResponseTest.php b/tests/Ai/Unit/Responses/StreamableAgentResponseTest.php index c19742ee5b..2869802f06 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 { diff --git a/tests/Mcp/Unit/Client/ClientManagerTest.php b/tests/Mcp/Unit/Client/ClientManagerTest.php index 441d803bc9..ef1c4b5024 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); diff --git a/tests/Mcp/Unit/Client/HandshakeTest.php b/tests/Mcp/Unit/Client/HandshakeTest.php index 0a1b682fc7..f5274579f1 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; } diff --git a/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php b/tests/Mcp/Unit/Client/Transport/HttpTransportTest.php index 2eda1c2315..fd6e4a89e9 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); diff --git a/tests/Mcp/Unit/Client/Transport/StdioTransportTest.php b/tests/Mcp/Unit/Client/Transport/StdioTransportTest.php index e3436f0045..ea0e6f5568 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; } diff --git a/tests/Mcp/Unit/Methods/CallToolTest.php b/tests/Mcp/Unit/Methods/CallToolTest.php index b3352a4554..3786f28f0b 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 3d6343667a..1469e2d06c 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 69a00ac947..787116ec2a 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. diff --git a/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/CacheInstrumentationTest.php index 0af91bd712..16a1c1f7e1 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()); diff --git a/tests/OpenTelemetry/Instrumentation/ExceptionInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/ExceptionInstrumentationTest.php index 7c6791267d..9f39b3d6d0 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([ diff --git a/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/HttpClientInstrumentationTest.php index 5b003664a8..7f31230d89 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 diff --git a/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php b/tests/OpenTelemetry/Instrumentation/RedisInstrumentationTest.php index ebc0b6fd87..3090f03f6d 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); diff --git a/tests/OpenTelemetry/Support/UserContextResolverTest.php b/tests/OpenTelemetry/Support/UserContextResolverTest.php index d22596c9c9..231e2ca0d5 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); } } diff --git a/tests/Sentry/EventHandler/AuthEventsTest.php b/tests/Sentry/EventHandler/AuthEventsTest.php index 5efaa40635..94a03234ca 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 diff --git a/tests/Sentry/Features/AiIntegrationTest.php b/tests/Sentry/Features/AiIntegrationTest.php index c59f773e4d..196da889b1 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]); diff --git a/tests/Sentry/Features/CacheIntegrationTest.php b/tests/Sentry/Features/CacheIntegrationTest.php index 80df893dda..d10d4044e0 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 diff --git a/tests/Sentry/HubTest.php b/tests/Sentry/HubTest.php index e97e43d431..49550f2f89 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; } /** diff --git a/tests/Sentry/Tracing/EventHandlerTest.php b/tests/Sentry/Tracing/EventHandlerTest.php index 42d31d58d0..11c1cf6067 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 diff --git a/tests/Telescope/ExtractPropertiesTest.php b/tests/Telescope/ExtractPropertiesTest.php index 833da8bba1..69eedcc93d 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.'); } } diff --git a/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php b/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php index 931f46b8ec..292ea8fb7e 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)]); diff --git a/tests/Telescope/Telescope/TelescopeTest.php b/tests/Telescope/Telescope/TelescopeTest.php index 908a6cb6b4..e517031423 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(); diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php index 7454c9e6ba..cc790e2605 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']); diff --git a/tests/Telescope/Watchers/DumpWatcherTest.php b/tests/Telescope/Watchers/DumpWatcherTest.php index 04cbcdee7b..2a14e836a8 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()); }