From 4e4207dc46a4415b4c0d4cd597efc8d0257af720 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 05:26:37 +0000
Subject: [PATCH 01/22] Import Laravel AI SDK documentation
---
src/docs/ai-sdk.md | 3759 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 3759 insertions(+)
create mode 100644 src/docs/ai-sdk.md
diff --git a/src/docs/ai-sdk.md b/src/docs/ai-sdk.md
new file mode 100644
index 000000000..aeba9db67
--- /dev/null
+++ b/src/docs/ai-sdk.md
@@ -0,0 +1,3759 @@
+# Laravel AI SDK
+
+- [Introduction](#introduction)
+- [Installation](#installation)
+ - [Configuration](#configuration)
+ - [Custom Base URLs](#custom-base-urls)
+ - [OpenAI-Compatible Providers](#openai-compatible-providers)
+ - [On-Demand Providers](#on-demand-providers)
+ - [Provider Support](#provider-support)
+- [Agents](#agents)
+ - [Prompting](#prompting)
+ - [Conversation Context](#conversation-context)
+ - [Structured Output](#structured-output)
+ - [Attachments](#attachments)
+ - [Streaming](#streaming)
+ - [Broadcasting](#broadcasting)
+ - [Queueing](#queueing)
+ - [Tools](#tools)
+ - [Deferred Tool Loading](#deferred-tool-loading)
+ - [File Storage Tools](#file-storage-tools)
+ - [MCP Tools](#mcp-tools)
+ - [Skills](#skills)
+ - [Provider Tools](#provider-tools)
+ - [Sub-Agents](#sub-agents)
+ - [Middleware](#middleware)
+ - [Anonymous Agents](#anonymous-agents)
+ - [Agent Configuration](#agent-configuration)
+ - [Provider Options](#provider-options)
+ - [Prompt Caching](#prompt-caching)
+- [Human Tool Approval](#human-tool-approval)
+ - [Complete Approval Flow](#complete-approval-flow)
+- [Images](#images)
+- [Audio (TTS)](#audio)
+- [Transcription (STT)](#transcription)
+- [Text Summarization](#text-summarization)
+- [Embeddings](#embeddings)
+ - [Multimodal Embeddings](#multimodal-embeddings)
+ - [Querying Embeddings](#querying-embeddings)
+ - [Caching Embeddings](#caching-embeddings)
+- [Reranking](#reranking)
+- [Classification](#classification)
+ - [Yes or No Decisions](#yes-or-no-decisions)
+ - [Choosing From Collections](#choosing-from-collections)
+- [Files](#files)
+- [Vector Stores](#vector-stores)
+ - [Adding Files to Stores](#adding-files-to-stores)
+- [Usage](#usage)
+- [Failover](#failover)
+- [Testing](#testing)
+ - [Agents](#testing-agents)
+ - [Images](#testing-images)
+ - [Audio](#testing-audio)
+ - [Transcriptions](#testing-transcriptions)
+ - [Embeddings](#testing-embeddings)
+ - [Reranking](#testing-reranking)
+ - [Classification](#testing-classification)
+ - [Files](#testing-files)
+ - [Vector Stores](#testing-vector-stores)
+- [Events](#events)
+
+
+## Introduction
+
+The [Laravel AI SDK](https://github.com/laravel/ai) provides a unified, expressive API for interacting with AI providers such as OpenAI, Anthropic, Gemini, and more. With the AI SDK, you can build intelligent agents with tools and structured output, generate images, synthesize and transcribe audio, create vector embeddings, and much more — all using a consistent, Laravel-friendly interface.
+
+
+## Installation
+
+You can install the Laravel AI SDK via Composer:
+
+```shell
+composer require laravel/ai
+```
+
+Next, you should publish the AI SDK configuration and migration files using the `vendor:publish` Artisan command:
+
+```shell
+php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
+```
+
+Finally, you should run your application's database migrations. This will create a `agent_conversations` and `agent_conversation_messages` table that the AI SDK uses to power its conversation storage:
+
+```shell
+php artisan migrate
+```
+
+
+### Configuration
+
+You may define your AI provider credentials in your application's `config/ai.php` configuration file or as environment variables in your application's `.env` file:
+
+```ini
+ANTHROPIC_API_KEY=
+AZURE_OPENAI_API_KEY=
+COHERE_API_KEY=
+DEEPSEEK_API_KEY=
+ELEVENLABS_API_KEY=
+GEMINI_API_KEY=
+GROQ_API_KEY=
+MISTRAL_API_KEY=
+OLLAMA_API_KEY=
+OPENAI_API_KEY=
+OPENAI_COMPATIBLE_API_KEY=
+OPENAI_COMPATIBLE_URL=
+OPENROUTER_API_KEY=
+JINA_API_KEY=
+TYPESAFE_API_KEY=
+VOYAGEAI_API_KEY=
+XAI_API_KEY=
+```
+
+The default models used for text, images, audio, transcription, and embeddings may also be configured in your application's `config/ai.php` configuration file.
+
+
+### Custom Base URLs
+
+By default, the Laravel AI SDK connects directly to each provider's public API endpoint. However, you may need to route requests through a different endpoint - for example, when using a proxy service to centralize API key management, implement rate limiting, or route traffic through a corporate gateway.
+
+You may configure custom base URLs by adding a `url` parameter to your provider configuration:
+
+```php
+'providers' => [
+ 'openai' => [
+ 'driver' => 'openai',
+ 'key' => env('OPENAI_API_KEY'),
+ 'url' => env('OPENAI_URL'),
+ ],
+
+ 'anthropic' => [
+ 'driver' => 'anthropic',
+ 'key' => env('ANTHROPIC_API_KEY'),
+ 'url' => env('ANTHROPIC_BASE_URL'),
+ ],
+],
+```
+
+This is useful when routing requests through a proxy service (such as LiteLLM or Azure OpenAI Gateway) or using alternative endpoints.
+
+Custom base URLs are supported for the following providers: OpenAI, Anthropic, Gemini, Groq, Cohere, DeepSeek, xAI, and OpenRouter.
+
+
+### OpenAI-Compatible Providers
+
+If you are using an OpenAI-compatible API, such as LM Studio, vLLM, Together, Fireworks, or a local gateway, you may configure an `openai-compatible` provider. The `url` option is required, while the `key` option is optional and will be sent as a bearer token when present:
+
+```php
+'providers' => [
+ 'local' => [
+ 'driver' => 'openai-compatible',
+ 'url' => env('LOCAL_AI_URL'),
+ 'key' => env('LOCAL_AI_API_KEY'),
+ ],
+],
+```
+
+Once configured, you may use the named provider like any other provider:
+
+```php
+agent()->prompt('What is Laravel?', provider: 'local', model: 'local-model');
+```
+
+You may also configure a default text model for the provider so that you do not need to pass a model explicitly:
+
+```php
+'local' => [
+ 'driver' => 'openai-compatible',
+ 'url' => env('LOCAL_AI_URL'),
+ 'key' => env('LOCAL_AI_API_KEY'),
+ 'models' => [
+ 'text' => [
+ 'default' => env('LOCAL_AI_MODEL'),
+ ],
+ ],
+],
+```
+
+You may add custom HTTP headers to every outgoing request for the provider by defining a `headers` array in its configuration. This is useful when an endpoint requires an additional identifying or authentication header beyond the bearer token:
+
+```php
+'local' => [
+ 'driver' => 'openai-compatible',
+ 'url' => env('LOCAL_AI_URL'),
+ 'key' => env('LOCAL_AI_API_KEY'),
+ 'headers' => [
+ 'X-Tenant-Id' => env('LOCAL_AI_TENANT_ID'),
+ ],
+],
+```
+
+OpenAI-compatible providers support text generation, streaming, tools, structured output, image attachments, embeddings, and transcription. If your endpoint requires additional request body fields, provide them using [provider options](#provider-options).
+
+
+#### OpenAI-Compatible Embeddings
+
+Since arbitrary endpoints have no known models, you must configure a default embeddings model to use `embeddings()` with an OpenAI-compatible provider. You may also configure a fixed dimensions value; if omitted, the request is sent without a `dimensions` parameter and the model's native dimensions are used.
+
+```php
+'local' => [
+ 'driver' => 'openai-compatible',
+ 'url' => env('LOCAL_AI_URL'),
+ 'key' => env('LOCAL_AI_API_KEY'),
+ 'models' => [
+ 'embeddings' => [
+ 'default' => 'text-embedding-qwen3-embedding-0.6b',
+ 'dimensions' => 1024, // optional
+ ],
+ ],
+],
+```
+
+
+#### OpenAI-Compatible Transcriptions
+
+Likewise, you must configure a default transcription model to use `Transcription` with an OpenAI-compatible provider. The audio will be uploaded to the endpoint's `/audio/transcriptions` route as a standard multipart request:
+
+```php
+'local' => [
+ 'driver' => 'openai-compatible',
+ 'url' => env('LOCAL_AI_URL'),
+ 'key' => env('LOCAL_AI_API_KEY'),
+ 'models' => [
+ 'transcription' => [
+ 'default' => 'whisper-1',
+ ],
+ ],
+],
+```
+
+> [!NOTE]
+> OpenAI-compatible and Groq providers do not support diarization. Invoking the `diarize` method when using these providers will throw an exception.
+
+
+### On-Demand Providers
+
+Sometimes you may need to use provider credentials that are not defined in your application's configuration file, such as API keys that are stored in your database for each tenant of a multi-tenant application. You may use the `Ai::build` method to create a provider from a configuration array. The array should have the same structure as a provider entry in your application's `config/ai.php` configuration file:
+
+```php
+use App\Ai\Agents\SalesCoach;
+use Laravel\Ai\Ai;
+
+$response = (new SalesCoach)->prompt('Analyze this sales transcript...', provider: [
+ Ai::build([
+ 'driver' => 'anthropic',
+ 'key' => $user->anthropic_key,
+ ]),
+]);
+```
+
+On-demand providers may be used anywhere a provider is accepted, including [failover](#failover) lists and when generating images, audio, transcriptions, and embeddings. To specify a model for an on-demand provider, include a `models` array in its configuration:
+
+```php
+$response = (new SalesCoach)->prompt('Analyze this sales transcript...', provider: [
+ Ai::build(['driver' => 'anthropic', 'key' => $user->anthropic_key]),
+ Ai::build([
+ 'driver' => 'openai',
+ 'key' => $user->openai_key,
+ 'models' => ['text' => ['default' => 'gpt-6']],
+ ]),
+]);
+```
+
+When an agent should always use an on-demand provider, you may return the provider from the agent's `provider` method. Since the provider is rebuilt when the agent is used, this approach also works well for [queued](#queueing) agents:
+
+```php
+use Laravel\Ai\Ai;
+use Laravel\Ai\Providers\Provider;
+
+public function provider(): Provider
+{
+ return Ai::build($this->user->aiConfiguration());
+}
+```
+
+> [!NOTE]
+> You should pass on-demand providers within an array, as shown in the examples above. If you give an on-demand provider a `name` in its configuration array, the name may not match a built-in provider or a provider defined in your `config/ai.php` configuration file.
+
+
+### Provider Support
+
+The AI SDK supports a variety of providers across its features. The following table summarizes which providers are available for each feature:
+
+
+
+The `Laravel\Ai\Enums\Lab` enum may be used to reference providers throughout your code instead of using plain strings:
+
+```php
+use Laravel\Ai\Enums\Lab;
+
+Lab::Anthropic;
+Lab::OpenAI;
+Lab::OpenAiCompatible;
+Lab::Gemini;
+// ...
+```
+
+
+## Agents
+
+Agents are the fundamental building block for interacting with AI providers in the Laravel AI SDK. Each agent is a dedicated PHP class that encapsulates the instructions, conversation context, tools, and output schema needed to interact with a large language model. Think of an agent as a specialized assistant — a sales coach, a document analyzer, a support bot — that you configure once and prompt as needed throughout your application.
+
+You can create an agent via the `make:agent` Artisan command:
+
+```shell
+php artisan make:agent SalesCoach
+
+php artisan make:agent SalesCoach --structured
+```
+
+Within the generated agent class, you can define the system prompt / instructions, message context, available tools, and output schema (if applicable):
+
+```php
+user->id)
+ ->latest()
+ ->limit(50)
+ ->get()
+ ->reverse()
+ ->map(function ($message) {
+ return new Message($message->role, $message->content);
+ })->all();
+ }
+
+ /**
+ * Get the tools available to the agent.
+ *
+ * @return Tool[]
+ */
+ public function tools(): iterable
+ {
+ return [
+ new RetrievePreviousTranscripts,
+ ];
+ }
+
+ /**
+ * Get the agent's structured output schema definition.
+ */
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'feedback' => $schema->string()->required(),
+ 'score' => $schema->integer()->min(1)->max(10)->required(),
+ ];
+ }
+}
+```
+
+
+### Prompting
+
+To prompt an agent, first create an instance using the `make` method or standard instantiation, then call `prompt`:
+
+```php
+$response = (new SalesCoach)
+ ->prompt('Analyze this sales transcript...');
+
+return (string) $response;
+```
+
+The `make` method resolves your agent from the container, allowing automatic dependency injection. You may also pass arguments to the agent's constructor:
+
+```php
+$agent = SalesCoach::make(user: $user);
+```
+
+By passing additional arguments to the `prompt` method, you may override the default provider, model, or HTTP timeout when prompting:
+
+```php
+$response = (new SalesCoach)->prompt(
+ 'Analyze this sales transcript...',
+ provider: Lab::Anthropic,
+ model: 'claude-sonnet-5',
+ timeout: 120,
+);
+```
+
+
+#### Raw HTTP Responses
+
+Every response returned from a text-generating agent exposes the raw HTTP response from the underlying provider API call via a `raw` property. This gives you access to provider-specific information that isn't part of the AI SDK's generic response - rate-limit headers, request IDs, or other exact payload fields:
+
+```php
+$response = (new SalesCoach)->prompt('Analyze this sales transcript...');
+
+$response->raw; // Illuminate\Http\Client\Response|null
+
+$response->raw->header('X-RateLimit-Remaining-Requests');
+$response->raw->json('id');
+```
+
+In a tool-call loop, each step retains the raw response of its own request:
+
+```php
+foreach ($response->steps as $step) {
+ $step->raw?->header('X-RateLimit-Remaining-Requests');
+}
+```
+
+> **Note:** The `raw` property is `null` when streaming a response, when using the Bedrock provider (which performs its API calls via the AWS SDK instead of an HTTP client), and on faked responses unless one is provided explicitly via `withRawResponse`.
+
+
+### Conversation Context
+
+If your agent implements the `Conversational` interface, you may use the `messages` method to return the previous conversation context, if applicable:
+
+```php
+/**
+ * Get the list of messages comprising the conversation so far.
+ */
+public function messages(): iterable
+{
+ return $this->user->history()
+ ->latest()
+ ->limit(50)
+ ->get()
+ ->reverse()
+ ->map(fn ($message) => new Message(
+ $message->role, $message->content,
+ ))->all();
+}
+```
+
+If your agent does not implement the `Conversational` interface, you may use the `withMessages` method to provide the conversation history for a single run, such as a history posted by your application's frontend:
+
+```php
+use Laravel\Ai\Messages\Message;
+
+$response = (new SalesCoach)
+ ->withMessages([
+ new Message('user', 'Analyze this sales transcript...'),
+ new Message('assistant', 'The rep never asked for the close.'),
+ ])
+ ->prompt('What should they say next time?');
+```
+
+Agents that implement the `Conversational` interface load their own history, so combining the two approaches will throw a `LogicException`.
+
+
+#### Remembering Conversations
+
+> **Warning:** Before using the `RemembersConversations` trait, you should publish and run the AI SDK migrations using the `vendor:publish` Artisan command. These migrations will create the necessary database tables to store conversations.
+
+If you would like Laravel to automatically store and retrieve conversation history for your agent, you may use the `RemembersConversations` trait. This trait provides a simple way to persist conversation messages to the database without manually implementing the `Conversational` interface:
+
+```php
+forUser($user)->prompt('Hello!');
+
+$conversationId = $response->conversationId;
+```
+
+The conversation ID is returned on the response and can be stored for future reference. If you would like to retrieve all of a user's conversations using Eloquent, you may add the `HasConversations` trait to your user model:
+
+```php
+conversations()
+ ->latest('updated_at')
+ ->paginate(20);
+```
+
+To continue an existing conversation, use the `continue` method:
+
+```php
+$response = (new SalesCoach)
+ ->continue($conversationId, as: $user)
+ ->prompt('Tell me more about that.');
+```
+
+The `continueOrStart` method may be used to continue the given conversation, or start a new conversation if the given ID is `null`:
+
+```php
+$response = (new SalesCoach)
+ ->continueOrStart($conversationId, as: $user)
+ ->prompt('Hello!');
+```
+
+When using the `RemembersConversations` trait, previous messages are automatically loaded and included in the conversation context when prompting. New messages (both user and assistant) are automatically stored after each interaction. Each response also contains the IDs of the conversation and messages that were stored:
+
+```php
+$response->conversationId;
+$response->userMessageId;
+$response->assistantMessageId;
+```
+
+
+#### Conversation Participants
+
+Although users are the most common conversation participants, conversations may belong to any Eloquent model. Use the `forParticipant` method to start a conversation for another type of model:
+
+```php
+$response = (new SalesCoach)
+ ->forParticipant($team)
+ ->prompt('Review our latest sales results.');
+```
+
+The participant's morph class and primary key are stored with the conversation. Therefore, models of different types that have the same primary key, such as `User` ID `1` and `Team` ID `1`, have separate conversation histories. The `forUser` method is an alias for `forParticipant`.
+
+You may continue the participant's most recent conversation with the agent using the `continueLastConversation` method. Conversations are scoped to the agent, so only conversations that the agent participated in will be continued:
+
+```php
+$response = (new SalesCoach)
+ ->continueLastConversation($team)
+ ->prompt('Tell me more about that.');
+```
+
+When continuing a specific conversation, pass the participant to the `continue` method:
+
+```php
+$response = (new SalesCoach)
+ ->continue($conversationId, as: $team)
+ ->prompt('Tell me more about that.');
+```
+
+The `HasConversations` trait may be added to any Eloquent model that participates in conversations. The resulting `conversations` relationship is a polymorphic relationship scoped to that model's type and primary key. You may also access the participant that owns a conversation through its inverse relationship:
+
+```php
+$conversations = $team->conversations;
+
+$participant = $conversation->participant;
+```
+
+If your application uses multiple participant model types, you should consider defining an [Eloquent morph map](/docs/{{version}}/eloquent-relationships#custom-polymorphic-types) so that stored participant types are not coupled to your model class names.
+
+> [!WARNING]
+> The `continue` and `continueOrStart` methods do not verify that the given participant owns the conversation. Your application should authorize access to the conversation before continuing it.
+
+
+#### Inspecting Stored Conversations
+
+When displaying a conversation to your users, you often need details such as message IDs, timestamps, and tool calls. You may resolve the conversation store from the service container to read the stored messages without querying the AI SDK's tables directly:
+
+```php
+use Laravel\Ai\Contracts\ConversationStore;
+
+$store = app(ConversationStore::class);
+```
+
+Messages are paginated newest first using a cursor and are returned as `StoredMessage` instances, which contain each message's ID, timestamps, usage, metadata, and attachments:
+
+```php
+$messages = $store->paginateConversationMessages($conversationId, perPage: 25);
+
+foreach ($messages as $message) {
+ $message->id;
+ $message->role;
+ $message->content;
+ $message->createdAt;
+ $message->usage;
+ $message->status;
+}
+```
+
+Each turn, which consists of a user prompt and the assistant's reply, is stored as a list of steps. A step is a single request to the provider, so a turn in which the model calls tools will contain several steps. Each tool result is recorded on the tool call that produced it. The `toolCalls`, `providerToolCalls`, and `toolResults` methods flatten these steps in order, so you do not need to traverse them yourself:
+
+```php
+$message->steps;
+
+$message->toolCalls();
+$message->providerToolCalls();
+$message->toolResults();
+```
+
+A tool call contains a `result` once it has been executed. Tool calls that contain an `approval_reason` but no `result` are still awaiting a [tool approval](#human-tool-approval).
+
+The `status` property contains a `Laravel\Ai\Enums\MessageStatus` instance. A turn that failed partway through is stored as `Failed` along with the steps it had already completed, so tool calls that ran before the failure remain in the history. When the conversation continues, any tool call without a recorded result is sent to the model marked as interrupted, since Laravel cannot determine whether it ran.
+
+Before continuing a conversation via an ID provided by your application's frontend, you should verify that the conversation was stored for the given participant:
+
+```php
+abort_unless($store->conversationBelongsTo(
+ $conversationId, $user->getMorphClass(), $user->getKey()
+), 403);
+```
+
+If the most recent turn is paused awaiting [tool approval](#human-tool-approval), you may render its pending tool calls after a page reload without resuming the run:
+
+```php
+foreach ($store->pendingApprovalsFor($conversationId) as $approval) {
+ // $approval->id, $approval->tool, $approval->arguments, $approval->reason...
+}
+```
+
+These methods are defined by the `PaginatesConversations`, `VerifiesConversationOwnership`, and `ResolvesPendingApprovals` contracts. The included database store implements all three, while a custom store may implement only the contracts it needs.
+
+
+### Structured Output
+
+If you would like your agent to return structured output, implement the `HasStructuredOutput` interface, which requires that your agent define a `schema` method:
+
+```php
+ $schema->integer()->required(),
+ ];
+ }
+}
+```
+
+When prompting an agent that returns structured output, you can access the returned `StructuredAgentResponse` like an array:
+
+```php
+$response = (new SalesCoach)->prompt('Analyze this sales transcript...');
+
+return $response['score'];
+```
+
+
+#### Nested Objects
+
+To define nested structured output, use the `object` method with a closure:
+
+```php
+ $schema->integer()->required(),
+ 'metadata' => $schema->object(fn ($schema) => [
+ 'confidence' => $schema->string()->enum(['low', 'medium', 'high'])->required(),
+ 'language' => $schema->string()->required(),
+ ])->required(),
+ ];
+ }
+}
+```
+
+
+#### Arrays of Objects
+
+If your agent should return a list of structured items, combine the `array` and `object` methods:
+
+```php
+public function schema(JsonSchema $schema): array
+{
+ return [
+ 'feedback' => $schema->array()
+ ->items(
+ $schema->object(fn ($schema) => [
+ 'comment' => $schema->string()->required(),
+ 'score' => $schema->integer()->required(),
+ ])
+ )
+ ->required(),
+ ];
+}
+```
+
+If a value may match one of several schemas, use the `anyOf` method:
+
+```php
+public function schema(JsonSchema $schema): array
+{
+ return [
+ 'content' => $schema->anyOf([
+ $schema->object(fn ($schema) => [
+ 'type' => $schema->string()->enum(['article'])->required(),
+ 'title' => $schema->string()->required(),
+ ]),
+ $schema->object(fn ($schema) => [
+ 'type' => $schema->string()->enum(['image'])->required(),
+ 'url' => $schema->string()->required(),
+ ]),
+ ])->required(),
+ ];
+}
+```
+
+
+### Attachments
+
+When prompting, you may also pass attachments with the prompt to allow the model to inspect images and documents:
+
+```php
+use App\Ai\Agents\SalesCoach;
+use Laravel\Ai\Files;
+
+$response = (new SalesCoach)->prompt(
+ 'Analyze the attached sales transcript...',
+ attachments: [
+ Files\Document::fromStorage('transcript.pdf'), // Attach a document from a filesystem disk...
+ Files\Document::fromPath('/home/laravel/transcript.md'), // Attach a document from a local path...
+ $request->file('transcript'), // Attach an uploaded file...
+ ]
+);
+```
+
+Likewise, the `Laravel\Ai\Files\Image` class may be used to attach images to a prompt:
+
+```php
+use App\Ai\Agents\ImageAnalyzer;
+use Laravel\Ai\Files;
+
+$response = (new ImageAnalyzer)->prompt(
+ 'What is in this image?',
+ attachments: [
+ Files\Image::fromStorage('photo.jpg'), // Attach an image from a filesystem disk...
+ Files\Image::fromPath('/home/laravel/photo.jpg'), // Attach an image from a local path...
+ $request->file('photo'), // Attach an uploaded file...
+ ]
+);
+```
+
+
+### Streaming
+
+You may stream an agent's response by invoking the `stream` method. The returned `StreamableAgentResponse` may be returned from a route to automatically send a streaming response (SSE) to the client:
+
+```php
+use App\Ai\Agents\SalesCoach;
+
+Route::get('/coach', function () {
+ return (new SalesCoach)->stream('Analyze this sales transcript...');
+});
+```
+
+The `then` method may be used to provide a closure that will be invoked when the entire response has been streamed to the client:
+
+```php
+use App\Ai\Agents\SalesCoach;
+use Laravel\Ai\Responses\StreamedAgentResponse;
+
+Route::get('/coach', function () {
+ return (new SalesCoach)
+ ->stream('Analyze this sales transcript...')
+ ->then(function (StreamedAgentResponse $response) {
+ // $response->text, $response->events, $response->usage...
+ });
+});
+```
+
+Alternatively, you may iterate through the streamed events manually:
+
+```php
+$stream = (new SalesCoach)->stream('Analyze this sales transcript...');
+
+foreach ($stream as $event) {
+ // ...
+}
+```
+
+The response also contains the model's reasoning and any sources it cited. Both are stored with the assistant message when using the `RemembersConversations` trait:
+
+```php
+use Laravel\Ai\Responses\StreamedAgentResponse;
+
+(new SalesCoach)
+ ->stream('Analyze this sales transcript...')
+ ->then(function (StreamedAgentResponse $response) {
+ $response->reasoning; // '' unless the model returned reasoning text...
+ $response->meta->citations;
+ });
+```
+
+Reasoning is also available on responses returned by the `prompt` method.
+
+
+
+#### Stream Protocols
+
+By default, streamed responses use the AI SDK's own event format. However, you may use a frontend streaming protocol instead, which allows you to pair your agent with an existing chat interface rather than building your own.
+
+You may stream the events using the [Vercel AI SDK stream protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol) by invoking the `usingVercelDataProtocol` method on the streamable response:
+
+```php
+use App\Ai\Agents\SalesCoach;
+
+Route::get('/coach', function () {
+ return (new SalesCoach)
+ ->stream('Analyze this sales transcript...')
+ ->usingVercelDataProtocol();
+});
+```
+
+You may pass a message ID if your application's frontend assigns its own:
+
+```php
+->usingVercelDataProtocol($request->string('messageId'));
+```
+
+Alternatively, the `usingAgentUserInteractionProtocol` method may be used to stream using the [Agent User Interaction (AG-UI) protocol](https://docs.ag-ui.com):
+
+```php
+Route::post('/coach', function (Request $request) {
+ return (new SalesCoach)
+ ->forUser($request->user())
+ ->stream($request->string('prompt'))
+ ->usingAgentUserInteractionProtocol();
+});
+```
+
+The `threadId` and `runId` arguments are optional and default to the conversation ID and the invocation ID:
+
+```php
+->usingAgentUserInteractionProtocol(
+ threadId: $request->input('threadId'),
+ runId: $request->input('runId'),
+);
+```
+
+To use a protocol that the AI SDK does not implement, you may pass your own `Laravel\Ai\Streaming\Protocols\StreamProtocol` implementation to the `usingProtocol` method:
+
+```php
+use App\Ai\Protocols\CustomProtocol;
+
+return (new SalesCoach)
+ ->stream('Analyze this sales transcript...')
+ ->usingProtocol(new CustomProtocol);
+```
+
+
+
+#### Frontend Integration
+
+Chat interfaces built with libraries such as Vercel's `useChat` or CopilotKit already render messages, tool calls, and approval prompts, so your application only needs to handle the requests they send. Each request contains the conversation history, the newest user message, and any tool approval responses.
+
+The `Vercel::chat` and `AgentUserInteraction::chat` methods convert such a request into an object that may be passed directly to an agent's `stream` method:
+
+```php
+use Laravel\Ai\Vercel\Vercel;
+
+Route::post('/chat', function (Request $request) {
+ $chat = Vercel::chat($request);
+
+ return (new SupportAgent)
+ ->withMessages($chat->history())
+ ->stream($chat)
+ ->usingProtocol($chat->protocol());
+});
+```
+
+If the request contains [approval decisions](#human-tool-approval), the agent will resume using those decisions. Otherwise, the agent is prompted with the request's newest user message and attachments. The `protocol` method returns the protocol used by the client.
+
+> [!NOTE]
+> Agents that implement the `Conversational` interface load their own history, so the `withMessages` method may be omitted.
+
+The `AgentUserInteraction::chat` method provides the same API for AG-UI clients, in addition to the request's thread and run IDs:
+
+```php
+use Laravel\Ai\AgentUserInteraction\AgentUserInteraction;
+
+$chat = AgentUserInteraction::chat($request);
+
+$chat->threadId();
+$chat->runId();
+```
+
+You may also convert stored messages back into the format a client expects, allowing the client to display a previous conversation, such as after a page reload:
+
+```php
+$messages = $conversation->messages()->oldest()->get();
+
+return ['messages' => Vercel::toUiMessages($messages)];
+```
+
+The `AgentUserInteraction::toClientState` method performs the same conversion for AG-UI clients, in addition to returning any pending approval interrupts:
+
+```php
+return AgentUserInteraction::toClientState($messages);
+```
+
+
+### Broadcasting
+
+You may broadcast streamed events in a few different ways. First, you can simply invoke the `broadcast` or `broadcastNow` method on a streamed event:
+
+```php
+use App\Ai\Agents\SalesCoach;
+use Illuminate\Broadcasting\Channel;
+
+$stream = (new SalesCoach)->stream('Analyze this sales transcript...');
+
+foreach ($stream as $event) {
+ $event->broadcast(new Channel('channel-name'));
+}
+```
+
+Or, you can invoke an agent's `broadcastOnQueue` method to queue the agent operation and broadcast the streamed events as they are available:
+
+```php
+(new SalesCoach)->broadcastOnQueue(
+ 'Analyze this sales transcript...',
+ new Channel('channel-name'),
+);
+```
+
+
+#### Skipping Oversized Events
+
+Some broadcasting platforms limit WebSocket messages to around 10KB. Data-heavy stream events, like large tool results, can exceed this limit and cause broadcasting to fail. You may exclude specific event types from broadcasting using the `WithoutBroadcasting` attribute:
+
+```php
+
+### Queueing
+
+Using an agent's `queue` method, you may prompt the agent, but allow it to process the response in the background, keeping your application feeling fast and responsive. The `then` and `catch` methods may be used to register closures that will be invoked when a response is available or if an exception occurs:
+
+```php
+use Illuminate\Http\Request;
+use Laravel\Ai\Responses\AgentResponse;
+use Throwable;
+
+Route::post('/coach', function (Request $request) {
+ (new SalesCoach)
+ ->queue($request->input('transcript'))
+ ->then(function (AgentResponse $response) {
+ // ...
+ })
+ ->catch(function (Throwable $e) {
+ // ...
+ });
+
+ return back();
+});
+```
+
+
+### Tools
+
+Tools may be used to give agents additional functionality that they can utilize while responding to prompts. Tools can be created using the `make:tool` Artisan command:
+
+```shell
+php artisan make:tool RandomNumberGenerator
+```
+
+The generated tool will be placed in your application's `app/Ai/Tools` directory. Each tool contains a `handle` method that will be invoked by the agent when it needs to utilize the tool:
+
+```php
+ $schema->integer()->min(0)->required(),
+ 'max' => $schema->integer()->required(),
+ ];
+ }
+}
+```
+
+Once you have defined your tool, you may return it from the `tools` method of any of your agents:
+
+```php
+use App\Ai\Tools\RandomNumberGenerator;
+
+/**
+ * Get the tools available to the agent.
+ *
+ * @return Tool[]
+ */
+public function tools(): iterable
+{
+ return [
+ new RandomNumberGenerator,
+ ];
+}
+```
+
+
+#### Runtime Tool Overrides
+
+The `withTools` method may be used to replace the tools declared by an agent instance. This is useful for per-tenant or feature-flagged tool sets:
+
+```php
+$response = (new SupportAgent)
+ ->withTools([new LookupOrder])
+ ->prompt('Where is order 12345?');
+```
+
+You may also pass a closure, which receives the agent's declared tools, allowing you to append to or filter them:
+
+```php
+$response = (new SupportAgent)
+ ->withTools(fn (array $tools) => [...$tools, new LookupOrder])
+ ->prompt('Where is order 12345?');
+```
+
+
+#### Validating Tool Arguments
+
+Although your tool's schema constrains the arguments a model may provide, you may validate the incoming arguments using the request's `validate` method:
+
+```php
+public function handle(Request $request): Stringable|string
+{
+ $validated = $request->validate([
+ 'city' => 'required|string',
+ 'days' => 'required|integer|max:7',
+ ]);
+
+ return $this->forecast($validated['city'], $validated['days']);
+}
+```
+
+When validation fails, the validation messages are returned to the model as the tool's result, allowing it to correct the arguments and call the tool again.
+
+
+#### Repairing Tool Calls
+
+Use the `RepairToolCalls` attribute to let an agent recover when a model calls an unknown local tool. Laravel returns the failed call to the model with the names of the available local tools, allowing it to correct the call:
+
+```php
+use Laravel\Ai\Attributes\RepairToolCalls;
+use Laravel\Ai\Contracts\Agent;
+use Laravel\Ai\Contracts\HasTools;
+use Laravel\Ai\Promptable;
+
+#[RepairToolCalls]
+class SupportAgent implements Agent, HasTools
+{
+ use Promptable;
+
+ // ...
+}
+```
+
+When Laravel derives the maximum number of steps automatically, this attribute adds one step for the repaired call. Explicit `MaxSteps` limits are unchanged.
+
+
+#### Similarity Search
+
+The `SimilaritySearch` tool allows agents to search for documents similar to a given query using vector embeddings stored in your database. This is useful for retrieval-augmented generation (RAG) when you want to give agents access to search your application's data.
+
+The simplest way to create a similarity search tool is using the `usingModel` method with an Eloquent model that has vector embeddings:
+
+```php
+use App\Models\Document;
+use Laravel\Ai\Tools\SimilaritySearch;
+
+public function tools(): iterable
+{
+ return [
+ SimilaritySearch::usingModel(Document::class, 'embedding'),
+ ];
+}
+```
+
+The first argument is the Eloquent model class, and the second argument is the column containing the vector embeddings.
+
+You may also provide a minimum similarity threshold between `0.0` and `1.0` and a closure to customize the query:
+
+```php
+SimilaritySearch::usingModel(
+ model: Document::class,
+ column: 'embedding',
+ minSimilarity: 0.7,
+ limit: 10,
+ query: fn ($query) => $query->where('published', true),
+),
+```
+
+For more control, you may create a similarity search tool with a custom closure that returns the search results:
+
+```php
+use App\Models\Document;
+use Laravel\Ai\Tools\SimilaritySearch;
+
+public function tools(): iterable
+{
+ return [
+ new SimilaritySearch(using: function (string $query) {
+ return Document::query()
+ ->where('user_id', $this->user->id)
+ ->whereVectorSimilarTo('embedding', $query)
+ ->limit(10)
+ ->get();
+ }),
+ ];
+}
+```
+
+You may customize the tool's description using the `withDescription` method:
+
+```php
+SimilaritySearch::usingModel(Document::class, 'embedding')
+ ->withDescription('Search the knowledge base for relevant articles.'),
+```
+
+
+### Deferred Tool Loading
+
+By default, every tool an agent exposes is sent to the provider with each request. When an agent provides a large number of tools, this consumes tokens and may reduce the accuracy of the model's tool selection. Using the `ToolSearch` provider tool with OpenAI or Anthropic, you may defer tool definitions so that the provider only loads them when they are needed:
+
+```php
+use App\Ai\Tools\RefundOrder;
+use App\Ai\Tools\SearchInvoices;
+use App\Ai\Tools\Weather;
+use Laravel\Ai\Providers\Tools\ToolSearch;
+
+public function tools(): iterable
+{
+ return [
+ new Weather,
+ new ToolSearch(tools: [
+ new SearchInvoices,
+ new RefundOrder,
+ ]),
+ ];
+}
+```
+
+The wrapped tools do not require any modification. The provider will search for and load them when they are relevant to the prompt, after which the agent may call them like any other tool.
+
+When using Anthropic, the `strategy` argument may be used to determine how the provider should search for deferred tools. The supported strategies are `regex` (default) and `bm25`:
+
+```php
+new ToolSearch(tools: [new SearchInvoices], strategy: 'bm25'),
+```
+
+When using Anthropic, additional provider-specific options may be passed to the search tool using the `withProviderOptions` method:
+
+```php
+(new ToolSearch(tools: [new SearchInvoices]))
+ ->withProviderOptions(['cache_control' => ['type' => 'ephemeral']]),
+```
+
+> [!WARNING]
+> Providers that do not support tool search will throw an exception rather than silently discarding the deferred tools. In addition, Anthropic requires that at least one tool is provided outside of the `ToolSearch` wrapper.
+
+
+### File Storage Tools
+
+The `FileStorage` tool factory allows you to give agents access to a Laravel [filesystem disk](/docs/{{version}}/filesystem). The `all` method returns tools that allow the agent to list, read, inspect, generate URLs for, write, delete, and copy files on the given disk:
+
+```php
+use Laravel\Ai\Tools\FileStorage;
+
+public function tools(): iterable
+{
+ return FileStorage::all('local');
+}
+```
+
+If your agent should only be able to inspect files, use the `readOnly` method:
+
+```php
+return FileStorage::readOnly('local');
+```
+
+These methods return an `Illuminate\Support\Collection`, allowing you to further filter the tools that are provided to the agent:
+
+```php
+use Laravel\Ai\Tools\Filesystem\DeleteFile;
+
+return FileStorage::all('s3')
+ ->reject(fn ($tool) => $tool instanceof DeleteFile);
+```
+
+
+### MCP Tools
+
+If your application uses [Laravel MCP](/docs/{{version}}/mcp), you may give your agents tools exposed by [Model Context Protocol](https://modelcontextprotocol.io) servers. Using the [Laravel MCP client](/docs/{{version}}/mcp#client), you may connect to a remote or local MCP server and pass its tools directly to your agent.
+
+> [!NOTE]
+> MCP tools require the [Laravel MCP](/docs/{{version}}/mcp) package to be installed in your application.
+
+Because an MCP client's `tools` method returns a collection, spread it into your agent's `tools` array using the `...` operator:
+
+```php
+use App\Ai\Tools\RandomNumberGenerator;
+use Laravel\Mcp\Client;
+
+/**
+ * Get the tools available to the agent.
+ *
+ * @return Tool[]
+ */
+public function tools(): iterable
+{
+ return [
+ ...Client::web('https://mcp.example.com')
+ ->withToken($token)
+ ->tools(),
+
+ new RandomNumberGenerator,
+ ];
+}
+```
+
+The AI SDK automatically wraps each MCP tool so the agent can call it like any other tool. You may also use a [named MCP client](/docs/{{version}}/mcp#named-clients):
+
+```php
+use Laravel\Mcp\Facades\Mcp;
+
+public function tools(): iterable
+{
+ return [
+ ...Mcp::client('github')->tools(),
+ ];
+}
+```
+
+Or connect to a [local MCP server](/docs/{{version}}/mcp#client-connecting):
+
+```php
+use Laravel\Mcp\Client;
+
+public function tools(): iterable
+{
+ return [
+ ...Client::local('php', ['artisan', 'mcp:start'])->tools(),
+ ];
+}
+```
+
+For more information on creating and authenticating MCP clients, including bearer tokens and OAuth, consult the [MCP client documentation](/docs/{{version}}/mcp#client).
+
+
+### Skills
+
+[Agent Skills](https://agentskills.io) are folders of instructions and supporting files that teach an agent how to perform a specific task. Because skills follow an open standard, the same skill may be shared between your application's agents and the coding agents you use to build your application.
+
+Each skill is a directory within your application's `resources/skills` directory that contains a `SKILL.md` file. Any other files in the directory, such as reference documents, are bundled with the skill:
+
+```text
+resources/skills/
+└── refund-policy/
+ ├── SKILL.md
+ └── references/EDGE-CASES.md
+```
+
+The `SKILL.md` file begins with YAML frontmatter containing the skill's `name` and a `description` of when the skill should be used, followed by the skill's instructions:
+
+```markdown
+---
+name: refund-policy
+description: Use when a customer asks for a refund or disputes a charge.
+---
+
+# Refund Policy
+
+Customers may request a full refund within 30 days of purchase...
+```
+
+If the `name` is omitted, the name of the skill's directory will be used. Skills without a `description` are ignored.
+
+To give an agent access to your skills, implement the `HasSkills` interface and return your skill sources from the `skills` method:
+
+```php
+use Laravel\Ai\Contracts\HasSkills;
+use Laravel\Ai\Skills\Skill;
+
+class SupportAgent implements Agent, HasSkills
+{
+ use Promptable;
+
+ public function skills(): iterable
+ {
+ return [
+ resource_path('skills'),
+ base_path('.agents/skills'),
+ new Skill('house-style', 'Use when you write copy for a customer.', view('skills.house-style')),
+ fn () => $this->user->team->skills->map(
+ fn ($skill) => new Skill($skill->name, $skill->description, $skill->instructions)
+ ),
+ ];
+ }
+}
+```
+
+Sources may be directories, `Skill` instances, or closures returning skills, which are only invoked once the agent needs them. When two skills share a name, the source listed first takes precedence.
+
+The agent receives a `LoadSkill` tool that lists each skill's name and description, loading a skill's full instructions and bundled text files only when a prompt calls for it. Binary files and files larger than 256 KB are not returned.
+
+
+### Provider Tools
+
+Provider tools are special tools implemented natively by AI providers, offering capabilities like web searching, URL fetching, and file searching. Unlike regular tools, provider tools are executed by the provider itself rather than your application.
+
+Provider tools can be returned by your agent's `tools` method.
+
+
+#### Web Search
+
+The `WebSearch` provider tool allows agents to search the web for real-time information. This is useful for answering questions about current events, recent data, or topics that may have changed since the model's training cutoff.
+
+**Supported providers:** Anthropic, OpenAI, Azure, Gemini, xAI, OpenRouter
+
+```php
+use Laravel\Ai\Providers\Tools\WebSearch;
+
+public function tools(): iterable
+{
+ return [
+ new WebSearch,
+ ];
+}
+```
+
+You may configure the web search tool to limit the number of searches or restrict results to specific domains:
+
+```php
+(new WebSearch)->max(5)->allow(['laravel.com', 'php.net']),
+```
+
+To refine search results based on user location, use the `location` method:
+
+```php
+(new WebSearch)->location(
+ city: 'New York',
+ region: 'NY',
+ country: 'US'
+);
+```
+
+
+#### Web Fetch
+
+The `WebFetch` provider tool allows agents to fetch and read the contents of web pages. This is useful when you need the agent to analyze specific URLs or retrieve detailed information from known web pages.
+
+**Supported providers:** Anthropic, Gemini, OpenRouter
+
+```php
+use Laravel\Ai\Providers\Tools\WebFetch;
+
+public function tools(): iterable
+{
+ return [
+ new WebFetch,
+ ];
+}
+```
+
+You may configure the web fetch tool to limit the number of fetches or restrict to specific domains:
+
+```php
+(new WebFetch)->max(3)->allow(['docs.laravel.com']),
+```
+
+
+#### File Search
+
+The `FileSearch` provider tool allows agents to search through [files](#files) stored in [vector stores](#vector-stores). This enables retrieval-augmented generation (RAG) by allowing the agent to search your uploaded documents for relevant information.
+
+**Supported providers:** OpenAI, Gemini, xAI
+
+```php
+use Laravel\Ai\Providers\Tools\FileSearch;
+
+public function tools(): iterable
+{
+ return [
+ new FileSearch(stores: ['store_id']),
+ ];
+}
+```
+
+You may provide multiple vector store IDs to search across multiple stores:
+
+```php
+new FileSearch(stores: ['store_1', 'store_2']);
+```
+
+If your files have [metadata](#adding-files-to-stores), you may filter the search results by providing a `where` argument. For simple equality filters, pass an array:
+
+```php
+new FileSearch(stores: ['store_id'], where: [
+ 'author' => 'Taylor Otwell',
+ 'year' => 2026,
+]);
+```
+
+For more complex filters, you may pass a closure that receives a `FileSearchQuery` instance:
+
+```php
+use Laravel\Ai\Providers\Tools\FileSearchQuery;
+
+new FileSearch(stores: ['store_id'], where: fn (FileSearchQuery $query) =>
+ $query->where('author', 'Taylor Otwell')
+ ->whereNot('status', 'draft')
+ ->whereIn('category', ['news', 'updates'])
+);
+```
+
+
+#### Code Execution
+
+The `CodeExecution` provider tool allows agents to run code in a sandbox hosted by the AI provider. This is useful for performing calculations and analyzing data.
+
+**Supported providers:** Anthropic, OpenAI, Azure, Gemini, xAI
+
+```php
+use Laravel\Ai\Providers\Tools\CodeExecution;
+
+public function tools(): iterable
+{
+ return [new CodeExecution];
+}
+```
+
+When using OpenAI or Azure, you may make [stored files](#files) available to the sandbox via provider options:
+
+```php
+(new CodeExecution)->withProviderOptions([
+ 'container' => ['type' => 'auto', 'file_ids' => ['file_123']],
+]);
+```
+
+
+### Sub-Agents
+
+Agents may also be returned from another agent's `tools` method. When an agent is returned as a tool, the parent agent may delegate a specific task to the sub-agent and use the sub-agent's response while answering the original prompt. This is useful when a general-purpose agent needs access to specialized agents with their own instructions, tools, model configuration, or provider preferences.
+
+For example, a customer support agent could delegate refund eligibility questions to a dedicated refunds agent:
+
+```php
+preliminary) {
+ continue;
+ }
+
+ // ...
+}
+```
+
+Response values such as `text`, `usage`, and `toolResults` ignore preliminary events. The [Vercel protocol](#stream-protocols) renders them as native streaming tool output, so `useChat` displays the progress without any custom code, while the AG-UI protocol reports them as activity snapshots. The completed response's text, reasoning, citations, and usage include those of the sub-agent.
+
+
+### Middleware
+
+Agents support middleware, allowing you to intercept and modify each generation step before it is sent to the provider. Middleware is invoked once per step, so a run that takes three steps will invoke it three times. Middleware can be created using the `make:agent-middleware` Artisan command:
+
+```shell
+php artisan make:agent-middleware LogPrompts
+```
+
+The generated middleware will be placed in your application's `app/Ai/Middleware` directory. To add middleware to an agent, implement the `HasMiddleware` interface and define a `middleware` method that returns an array of middleware classes:
+
+```php
+ $step->model]);
+
+ return $next($step);
+ }
+}
+```
+
+In addition to the `provider`, `model`, `instructions`, `messages`, and `tools` that are about to be sent, the step exposes the steps that have already completed, their combined usage, and the progress of the run:
+
+```php
+$step->steps;
+$step->usage;
+
+$step->number;
+$step->isFirstStep();
+$step->isFinalStep;
+```
+
+The `withModel`, `withInstructions`, `withMessages`, `withTools`, `onlyTools`, `withoutTools`, `withToolChoice`, `withMaxTokens`, and `withProviderOptions` methods each return a copy of the step. For example, you may remove an expensive tool once the agent has used it:
+
+```php
+public function handle(PendingStep $step, Closure $next)
+{
+ if (! $step->isFirstStep()) {
+ $step = $step->withoutTools('SearchDocumentation');
+ }
+
+ return $next($step);
+}
+```
+
+Or, you may keep a long tool calling loop within the context window by summarizing the middle of the conversation:
+
+```php
+use App\Ai\Agents\Summarizer;
+use Laravel\Ai\Messages\UserMessage;
+
+public function handle(PendingStep $step, Closure $next)
+{
+ if (count($step->messages) > 40) {
+ $summary = (new Summarizer)->prompt(
+ collect(array_slice($step->messages, 1, -10))->map->content->implode("\n"),
+ )->text;
+
+ $step = $step->withMessages([
+ $step->messages[0],
+ new UserMessage("Summary of the conversation so far: {$summary}"),
+ ...array_slice($step->messages, -10),
+ ]);
+ }
+
+ return $next($step);
+}
+```
+
+Messages passed to the `withMessages` method only change what is sent for the current step. Later steps and the stored conversation continue to use the full, unsummarized history.
+
+You may use the `then` method to execute code once the model has answered the step, before its tool calls are executed. This works for both synchronous and streaming responses:
+
+```php
+use Laravel\Ai\Gateway\StepResponse;
+
+public function handle(PendingStep $step, Closure $next)
+{
+ return $next($step)->then(function (StepResponse $response) {
+ Log::info('Agent responded', ['text' => $response->text]);
+ });
+}
+```
+
+Middleware must return the result of `$next`, or its own `StepResponse` to answer the step without invoking the model, such as when serving a cached response. Returning any other value will throw a `LogicException`.
+
+
+### Anonymous Agents
+
+Sometimes you may want to quickly interact with a model without creating a dedicated agent class. You can create an ad-hoc, anonymous agent using the `agent` function:
+
+```php
+use function Laravel\Ai\{agent};
+
+$response = agent(
+ instructions: 'You are an expert at software development.',
+ messages: [],
+ tools: [],
+)->prompt('Tell me about Laravel');
+```
+
+Anonymous agents may also produce structured output:
+
+```php
+use Illuminate\Contracts\JsonSchema\JsonSchema;
+
+use function Laravel\Ai\{agent};
+
+$response = agent(
+ schema: fn (JsonSchema $schema) => [
+ 'number' => $schema->integer()->required(),
+ ],
+)->prompt('Generate a random number less than 100');
+```
+
+
+### Agent Configuration
+
+You may configure text generation options for an agent using PHP attributes. The following attributes are available:
+
+- `MaxSteps`: The maximum number of steps the agent may take when using tools.
+- `MaxTokens`: The maximum number of tokens the model may generate.
+- `Model`: The model the agent should use.
+- `Provider`: The AI provider (or providers for failover) to use for the agent.
+- `Temperature`: The sampling temperature to use for generation (0.0 to 1.0).
+- `Timeout`: The HTTP timeout in seconds for agent requests (default: 60).
+- `TopP`: The nucleus sampling probability to use for generation (0.0 to 1.0).
+- `UseCheapestModel`: Use the provider's cheapest text model for cost optimization.
+- `UseSmartestModel`: Use the provider's most capable text model for complex tasks.
+
+```php
+ [!NOTE]
+> The underlying model selected by `UseCheapestModel` and `UseSmartestModel` may change between releases of the Laravel AI SDK as providers release new models. Switching models can introduce behavioral changes, deprecated parameters, and significant cost differences. If you need a stable, predictable model and pricing, specify the model explicitly using the `Model` attribute.
+
+
+### Provider Options
+
+If your agent needs to pass provider-specific options (such as OpenAI reasoning effort or penalty settings), implement the `HasProviderOptions` contract and define a `providerOptions` method:
+
+```php
+ [
+ 'reasoning' => ['effort' => 'low'],
+ 'frequency_penalty' => 0.5,
+ 'presence_penalty' => 0.3,
+ ],
+ Lab::Anthropic => [
+ 'thinking' => ['budget_tokens' => 1024],
+ 'cache_control' => ['type' => 'ephemeral'],
+ ],
+ default => [],
+ };
+ }
+}
+```
+
+The `providerOptions` method receives the provider currently being used (`Lab` enum or string), allowing you to return different options per provider. This is especially useful when using [failover](#failover), since each fallback provider can receive its own configuration.
+
+The Anthropic example above also enables [prompt caching](#prompt-caching) via `cache_control`.
+
+The [image](#images), [audio](#audio), [transcription](#transcription), [embedding](#embeddings), and [reranking](#reranking) builders accept provider options as well:
+
+```php
+use Laravel\Ai\Audio;
+
+$audio = Audio::of('I love coding with Laravel.')
+ ->withProviderOptions(['speed' => 1.25])
+ ->generate();
+```
+
+You may also pass a closure instead of an array, which will receive the provider currently being used.
+
+
+#### Custom HTTP Headers
+
+Headers configured for a provider within your application's `config/ai.php` configuration file are sent with every request that provider makes. To send headers on a per-request basis, such as metadata used by an AI gateway, you may use the `withHeaders` method, which is available on the image, audio, transcription, embedding, and reranking builders, as well as on [file uploads](#files):
+
+```php
+use Laravel\Ai\Embeddings;
+
+$embeddings = Embeddings::for($chunks)
+ ->withHeaders(['cf-aig-metadata' => json_encode(['team' => $team->id])])
+ ->withProviderOptions(['dimensions' => 1024])
+ ->generate();
+```
+
+Headers may also be given as a closure, which receives the provider currently being used. Headers are not included in the request body and do not affect [embedding cache keys](#caching-embeddings).
+
+
+### Prompt Caching
+
+Most providers cache repeated prompt prefixes automatically and bill the cached portion at a discount. OpenAI, Gemini, Groq, DeepSeek, and xAI require no configuration, and you may inspect the savings via the response's usage:
+
+```php
+$response->usage->cacheReadInputTokens;
+$response->usage->cacheWriteInputTokens;
+```
+
+Both of these counts are subsets of the input token total, which is discussed further in the [usage documentation](#usage).
+
+The `anthropic` and `bedrock` providers only cache when asked. The `CacheInstructions` and `CacheToolDefinitions` attributes place a cache breakpoint at the end of your agent's instructions and tool definitions, so every conversation reads that prefix from the cache instead of writing it again:
+
+```php
+use Laravel\Ai\Attributes\CacheInstructions;
+use Laravel\Ai\Attributes\CacheToolDefinitions;
+
+#[CacheInstructions]
+#[CacheToolDefinitions]
+class SalesCoach implements Agent
+{
+ use Promptable;
+
+ // ...
+}
+```
+
+If your instructions change on every request, such as when they embed the current date, use `CacheToolDefinitions` alone. Caching a prefix that changes on every request creates a new cache entry each time, so you pay to write it to the cache without ever reusing it.
+
+Providers that do not support these attributes ignore them, so an agent may safely declare them while using [failover](#failover).
+
+Cached prefixes are retained for five minutes by default. Anthropic may retain them for an hour if you pass a TTL to the attribute:
+
+```php
+#[CacheInstructions('1h')]
+#[CacheToolDefinitions('1h')]
+```
+
+Alternatively, Anthropic's automatic caching may be enabled via a top-level `cache_control` [provider option](#provider-options). This places a single breakpoint after the last block of the request, so the breakpoint advances as the conversation grows and each turn reads the previous turns from the cache. Both mechanisms may be combined.
+
+> [!WARNING]
+> Because providers build prompts in the order tools, instructions, and messages, caching instructions for an hour also requires caching tool definitions for an hour. Mixing the two throws an `InvalidArgumentException`.
+
+
+## Human Tool Approval
+
+> [!WARNING]
+> Tool approval requires the paused turn's history to be available when the run is resumed. You should either use a `Conversational` agent, such as one using the `RemembersConversations` trait, or provide the history from your application's frontend using the [`withMessages` method](#conversation-context). Agents that do neither will throw an `ApprovalNotResumableException` when a tool pauses.
+
+Tools that perform sensitive or irreversible actions may require human approval before they are executed. To make a tool approvable, implement the `Approvable` contract and use the `InteractsWithApprovals` trait. Approvable tools require approval by default:
+
+```php
+ $schema->string()->required(),
+ ];
+ }
+}
+```
+
+To determine whether approval is needed based on the tool call's arguments, define a `needsApproval` method on the tool. This method may return a boolean or an `Approval` instance that includes a reason for the approval request:
+
+```php
+use Laravel\Ai\Approvals\Approval;
+
+/**
+ * Determine whether the tool needs approval for the given request.
+ */
+protected function needsApproval(Request $request): Approval|bool
+{
+ return str_starts_with($request['path'], 'temporary/')
+ ? false
+ : Approval::required('This will permanently delete a file.');
+}
+```
+
+You may override a tool's approval requirement when returning it from an agent's `tools` method:
+
+```php
+public function tools(): iterable
+{
+ return [
+ (new SendNotification)->withoutApproval(),
+ (new DeleteFile)->requireApproval('Deletion review required.'),
+ ];
+}
+```
+
+When an approvable tool is called, the agent pauses before executing it. You may inspect the response's pending approvals, which contain each tool call's ID, tool name, arguments, and approval reason:
+
+```php
+$response = (new FileAssistant)
+ ->forUser($user)
+ ->prompt('Delete the old invoice.');
+
+if ($response->hasPendingApprovals()) {
+ foreach ($response->pendingApprovals as $approval) {
+ // $approval->id
+ // $approval->tool
+ // $approval->arguments
+ // $approval->reason
+ }
+}
+```
+
+To resume the agent, continue the conversation and provide a `Decisions` instance containing a decision for each pending tool call. Decisions may approve the call, reject it, or edit its arguments before execution:
+
+```php
+use Laravel\Ai\Approvals\Decision;
+use Laravel\Ai\Approvals\Decisions;
+
+$response = (new FileAssistant)
+ ->continue($conversationId, as: $user)
+ ->prompt(Decisions::from([
+ 'call_abc' => Decision::approve(),
+ 'call_ghi' => Decision::reject('The invoice must be retained.'),
+ ]));
+```
+
+> [!IMPORTANT]
+> Paused turns are matched by their conversation and pending tool calls, not by the participant that paused them. Therefore, your application should authorize access to the conversation before resuming it, as demonstrated in the [complete approval flow](#complete-approval-flow), or verify access using the conversation store's `conversationBelongsTo` method.
+
+The boolean values `true` and `false` may be used as shorthand for approval and rejection. Every pending tool call must receive a decision. Unknown, missing, or previously resolved tool call IDs will cause an `ApprovalMismatchException` to be thrown. You may provide a default for calls without an explicit decision using the `approveRemaining` or `rejectRemaining` methods:
+
+```php
+$decisions = Decisions::from([
+ 'call_abc' => true,
+])->rejectRemaining('Not approved.');
+
+$response = (new FileAssistant)
+ ->continue($conversationId, as: $user)
+ ->prompt($decisions);
+```
+
+A rejection with a result, such as `Decision::reject('Not approved.')`, is returned to the model so it may continue responding. A rejection without a result stops the generation loop after recording the rejection.
+
+Tool approval is supported by the `prompt`, `stream`, `queue`, `broadcast`, `broadcastNow`, and `broadcastOnQueue` methods.
+
+During streaming and broadcasting, a pause is represented by a `tool_approval_request` event. When using the [Vercel AI SDK stream protocol](#stream-protocols), approval requests and results are emitted using the protocol's native tool approval parts, and the Agent User Interaction protocol reports them as interrupts.
+
+Clients that use either protocol post their decisions along with the rest of the conversation, so a [chat request](#frontend-integration) may be passed directly to the agent:
+
+```php
+$chat = Vercel::chat($request);
+
+return (new FileAssistant)
+ ->continue($conversationId, as: $request->user())
+ ->stream($chat)
+ ->usingProtocol($chat->protocol());
+```
+
+When a paused turn is resumed, the resumed steps are merged into that turn, so each turn is stored as a single assistant message. The response's `assistantMessageId` contains the ID of the paused message, and that message's usage includes both the pause and the resume.
+
+For queued agents, the resulting response is passed to the `then` callback, and Laravel also dispatches a `ToolApprovalRequested` event.
+
+Laravel stores the result of an approved tool before asking the model to continue. If generation then fails, the approval has already been resolved. Continue the conversation with a normal text prompt instead of submitting the same approval decisions again.
+
+
+### Complete Approval Flow
+
+The following route demonstrates a complete approval flow, accepting either a new text prompt or approval decisions from the chat screen. This example assumes the application's `User` model uses the `HasConversations` trait:
+
+```php
+use App\Ai\Agents\FileAssistant;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Gate;
+use Illuminate\Support\Facades\Route;
+use Illuminate\Validation\Rule;
+use Laravel\Ai\Approvals\Decision;
+use Laravel\Ai\Approvals\Decisions;
+use Laravel\Ai\Models\Conversation;
+
+Route::post('/chat/{conversation}', function (Request $request, Conversation $conversation) {
+ Gate::authorize('view', $conversation);
+
+ $validated = $request->validate([
+ 'message' => ['nullable', 'string', 'required_without:decisions', 'prohibits:decisions'],
+ 'decisions' => ['nullable', 'array', 'required_without:message', 'prohibits:message'],
+ 'decisions.*.action' => ['required_with:decisions', Rule::in(['approve', 'reject'])],
+ 'decisions.*.result' => ['nullable', 'string'],
+ ]);
+
+ $prompt = isset($validated['decisions'])
+ ? Decisions::from(collect($validated['decisions'])->map(
+ fn (array $decision) => match ($decision['action']) {
+ 'approve' => Decision::approve(),
+ 'reject' => Decision::reject($decision['result'] ?? null),
+ }
+ )->all())
+ : $validated['message'];
+
+ $response = (new FileAssistant)
+ ->continue($conversation->id, as: $request->user())
+ ->prompt($prompt);
+
+ return [
+ 'conversation_id' => $response->conversationId,
+ 'status' => $response->hasPendingApprovals() ? 'awaiting_approval' : 'complete',
+ 'message' => $response->text,
+ 'approvals' => $response->pendingApprovals,
+ ];
+})->middleware('auth');
+```
+
+When the response status is `awaiting_approval`, the chat screen should render the pending approvals and submit the user's choices to the same endpoint using the tool call ID as each decision's key. Otherwise, the screen may submit a plain `message` value:
+
+```json
+{
+ "decisions": {
+ "call_abc": {
+ "action": "approve"
+ },
+ "call_def": {
+ "action": "reject",
+ "result": "The invoice must be retained."
+ }
+ }
+}
+```
+
+
+## Images
+
+The `Laravel\Ai\Image` class may be used to generate images using the `openai`, `gemini`, or `xai` providers:
+
+```php
+use Laravel\Ai\Image;
+
+$image = Image::of('A donut sitting on the kitchen counter')->generate();
+
+$rawContent = (string) $image;
+```
+
+The `square`, `portrait`, and `landscape` methods may be used to control the aspect ratio of the image, while the `quality` method may be used to guide the model on final image quality (`high`, `medium`, `low`). The `timeout` method may be used to specify the HTTP timeout in seconds:
+
+```php
+use Laravel\Ai\Image;
+
+$image = Image::of('A donut sitting on the kitchen counter')
+ ->quality('high')
+ ->landscape()
+ ->timeout(120)
+ ->generate();
+```
+
+You may attach reference images using the `attachments` method:
+
+```php
+use Laravel\Ai\Files;
+use Laravel\Ai\Image;
+
+$image = Image::of('Update this photo of me to be in the style of an impressionist painting.')
+ ->attachments([
+ Files\Image::fromStorage('photo.jpg'),
+ // Files\Image::fromPath('/home/laravel/photo.jpg'),
+ // Files\Image::fromUrl('https://example.com/photo.jpg'),
+ // $request->file('photo'),
+ ])
+ ->landscape()
+ ->generate();
+```
+
+Some providers may generate multiple images in a single request. OpenAI, Azure, and xAI accept an `n` [provider option](#provider-options), and the response will contain every image that was returned:
+
+```php
+$response = Image::of('A donut sitting on the kitchen counter')
+ ->withProviderOptions(['n' => 4])
+ ->generate();
+
+count($response); // 4
+$response->images; // A collection of generated images...
+$response->firstImage(); // The first generated image...
+```
+
+Generated images may be easily stored on the default disk configured in your application's `config/filesystems.php` configuration file:
+
+```php
+$image = Image::of('A donut sitting on the kitchen counter');
+
+$path = $image->store();
+$path = $image->storeAs('image.jpg');
+$path = $image->storePublicly();
+$path = $image->storePubliclyAs('image.jpg');
+```
+
+Image generation may also be queued:
+
+```php
+use Laravel\Ai\Image;
+use Laravel\Ai\Responses\ImageResponse;
+
+Image::of('A donut sitting on the kitchen counter')
+ ->portrait()
+ ->queue()
+ ->then(function (ImageResponse $image) {
+ $path = $image->store();
+
+ // ...
+ });
+```
+
+
+## Audio
+
+The `Laravel\Ai\Audio` class may be used to generate audio from the given text:
+
+```php
+use Laravel\Ai\Audio;
+
+$audio = Audio::of('I love coding with Laravel.')->generate();
+
+$rawContent = (string) $audio;
+```
+
+You may also generate audio from a string using the `toAudio` method available via Laravel's `Stringable` class:
+
+```php
+use Illuminate\Support\Str;
+
+$audio = Str::of('I love coding with Laravel.')->toAudio();
+```
+
+The `male`, `female`, and `voice` methods may be used to determine the voice of the generated audio:
+
+```php
+$audio = Audio::of('I love coding with Laravel.')
+ ->female()
+ ->generate();
+
+$audio = Audio::of('I love coding with Laravel.')
+ ->voice('voice-id-or-name')
+ ->generate();
+```
+
+Similarly, the `instructions` method may be used to dynamically coach the model on how the generated audio should sound:
+
+```php
+$audio = Audio::of('I love coding with Laravel.')
+ ->female()
+ ->instructions('Said like a pirate')
+ ->generate();
+```
+
+Generated audio may be easily stored on the default disk configured in your application's `config/filesystems.php` configuration file:
+
+```php
+$audio = Audio::of('I love coding with Laravel.')->generate();
+
+$path = $audio->store();
+$path = $audio->storeAs('audio.mp3');
+$path = $audio->storePublicly();
+$path = $audio->storePubliclyAs('audio.mp3');
+```
+
+Audio generation may also be queued:
+
+```php
+use Laravel\Ai\Audio;
+use Laravel\Ai\Responses\AudioResponse;
+
+Audio::of('I love coding with Laravel.')
+ ->queue()
+ ->then(function (AudioResponse $audio) {
+ $path = $audio->store();
+
+ // ...
+ });
+```
+
+
+## Transcriptions
+
+The `Laravel\Ai\Transcription` class may be used to generate a transcript of the given audio:
+
+```php
+use Laravel\Ai\Transcription;
+
+$transcript = Transcription::fromPath('/home/laravel/audio.mp3')->generate();
+$transcript = Transcription::fromStorage('audio.mp3')->generate();
+$transcript = Transcription::fromUpload($request->file('audio'))->generate();
+
+return (string) $transcript;
+```
+
+The `diarize` method may be used to indicate you would like the response to include the diarized transcript in addition to the raw text transcript, allowing you to access the segmented transcript by speaker:
+
+```php
+$transcript = Transcription::fromStorage('audio.mp3')
+ ->diarize()
+ ->generate();
+```
+
+Transcription generation may also be queued:
+
+```php
+use Laravel\Ai\Transcription;
+use Laravel\Ai\Responses\TranscriptionResponse;
+
+Transcription::fromStorage('audio.mp3')
+ ->queue()
+ ->then(function (TranscriptionResponse $transcript) {
+ // ...
+ });
+```
+
+
+## Text Summarization
+
+You may summarize text using the `summarize` method available via Laravel's `Stringable` class. By default, the summary will contain no more than three sentences and will be generated using the configured provider's cheapest text model:
+
+```php
+use Illuminate\Support\Str;
+
+$summary = Str::of($article)->summarize();
+```
+
+You may specify the maximum number of sentences, provider, model, and timeout used to generate the summary. The `Str` class also offers a static version of the method:
+
+```php
+use Laravel\Ai\Enums\Lab;
+
+$summary = Str::of($article)->summarize(
+ sentences: 4,
+ provider: Lab::Anthropic,
+ model: 'claude-sonnet-5',
+ timeout: 30,
+);
+
+$summary = Str::summarize($article, sentences: 4);
+```
+
+
+## Embeddings
+
+You may easily generate vector embeddings for any given string using the new `toEmbeddings` method available via Laravel's `Stringable` class:
+
+```php
+use Illuminate\Support\Str;
+
+$embeddings = Str::of('Napa Valley has great wine.')->toEmbeddings();
+```
+
+Alternatively, you may use the `Embeddings` class to generate embeddings for multiple inputs at once:
+
+```php
+use Laravel\Ai\Embeddings;
+
+$response = Embeddings::for([
+ 'Napa Valley has great wine.',
+ 'Laravel is a PHP framework.',
+])->generate();
+
+$response->embeddings; // [[0.123, 0.456, ...], [0.789, 0.012, ...]]
+```
+
+You may specify the dimensions and provider for the embeddings:
+
+```php
+$response = Embeddings::for(['Napa Valley has great wine.'])
+ ->dimensions(1536)
+ ->generate(Lab::OpenAI, 'text-embedding-3-small');
+```
+
+
+### Multimodal Embeddings
+
+In addition to strings, the `Embeddings::for` method accepts image, audio, document, and video inputs, allowing you to generate embeddings for non-text content. Gemini supports image, audio, document, and video embeddings, while VoyageAI supports image and video embeddings:
+
+```php
+use Laravel\Ai\Embeddings;
+use Laravel\Ai\Enums\Lab;
+use Laravel\Ai\Files\Image;
+use Laravel\Ai\Files\Video;
+
+$response = Embeddings::for([
+ 'A vineyard at sunset.',
+ Image::fromStorage('vineyard.jpg'),
+ Video::fromPath('/home/laravel/tour.mp4'),
+])->generate(Lab::Gemini);
+```
+
+Multimodal inputs use the same [file classes used for attachments](#attachments). These files may be created from a local path, a filesystem disk, a remote URL, or Base64-encoded content. Images, documents, and videos may also be created from uploaded files, while documents may be created from raw string content:
+
+```php
+use Laravel\Ai\Files\Audio;
+use Laravel\Ai\Files\Document;
+use Laravel\Ai\Files\Image;
+use Laravel\Ai\Files\Video;
+
+Image::fromPath('/home/laravel/photo.jpg');
+Image::fromStorage('photo.jpg');
+Image::fromUpload($request->file('photo'));
+
+Audio::fromPath('/home/laravel/clip.mp3');
+Audio::fromStorage('clip.mp3');
+Audio::fromUpload($request->file('clip.mp3'));
+
+Video::fromPath('/home/laravel/video.mp4');
+Video::fromStorage('video.mp4');
+Video::fromUpload($request->file('video'));
+
+Document::fromUrl('https://example.com/report.pdf');
+Document::fromString('Laravel is a PHP framework.', 'text/plain');
+Document::fromUpload($request->file('report'));
+```
+
+> [!NOTE]
+> VoyageAI does not allow remote URL media and Base64-encoded media to be mixed in a single request. Local, stored, and uploaded files are sent as Base64-encoded content, and text inputs may be combined with either media source. Consult your provider's documentation to determine which multimodal models and inputs are available.
+
+
+### Querying Embeddings
+
+Once you have generated embeddings, you will typically store them in a `vector` column in your database for later querying. Laravel provides native support for vector columns on PostgreSQL via the `pgvector` extension and MariaDB. To get started, define a `vector` column in your migration, specifying the number of dimensions:
+
+```php
+Schema::ensureVectorExtensionExists();
+
+Schema::create('documents', function (Blueprint $table) {
+ $table->id();
+ $table->string('title');
+ $table->text('content');
+ $table->vector('embedding', dimensions: 1536);
+ $table->timestamps();
+});
+```
+
+You may also add a vector index to speed up similarity searches. When calling `index` on a vector column, Laravel will automatically create an HNSW index with cosine distance:
+
+```php
+$table->vector('embedding', dimensions: 1536)->index();
+```
+
+On your Eloquent model, you should cast the vector column using the `AsVector` cast:
+
+```php
+use Illuminate\Database\Eloquent\Casts\AsVector;
+
+protected function casts(): array
+{
+ return [
+ 'embedding' => AsVector::class,
+ ];
+}
+```
+
+To query for similar records, use the `whereVectorSimilarTo` method. This method filters results by a minimum cosine similarity (between `0.0` and `1.0`, where `1.0` is identical) and orders the results by similarity:
+
+```php
+use App\Models\Document;
+
+$documents = Document::query()
+ ->whereVectorSimilarTo('embedding', $queryEmbedding, minSimilarity: 0.4)
+ ->limit(10)
+ ->get();
+```
+
+The `$queryEmbedding` may be an array of floats or a plain string. When a string is given, Laravel will automatically generate embeddings for it:
+
+```php
+$documents = Document::query()
+ ->whereVectorSimilarTo('embedding', 'best wineries in Napa Valley')
+ ->limit(10)
+ ->get();
+```
+
+If you need more control, you may use the lower-level `whereVectorDistanceLessThan`, `selectVectorDistance`, and `orderByVectorDistance` methods independently:
+
+```php
+$documents = Document::query()
+ ->select('*')
+ ->selectVectorDistance('embedding', $queryEmbedding, as: 'distance')
+ ->whereVectorDistanceLessThan('embedding', $queryEmbedding, maxDistance: 0.3)
+ ->orderByVectorDistance('embedding', $queryEmbedding)
+ ->limit(10)
+ ->get();
+```
+
+If you would like to give an agent the ability to perform similarity searches as a tool, check out the [Similarity Search](#similarity-search) tool documentation.
+
+> [!NOTE]
+> Vector queries are currently supported on PostgreSQL connections using the `pgvector` extension, or on MariaDB 11.7 or later using its native vector support.
+
+
+### Caching Embeddings
+
+Embedding generation can be cached to avoid redundant API calls for identical inputs. To enable caching, set the `ai.caching.embeddings.cache` configuration option to `true`:
+
+```php
+'caching' => [
+ 'embeddings' => [
+ 'cache' => true,
+ 'store' => env('CACHE_STORE', 'database'),
+ 'individually' => true,
+ // ...
+ ],
+],
+```
+
+When caching is enabled, embeddings are cached for 30 days. The cache key is based on the provider, model, dimensions, and input content, ensuring that identical requests return cached results while different configurations generate fresh embeddings.
+
+By default, each input's embedding is cached under its own key, so a later request may hit the cache for inputs it has seen before even when the set of inputs or their order has changed. To instead cache the entire set of inputs under a single key, set the `ai.caching.embeddings.individually` configuration option to `false`.
+
+You may also enable caching for a specific request using the `cache` method, even when global caching is disabled:
+
+```php
+$response = Embeddings::for(['Napa Valley has great wine.'])
+ ->cache()
+ ->generate();
+```
+
+You may specify a custom cache duration in seconds:
+
+```php
+$response = Embeddings::for(['Napa Valley has great wine.'])
+ ->cache(seconds: 3600) // Cache for 1 hour
+ ->generate();
+```
+
+The `toEmbeddings` Stringable method also accepts a `cache` argument:
+
+```php
+// Cache with default duration...
+$embeddings = Str::of('Napa Valley has great wine.')->toEmbeddings(cache: true);
+
+// Cache for a specific duration...
+$embeddings = Str::of('Napa Valley has great wine.')->toEmbeddings(cache: 3600);
+```
+
+
+## Reranking
+
+Reranking allows you to reorder a list of documents based on their relevance to a given query. This is useful for improving search results by using semantic understanding:
+
+The `Laravel\Ai\Reranking` class may be used to rerank documents:
+
+```php
+use Laravel\Ai\Reranking;
+
+$response = Reranking::of([
+ 'Django is a Python web framework.',
+ 'Laravel is a PHP web application framework.',
+ 'React is a JavaScript library for building user interfaces.',
+])->rerank('PHP frameworks');
+
+// Access the top result...
+$response->first()->document; // "Laravel is a PHP web application framework."
+$response->first()->score; // 0.95
+$response->first()->index; // 1 (original position)
+```
+
+The `limit` method may be used to restrict the number of results returned, while the `timeout` method may be used to specify the HTTP timeout in seconds, which defaults to 30:
+
+```php
+$response = Reranking::of($documents)
+ ->limit(5)
+ ->timeout(60)
+ ->rerank('search query');
+```
+
+
+### Reranking Collections
+
+For convenience, Laravel collections may be reranked using the `rerank` macro. The first argument specifies which field(s) to use for reranking, and the second argument is the query:
+
+```php
+// Rerank by a single field...
+$posts = Post::all()
+ ->rerank('body', 'Laravel tutorials');
+
+// Rerank by multiple fields (sent as JSON)...
+$reranked = $posts->rerank(['title', 'body'], 'Laravel tutorials');
+
+// Rerank using a closure to build the document...
+$reranked = $posts->rerank(
+ fn ($post) => $post->title.': '.$post->body,
+ 'Laravel tutorials'
+);
+```
+
+You may also limit the number of results and specify a provider:
+
+```php
+$reranked = $posts->rerank(
+ by: 'content',
+ query: 'Laravel tutorials',
+ limit: 10,
+ provider: Lab::Cohere,
+ timeout: 60,
+);
+```
+
+
+## Classification
+
+> [!WARNING]
+> Classification is currently experimental and its API may change in future minor releases of the AI SDK.
+
+Classification allows you to ask a fixed set of questions about a given string or array of data and receive a typed answer, backed by a probability, for each question instead of free-form text. This is useful for routing, moderation, and scoring, where you need to compare an answer against a threshold or make assertions about it in your tests.
+
+The `Laravel\Ai\Classification` class may be used to classify content. Each question is given a key, and the corresponding answer may be retrieved from the response using that key:
+
+```php
+use Laravel\Ai\Classification;
+use Laravel\Ai\Classification\Boolean;
+use Laravel\Ai\Classification\Choice;
+use Laravel\Ai\Classification\Score;
+
+$result = Classification::of($supportRequest)
+ ->questions([
+ 'urgent' => new Boolean('Does this request need an immediate response?', [
+ 'true' => 'Explicitly time-sensitive',
+ 'false' => 'No urgency expressed',
+ ]),
+ 'department' => new Choice('Which team should handle this request?', [
+ 'billing' => 'Payments, invoices, and refunds',
+ 'technical' => 'Bugs, outages, and integrations',
+ 'sales' => 'Pricing, plans, and upgrades',
+ ]),
+ 'frustration' => new Score('How frustrated is the customer?', [
+ 'Calm',
+ 'Frustrated',
+ 'Very angry',
+ ]),
+ ])
+ ->classify();
+```
+
+`Boolean` questions return the probability that the answer is "true". The `isTrue` method may be used to determine whether that probability meets a given threshold, which defaults to `0.5`:
+
+```php
+$result['urgent']->probability; // 0.94
+$result['urgent']->isTrue(threshold: 0.8); // true
+```
+
+`Choice` questions return one of the given options along with the probability of each option. The `confidence` property indicates how certain the provider is across the full set of probabilities, and will be `null` when the provider is unable to measure it:
+
+```php
+$result['department']->choice; // 'technical'
+$result['department']->probabilityOf('technical'); // 0.87
+$result['department']->probabilities; // ['billing' => 0.08, 'technical' => 0.87, 'sales' => 0.05]
+$result['department']->confidence; // 0.82
+```
+
+`Score` questions return a position on the ordered levels that were provided. The `score` property is probability-weighted and may fall between two levels, while the `level` and `label` methods describe the most probable level:
+
+```php
+$result['frustration']->score; // 1.24, the probability-weighted level
+$result['frustration']->level(); // 1, the most probable level
+$result['frustration']->label(); // 'Frustrated'
+$result['frustration']->normalized(); // 0.62, the score as a fraction of the highest level
+$result['frustration']->probabilities; // [0.12, 0.52, 0.36]
+```
+
+The criteria given to a `Boolean` question, the option descriptions given to a `Choice` question, and the levels given to a `Score` question may each be an array when a single sentence is not sufficient. `Choice` questions require at least two options, while `Score` questions require at least two levels.
+
+The response may be iterated, counted, and accessed as an array. In addition, the `answer` method may be used to retrieve a single answer, while the `collect` method returns all of the answers as a [collection](/docs/{{version}}/collections):
+
+```php
+$result->answer('urgent');
+$result->collect();
+
+$result->usage;
+$result->meta->provider;
+```
+
+
+### Yes or No Decisions
+
+For a single yes or no decision, you may use the `decide` method available via Laravel's `Stringable` class, which returns a boolean instead of a full response. You may describe what a "yes" and a "no" mean, and specify the probability the answer must reach, which defaults to `0.5`:
+
+```php
+use Illuminate\Support\Str;
+
+if (Str::of($message)->decide('Is this spam?')) {
+ // ...
+}
+
+$spam = Str::of($message)->decide('Is this spam?', criteria: [
+ 'true' => 'Unsolicited bulk mail.',
+ 'false' => 'A genuine message from a customer.',
+], threshold: 0.9);
+```
+
+
+### Choosing From Collections
+
+To quickly choose a single item from a list of options, you may use the `decide` method available on Laravel's `Collection` class. The method accepts a question and the text to classify, and returns the chosen item from the collection.
+
+Collections of strings and enums may be used directly, while other items should be named using the `by` argument. You may also provide a field, array of fields, or closure via the `describe` argument to give the model more detail about each option:
+
+```php
+$department = collect(['billing', 'technical', 'sales'])
+ ->decide('Which team should handle this request?', $ticket->body);
+
+$priority = collect(Priority::cases())
+ ->decide('How urgent is this request?', $ticket->body);
+
+$department = Department::all()->decide(
+ 'Which department should handle this request?',
+ $ticket->body,
+ by: 'name',
+ describe: 'description',
+);
+```
+
+The description defined via the `describe` argument serves as the criteria the model uses to determine whether the text matches that option, which is helpful when an option's name alone is ambiguous. When a closure is provided to the `describe` argument, it receives each item and should return a string or array describing it:
+
+```php
+$priority = collect(Priority::cases())->decide(
+ 'How urgent is this request?',
+ $ticket->body,
+ describe: fn (Priority $priority) => $priority->description(),
+);
+```
+
+When a collection of strings is keyed by strings, its keys are used as the options and its values as their descriptions, and the chosen key is returned. If a `threshold` is given and the probability of the chosen option is below it, `null` is returned:
+
+```php
+$department = collect([
+ 'billing' => 'Payments, invoices, and refunds',
+ 'technical' => 'Bugs, outages, and integrations',
+])->decide('Which team should handle this request?', $ticket->body, threshold: 0.6) ?? 'triage';
+```
+
+
+## Files
+
+The `Laravel\Ai\Files` class or the individual file classes may be used to store files with your AI provider for later use in conversations. This is useful for large documents or files you want to reference multiple times without re-uploading:
+
+```php
+use Laravel\Ai\Files\Document;
+use Laravel\Ai\Files\Image;
+
+// Store a file from a local path...
+$response = Document::fromPath('/home/laravel/document.pdf')->put();
+$response = Image::fromPath('/home/laravel/photo.jpg')->put();
+
+// Store a file that is stored on a filesystem disk...
+$response = Document::fromStorage('document.pdf', disk: 'local')->put();
+$response = Image::fromStorage('photo.jpg', disk: 'local')->put();
+
+// Store a file that is stored on a remote URL...
+$response = Document::fromUrl('https://example.com/document.pdf')->put();
+$response = Image::fromUrl('https://example.com/photo.jpg')->put();
+
+return $response->id;
+```
+
+You may also store raw content or uploaded files:
+
+```php
+use Laravel\Ai\Files;
+use Laravel\Ai\Files\Document;
+
+// Store raw content...
+$stored = Document::fromString('Hello, World!', 'text/plain')->put();
+
+// Store an uploaded file...
+$stored = Document::fromUpload($request->file('document'))->put();
+```
+
+Once a file has been stored, you may reference the file when generating text via agents instead of re-uploading the file:
+
+```php
+use App\Ai\Agents\SalesCoach;
+use Laravel\Ai\Files;
+
+$response = (new SalesCoach)->prompt(
+ 'Analyze the attached sales transcript...',
+ attachments: [
+ Files\Document::fromId('file-id') // Attach a stored document...
+ ]
+);
+```
+
+To retrieve a previously stored file, use the `get` method on a file instance:
+
+```php
+use Laravel\Ai\Files\Document;
+
+$file = Document::fromId('file-id')->get();
+
+$file->id;
+$file->mimeType();
+```
+
+To delete a file from the provider, use the `delete` method:
+
+```php
+Document::fromId('file-id')->delete();
+```
+
+By default, the `Files` class uses the default AI provider configured in your application's `config/ai.php` configuration file. For most operations, you may specify a different provider using the `provider` argument:
+
+```php
+$response = Document::fromPath(
+ '/home/laravel/document.pdf'
+)->put(provider: Lab::Anthropic);
+```
+
+You may pass provider-specific upload options using the `withProviderOptions` method. For example, you may set OpenAI's file `purpose`:
+
+```php
+use Laravel\Ai\Files\Document;
+
+$response = Document::fromPath('/home/laravel/knowledge.txt')
+ ->withProviderOptions(['purpose' => 'assistants'])
+ ->put();
+```
+
+To scope options per provider, pass a closure that receives the current provider:
+
+```php
+use Laravel\Ai\Enums\Lab;
+use Laravel\Ai\Files\Document;
+
+$response = Document::fromPath('/home/laravel/training.jsonl')
+ ->withProviderOptions(fn (Lab|string $provider) => match ($provider) {
+ Lab::OpenAI => ['purpose' => 'fine-tune'],
+ default => [],
+ })
+ ->put();
+```
+
+
+### Using Stored Files in Conversations
+
+Once a file has been stored with a provider, you may reference it in agent conversations using the `fromId` method on the `Document` or `Image` classes:
+
+```php
+use App\Ai\Agents\DocumentAnalyzer;
+use Laravel\Ai\Files;
+use Laravel\Ai\Files\Document;
+
+$stored = Document::fromPath('/path/to/report.pdf')->put();
+
+$response = (new DocumentAnalyzer)->prompt(
+ 'Summarize this document.',
+ attachments: [
+ Document::fromId($stored->id),
+ ],
+);
+```
+
+Similarly, stored images may be referenced using the `Image` class:
+
+```php
+use Laravel\Ai\Files;
+use Laravel\Ai\Files\Image;
+
+$stored = Image::fromPath('/path/to/photo.jpg')->put();
+
+$response = (new ImageAnalyzer)->prompt(
+ 'What is in this image?',
+ attachments: [
+ Image::fromId($stored->id),
+ ],
+);
+```
+
+
+## Vector Stores
+
+Vector stores allow you to create searchable collections of files that can be used for retrieval-augmented generation (RAG). The `Laravel\Ai\Stores` class provides methods for creating, retrieving, and deleting vector stores:
+
+```php
+use Laravel\Ai\Stores;
+
+// Create a new vector store...
+$store = Stores::create('Knowledge Base');
+
+// Create a store with additional options...
+$store = Stores::create(
+ name: 'Knowledge Base',
+ description: 'Documentation and reference materials.',
+ expiresWhenIdleFor: days(30),
+);
+
+return $store->id;
+```
+
+To retrieve an existing vector store by its ID, use the `get` method:
+
+```php
+use Laravel\Ai\Stores;
+
+$store = Stores::get('store_id');
+
+$store->id;
+$store->name;
+$store->fileCounts;
+$store->ready;
+```
+
+To delete a vector store, use the `delete` method on the `Stores` class or the store instance:
+
+```php
+use Laravel\Ai\Stores;
+
+// Delete by ID...
+Stores::delete('store_id');
+
+// Or delete via a store instance...
+$store = Stores::get('store_id');
+
+$store->delete();
+```
+
+
+### Adding Files to Stores
+
+Once you have a vector store, you may add [files](#files) to it using the `add` method. Files added to a store are automatically indexed for semantic searching using the [file search provider tool](#file-search):
+
+```php
+use Laravel\Ai\Files\Document;
+use Laravel\Ai\Stores;
+
+$store = Stores::get('store_id');
+
+// Add a file that has already been stored with the provider...
+$document = $store->add('file_id');
+$document = $store->add(Document::fromId('file_id'));
+
+// Or, store and add a file in one step...
+$document = $store->add(Document::fromPath('/path/to/document.pdf'));
+$document = $store->add(Document::fromStorage('manual.pdf'));
+$document = $store->add($request->file('document'));
+
+$document->id;
+$document->fileId;
+```
+
+> **Note:** Typically, when adding previously stored files to vector stores, the returned document ID will match the file's previously assigned ID; however, some vector storage providers may return a new, different "document ID". Therefore, it's recommended that you always store both IDs in your database for future reference.
+
+When adding a file to a Gemini store, Laravel waits for the import to finish so that the document is searchable once the call returns. A `Laravel\Ai\Exceptions\AiException` will be thrown if the import fails or exceeds five minutes, so you may wish to add Gemini files from a [queued job](/docs/{{version}}/queues).
+
+You may attach metadata to files when adding them to a store. This metadata can later be used to filter search results when using the [file search provider tool](#file-search):
+
+```php
+$store->add(Document::fromPath('/path/to/document.pdf'), metadata: [
+ 'author' => 'Taylor Otwell',
+ 'department' => 'Engineering',
+ 'year' => 2026,
+]);
+```
+
+To remove a file from a store, use the `remove` method:
+
+```php
+$store->remove('file_id');
+```
+
+Removing a file from a vector store does not remove it from the provider's [file storage](#files). To remove a file from the vector store and delete it permanently from file storage, use the `deleteFile` argument:
+
+```php
+$store->remove('file_abc123', deleteFile: true);
+```
+
+
+## Usage
+
+Every response contains a `usage` property containing the token counts reported by the provider. The input and output counts are totals, so tokens counted as cached or reasoning tokens are also included in the total they belong to:
+
+```php
+$response = (new SalesCoach)->prompt('Analyze this sales transcript...');
+
+$response->usage->inputTokens;
+$response->usage->outputTokens;
+$response->usage->totalTokens();
+```
+
+Text generation returns a `Laravel\Ai\Responses\Data\TextUsage` instance, which breaks these totals down further. Each of these values will be `null`, rather than `0`, when the provider does not report it:
+
+```php
+$response->usage->cacheReadInputTokens; // Subset of the input tokens read from a prompt cache...
+$response->usage->cacheWriteInputTokens; // Subset of the input tokens written to a prompt cache...
+$response->usage->reasoningTokens; // Subset of the output tokens spent on reasoning...
+
+$response->usage->uncachedInputTokens(); // Input tokens that were neither read from nor written to the cache...
+```
+
+Cache reads, cache writes, and uncached input are billed at different rates, so you should price these three counts separately instead of using the input total alone.
+
+The remaining capabilities return a usage object containing the counts specific to them:
+
+
+
+| Capability | Usage object | Adds |
+|---|---|---|
+| Text, classification | `TextUsage` | Cache read, cache write, and reasoning tokens |
+| Images | `ImageUsage` | `imageInputTokens` and `imageOutputTokens` |
+| Transcription | `TranscriptionUsage` | `audioSeconds`, the duration of the transcribed audio |
+| Reranking | `RerankingUsage` | `searchUnits`, which some providers bill instead of tokens |
+| Audio, embeddings | `Usage` | |
+
+
+
+Not every provider reports every count, and counts that a provider does not report will be `null`:
+
+```php
+use Laravel\Ai\Image;
+use Laravel\Ai\Transcription;
+
+Image::of('A donut sitting on the kitchen counter')->generate()->usage->imageOutputTokens;
+
+Transcription::fromPath('/home/laravel/meeting.mp3')->generate()->usage->audioSeconds;
+```
+
+
+## Failover
+
+When prompting or generating other media, you may provide an array of providers / models to automatically failover to a backup provider / model if a service interruption or rate limit is encountered on the primary provider:
+
+```php
+use App\Ai\Agents\SalesCoach;
+use Laravel\Ai\Enums\Lab;
+use Laravel\Ai\Image;
+
+$response = (new SalesCoach)->prompt(
+ 'Analyze this sales transcript...',
+ provider: [Lab::OpenAI, Lab::Anthropic],
+);
+
+$image = Image::of('A donut sitting on the kitchen counter')
+ ->generate(provider: [Lab::Gemini, Lab::xAI]);
+```
+
+Failover only occurs when a `FailoverableException` is thrown — such as a rate limit (`RateLimitedException`), an overloaded or unavailable provider (`ProviderOverloadedException`), or insufficient credits (`InsufficientCreditsException`). Ordinary errors, like a validation or bad request error, will not trigger failover.
+
+When you pass a plain list of providers, such as `[Lab::OpenAI, Lab::Anthropic]`, each provider uses its default model. To specify a particular model for each provider in the failover chain, pass an associative array keyed by the provider, using the `Lab` enum's `value` as the key (enum cases cannot be used directly as PHP array keys):
+
+```php
+use Laravel\Ai\Enums\Lab;
+
+$response = (new SalesCoach)->prompt(
+ 'Analyze this sales transcript...',
+ provider: [
+ Lab::Gemini->value => 'gemini-3-flash-preview',
+ Lab::DeepSeek->value => 'deepseek-v4-pro',
+ ],
+);
+```
+
+
+## Testing
+
+When faking queued image, audio, transcription, or embeddings generation, any `then` callback registered on the queued generation will be invoked with the faked response, allowing you to test the logic contained within the callback. If you would prefer that these callbacks are not invoked, you may fake the queue using `Queue::fake()` as well.
+
+
+### Agents
+
+To fake an agent's responses during tests, call the `fake` method on the agent class. You may optionally provide an array of responses or a closure:
+
+```php
+use App\Ai\Agents\SalesCoach;
+use Laravel\Ai\Prompts\AgentPrompt;
+
+// Automatically generate a fixed response for every prompt...
+SalesCoach::fake();
+
+// Provide a list of prompt responses...
+SalesCoach::fake([
+ 'First response',
+ 'Second response',
+]);
+
+// Dynamically handle prompt responses based on the incoming prompt...
+SalesCoach::fake(function (AgentPrompt $prompt) {
+ return 'Response for: '.$prompt->prompt;
+});
+```
+
+When faking an agent that returns structured output, you may provide arrays as responses. The agent will return a structured response containing the given data:
+
+```php
+SalesCoach::fake([
+ ['score' => 87],
+]);
+```
+
+You may also fake a response that is awaiting tool approval:
+
+```php
+use Laravel\Ai\Approvals\PendingApproval;
+use Laravel\Ai\Responses\AgentResponse;
+
+FileAssistant::fake([
+ AgentResponse::fakeWithPendingApprovals([
+ new PendingApproval(
+ id: 'call_abc',
+ tool: 'DeleteFile',
+ arguments: ['path' => 'invoice.pdf'],
+ reason: 'This will permanently delete a file.',
+ ),
+ ]),
+]);
+
+$response = (new FileAssistant)->prompt('Delete the invoice.');
+
+$response->hasPendingApprovals(); // true
+```
+
+In addition, you can fake a response that includes reasoning. The fake emits reasoning events, so the reasoning is reported on streamed runs as well:
+
+```php
+use Laravel\Ai\Responses\AgentResponse;
+
+SalesCoach::fake([
+ AgentResponse::fakeWithReasoning('They asked about pricing.', 'Plans start at $10.'),
+]);
+
+$response = (new SalesCoach)->stream('What does it cost?');
+
+foreach ($response as $event) {
+ // ...
+}
+
+$response->reasoning; // 'They asked about pricing.'
+```
+
+> **Note:** When `Agent::fake()` is invoked on an agent that returns structured output and fake output was not explicitly provided, Laravel will automatically generate fake data that matches your agent's defined output schema.
+
+After prompting the agent, you may make assertions about the prompts that were received:
+
+```php
+use Laravel\Ai\Prompts\AgentPrompt;
+
+SalesCoach::assertPrompted('Analyze this...');
+
+SalesCoach::assertPrompted(function (AgentPrompt $prompt) {
+ return $prompt->contains('Analyze');
+});
+
+SalesCoach::assertPromptedTimes(3);
+
+SalesCoach::assertNotPrompted('Missing prompt');
+
+SalesCoach::assertNeverPrompted();
+```
+
+When asserting an approval continuation, you may inspect the prompt's approval decisions:
+
+```php
+use Laravel\Ai\Approvals\Decisions;
+use Laravel\Ai\Prompts\AgentPrompt;
+
+FileAssistant::fake();
+
+(new FileAssistant)->prompt(Decisions::from([
+ 'call_abc' => true,
+]));
+
+FileAssistant::assertPrompted(function (AgentPrompt $prompt) {
+ return $prompt->hasApprovalDecisions()
+ && $prompt->approvalDecisions->get('call_abc')->isApproved();
+});
+```
+
+For queued agent invocations, use the queued assertion methods:
+
+```php
+use Laravel\Ai\QueuedAgentPrompt;
+
+SalesCoach::assertQueued('Analyze this...');
+
+SalesCoach::assertQueued(function (QueuedAgentPrompt $prompt) {
+ return $prompt->contains('Analyze');
+});
+
+SalesCoach::assertNotQueued('Missing prompt');
+
+SalesCoach::assertNeverQueued();
+```
+
+To ensure all agent invocations have a corresponding fake response, you may use `preventStrayPrompts`. If an agent is invoked without a defined fake response, an exception will be thrown:
+
+```php
+SalesCoach::fake()->preventStrayPrompts();
+```
+
+
+### Images
+
+Image generations may be faked by invoking the `fake` method on the `Image` class. Once image has been faked, various assertions may be performed against the recorded image generation prompts:
+
+```php
+use Laravel\Ai\Image;
+use Laravel\Ai\Prompts\ImagePrompt;
+use Laravel\Ai\Prompts\QueuedImagePrompt;
+
+// Automatically generate a fixed response for every prompt...
+Image::fake();
+
+// Provide a list of prompt responses...
+Image::fake([
+ base64_encode($firstImage),
+ base64_encode($secondImage),
+]);
+
+// Dynamically handle prompt responses based on the incoming prompt...
+Image::fake(function (ImagePrompt $prompt) {
+ return base64_encode('...');
+});
+```
+
+After generating images, you may make assertions about the prompts that were received:
+
+```php
+Image::assertGenerated(function (ImagePrompt $prompt) {
+ return $prompt->contains('sunset') && $prompt->isLandscape();
+});
+
+Image::assertNotGenerated('Missing prompt');
+
+Image::assertNothingGenerated();
+```
+
+For queued image generations, use the queued assertion methods:
+
+```php
+Image::assertQueued(
+ fn (QueuedImagePrompt $prompt) => $prompt->contains('sunset')
+);
+
+Image::assertNotQueued('Missing prompt');
+
+Image::assertNothingQueued();
+```
+
+To ensure all image generations have a corresponding fake response, you may use `preventStrayImages`. If an image is generated without a defined fake response, an exception will be thrown:
+
+```php
+Image::fake()->preventStrayImages();
+```
+
+
+### Audio
+
+Audio generations may be faked by invoking the `fake` method on the `Audio` class. Once audio has been faked, various assertions may be performed against the recorded audio generation prompts:
+
+```php
+use Laravel\Ai\Audio;
+use Laravel\Ai\Prompts\AudioPrompt;
+use Laravel\Ai\Prompts\QueuedAudioPrompt;
+
+// Automatically generate a fixed response for every prompt...
+Audio::fake();
+
+// Provide a list of prompt responses...
+Audio::fake([
+ base64_encode($firstAudio),
+ base64_encode($secondAudio),
+]);
+
+// Dynamically handle prompt responses based on the incoming prompt...
+Audio::fake(function (AudioPrompt $prompt) {
+ return base64_encode('...');
+});
+```
+
+After generating audio, you may make assertions about the prompts that were received:
+
+```php
+Audio::assertGenerated(function (AudioPrompt $prompt) {
+ return $prompt->contains('Hello') && $prompt->isFemale();
+});
+
+Audio::assertNotGenerated('Missing prompt');
+
+Audio::assertNothingGenerated();
+```
+
+For queued audio generations, use the queued assertion methods:
+
+```php
+Audio::assertQueued(
+ fn (QueuedAudioPrompt $prompt) => $prompt->contains('Hello')
+);
+
+Audio::assertNotQueued('Missing prompt');
+
+Audio::assertNothingQueued();
+```
+
+To ensure all audio generations have a corresponding fake response, you may use `preventStrayAudio`. If audio is generated without a defined fake response, an exception will be thrown:
+
+```php
+Audio::fake()->preventStrayAudio();
+```
+
+
+### Transcriptions
+
+Transcription generations may be faked by invoking the `fake` method on the `Transcription` class. Once transcription has been faked, various assertions may be performed against the recorded transcription generation prompts:
+
+```php
+use Laravel\Ai\Transcription;
+use Laravel\Ai\Prompts\TranscriptionPrompt;
+use Laravel\Ai\Prompts\QueuedTranscriptionPrompt;
+
+// Automatically generate a fixed response for every prompt...
+Transcription::fake();
+
+// Provide a list of prompt responses...
+Transcription::fake([
+ 'First transcription text.',
+ 'Second transcription text.',
+]);
+
+// Dynamically handle prompt responses based on the incoming prompt...
+Transcription::fake(function (TranscriptionPrompt $prompt) {
+ return 'Transcribed text...';
+});
+```
+
+After generating transcriptions, you may make assertions about the prompts that were received:
+
+```php
+Transcription::assertGenerated(function (TranscriptionPrompt $prompt) {
+ return $prompt->language === 'en' && $prompt->isDiarized();
+});
+
+Transcription::assertNotGenerated(
+ fn (TranscriptionPrompt $prompt) => $prompt->language === 'fr'
+);
+
+Transcription::assertNothingGenerated();
+```
+
+For queued transcription generations, use the queued assertion methods:
+
+```php
+Transcription::assertQueued(
+ fn (QueuedTranscriptionPrompt $prompt) => $prompt->isDiarized()
+);
+
+Transcription::assertNotQueued(
+ fn (QueuedTranscriptionPrompt $prompt) => $prompt->language === 'fr'
+);
+
+Transcription::assertNothingQueued();
+```
+
+To ensure all transcription generations have a corresponding fake response, you may use `preventStrayTranscriptions`. If a transcription is generated without a defined fake response, an exception will be thrown:
+
+```php
+Transcription::fake()->preventStrayTranscriptions();
+```
+
+
+### Embeddings
+
+Embeddings generations may be faked by invoking the `fake` method on the `Embeddings` class. Once embeddings has been faked, various assertions may be performed against the recorded embeddings generation prompts:
+
+```php
+use Laravel\Ai\Embeddings;
+use Laravel\Ai\Prompts\EmbeddingsPrompt;
+use Laravel\Ai\Prompts\QueuedEmbeddingsPrompt;
+
+// Automatically generate fake embeddings of the proper dimensions for every prompt...
+Embeddings::fake();
+
+// Provide a list of prompt responses...
+Embeddings::fake([
+ [$firstEmbeddingVector],
+ [$secondEmbeddingVector],
+]);
+
+// Dynamically handle prompt responses based on the incoming prompt...
+Embeddings::fake(function (EmbeddingsPrompt $prompt) {
+ return array_map(
+ fn () => Embeddings::fakeEmbedding($prompt->dimensions),
+ $prompt->inputs
+ );
+});
+```
+
+After generating embeddings, you may make assertions about the prompts that were received:
+
+```php
+Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) {
+ return $prompt->contains('Laravel') && $prompt->dimensions === 1536;
+});
+
+Embeddings::assertNotGenerated(
+ fn (EmbeddingsPrompt $prompt) => $prompt->contains('Other')
+);
+
+Embeddings::assertNothingGenerated();
+```
+
+For queued embeddings generations, use the queued assertion methods:
+
+```php
+Embeddings::assertQueued(
+ fn (QueuedEmbeddingsPrompt $prompt) => $prompt->contains('Laravel')
+);
+
+Embeddings::assertNotQueued(
+ fn (QueuedEmbeddingsPrompt $prompt) => $prompt->contains('Other')
+);
+
+Embeddings::assertNothingQueued();
+```
+
+To ensure all embeddings generations have a corresponding fake response, you may use `preventStrayEmbeddings`. If embeddings are generated without a defined fake response, an exception will be thrown:
+
+```php
+Embeddings::fake()->preventStrayEmbeddings();
+```
+
+
+### Reranking
+
+Reranking operations may be faked by invoking the `fake` method on the `Reranking` class:
+
+```php
+use Laravel\Ai\Reranking;
+use Laravel\Ai\Prompts\RerankingPrompt;
+use Laravel\Ai\Responses\Data\RankedDocument;
+
+// Automatically generate a fake reranked responses...
+Reranking::fake();
+
+// Provide custom responses...
+Reranking::fake([
+ [
+ new RankedDocument(index: 0, document: 'First', score: 0.95),
+ new RankedDocument(index: 1, document: 'Second', score: 0.80),
+ ],
+]);
+```
+
+After reranking, you may make assertions about the operations that were performed:
+
+```php
+Reranking::assertReranked(function (RerankingPrompt $prompt) {
+ return $prompt->contains('Laravel') && $prompt->limit === 5;
+});
+
+Reranking::assertNotReranked(
+ fn (RerankingPrompt $prompt) => $prompt->contains('Django')
+);
+
+Reranking::assertNothingReranked();
+```
+
+
+### Classification
+
+Classification may be faked by invoking the `fake` method on the `Classification` class. If no custom responses are provided, Laravel will automatically generate answers that match the shape of each question:
+
+```php
+use Laravel\Ai\Classification;
+use Laravel\Ai\Prompts\ClassificationPrompt;
+use Laravel\Ai\Responses\Data\BooleanAnswer;
+use Laravel\Ai\Responses\Data\ChoiceAnswer;
+
+// Automatically generate fake answers...
+Classification::fake();
+
+// Provide answers for specific questions...
+Classification::fake([
+ [
+ 'urgent' => new BooleanAnswer(0.94),
+ 'department' => new ChoiceAnswer('technical', [
+ 'billing' => 0.08,
+ 'technical' => 0.87,
+ 'sales' => 0.05,
+ ], confidence: 0.82),
+ ],
+]);
+
+// Build answers from the prompt...
+Classification::fake(fn (ClassificationPrompt $prompt) => [
+ 'urgent' => new BooleanAnswer($prompt->contains('ASAP') ? 1.0 : 0.0),
+]);
+```
+
+Questions that are omitted from a fake response will still receive a generated answer, so your tests only need to provide the answers they make assertions against.
+
+After classifying, you may make assertions about the operations that were performed:
+
+```php
+Classification::assertClassified(function (ClassificationPrompt $prompt) {
+ return $prompt->contains('refund') && $prompt->asks('department');
+});
+
+Classification::assertNotClassified(
+ fn (ClassificationPrompt $prompt) => $prompt->asks('sentiment')
+);
+
+Classification::assertNothingClassified();
+```
+
+
+### Files
+
+File operations may be faked by invoking the `fake` method on the `Files` class:
+
+```php
+use Laravel\Ai\Files;
+
+Files::fake();
+```
+
+Once file operations have been faked, you may make assertions about the uploads and deletions that occurred:
+
+```php
+use Laravel\Ai\Contracts\Files\StorableFile;
+use Laravel\Ai\Files\Document;
+
+// Store files...
+Document::fromString('Hello, Laravel!', mimeType: 'text/plain')
+ ->as('hello.txt')
+ ->put();
+
+// Make assertions...
+Files::assertStored(fn (StorableFile $file) =>
+ (string) $file === 'Hello, Laravel!' &&
+ $file->mimeType() === 'text/plain'
+);
+
+Files::assertNotStored(fn (StorableFile $file) =>
+ (string) $file === 'Hello, World!'
+);
+
+Files::assertNothingStored();
+```
+
+For asserting against file deletions, you may pass a file ID:
+
+```php
+Files::assertDeleted('file-id');
+Files::assertNotDeleted('file-id');
+Files::assertNothingDeleted();
+```
+
+
+### Vector Stores
+
+Vector store operations may be faked by invoking the `fake` method on the `Stores` class. Faking stores will also fake [file operations](#files) automatically:
+
+```php
+use Laravel\Ai\Stores;
+
+Stores::fake();
+```
+
+Once store operations have been faked, you may make assertions about the stores that were created or deleted:
+
+```php
+use Laravel\Ai\Stores;
+
+// Create store...
+$store = Stores::create('Knowledge Base');
+
+// Make assertions...
+Stores::assertCreated('Knowledge Base');
+
+Stores::assertCreated(fn (string $name, ?string $description) =>
+ $name === 'Knowledge Base'
+);
+
+Stores::assertNotCreated('Other Store');
+
+Stores::assertNothingCreated();
+```
+
+For asserting against store deletions, you may provide the store ID:
+
+```php
+Stores::assertDeleted('store_id');
+Stores::assertNotDeleted('other_store_id');
+Stores::assertNothingDeleted();
+```
+
+To assert files were added or removed from a store, use the assertion methods on a given `Store` instance:
+
+```php
+Stores::fake();
+
+$store = Stores::get('store_id');
+
+// Add / remove files...
+$store->add('added_id');
+$store->remove('removed_id');
+
+// Make assertions...
+$store->assertAdded('added_id');
+$store->assertRemoved('removed_id');
+
+$store->assertNotAdded('other_file_id');
+$store->assertNotRemoved('other_file_id');
+```
+
+If a file is stored in the provider's [file storage](#files) and added to a vector store in the same request, you may not know the file's provider ID. In this case, you can pass a closure to the `assertAdded` method to assert against the content of the added file:
+
+```php
+use Laravel\Ai\Contracts\Files\StorableFile;
+use Laravel\Ai\Files\Document;
+
+$store->add(Document::fromString('Hello, World!', 'text/plain')->as('hello.txt'));
+
+$store->assertAdded(fn (StorableFile $file) => $file->name() === 'hello.txt');
+$store->assertAdded(fn (StorableFile $file) => $file->content() === 'Hello, World!');
+```
+
+
+## Events
+
+The Laravel AI SDK dispatches a variety of [events](/docs/{{version}}/events), including:
+
+- `AddingFileToStore`
+- `AgentFailed`
+- `AgentFailedOver`
+- `AgentPrompted`
+- `AgentStreamed`
+- `AudioGenerated`
+- `Classified`
+- `Classifying`
+- `CreatingStore`
+- `EmbeddingsGenerated`
+- `FileAddedToStore`
+- `FileDeleted`
+- `FileRemovedFromStore`
+- `FileStored`
+- `GeneratingAudio`
+- `GeneratingEmbeddings`
+- `GeneratingImage`
+- `GeneratingTranscription`
+- `ImageGenerated`
+- `InvokingTool`
+- `PromptingAgent`
+- `ProviderFailedOver`
+- `RemovingFileFromStore`
+- `Reranked`
+- `Reranking`
+- `StartingStep`
+- `StepCompleted`
+- `StepFailed`
+- `StoreCreated`
+- `StoreDeleted`
+- `StoringFile`
+- `StreamingAgent`
+- `ToolApprovalRequested`
+- `ToolApprovalResolved`
+- `ToolFailed`
+- `ToolInvoked`
+- `TranscriptionGenerated`
+
+You can listen to any of these events to log or store AI SDK usage information.
From 3848ffe294c97003d5af63e90f7a2bd2526b3a59 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 05:33:45 +0000
Subject: [PATCH 02/22] Document AI SDK port plan and follow-up work
---
docs/plans/2026-10-07-ai-sdk-port.md | 309 +++++++++++++++++++++++++++
docs/todo.md | 5 +
2 files changed, 314 insertions(+)
create mode 100644 docs/plans/2026-10-07-ai-sdk-port.md
diff --git a/docs/plans/2026-10-07-ai-sdk-port.md b/docs/plans/2026-10-07-ai-sdk-port.md
new file mode 100644
index 000000000..254b80520
--- /dev/null
+++ b/docs/plans/2026-10-07-ai-sdk-port.md
@@ -0,0 +1,309 @@
+# AI SDK port and framework integration
+
+## Outcome and working boundaries
+
+Port Laravel AI `1.x` to `hypervel/ai`, retaining its optional MCP adapters. The owner has assigned the MCP package port to a separate session; it is not a prerequisite for this port. Preserve upstream application APIs, named arguments, protected extension points, protocols and test coverage while making execution safe and efficient in long-lived coroutine workers. Ordinary Eloquent/query-builder usage and ordinary agent calls keep their familiar behavior.
+
+Work in the `components-ai` worktree on `feature/ai`, based on `0.4`. Dependencies are independently installed and `.env` is copied. No source implementation has started. Follow the current monorepo instructions and the owner's governing `/home/binaryfire/workspace/contrib/hypervel/components/AGENTS.md`, including its dependency-access guidance even where the worktree copy differs. Maintain a separate remaining-file checklist during implementation rather than expanding this plan into a source inventory. Never stage, commit, push, merge another worktree, or alter historical plans without authorization.
+
+The public reference is the local `references/laravel/ai` clone on `1.x` and its current tests/config/resources. Recheck upstream changes before copying. Use `src/cache/{composer.json,README.md,LICENSE.md}` as the first-party package skeleton. Copy and adapt files alphabetically, one at a time, including tests and fixtures. Exclude Boost resources only; agent skills remain supported. Real MCP integration execution depends on the separately assigned package, as described in §8. Apply current dependency-injection guidance: retain upstream access patterns in closely ported classes; use injection where a substantive redesign benefits from it.
+
+Two external dependencies have different treatment:
+
+- The existing native HTTP implementation in the `components-http` worktree supplies the transport selection, named connection and ObjectPool architecture. Reconcile against that implementation when it becomes available on the working base; do not implement competing APIs, silently merge it, or edit its historical plan. This plan owns the additional incremental streaming work and its acceptance tests.
+- A proposed Swoole per-response disconnect notification is **not** a prerequisite. Implement all behavior possible on supported released runtimes. The missing immediate HTTP/2 reset notification is tracked in `docs/todo.md`; no polling workaround, unreleased runtime requirement or speculative native API belongs in this port.
+
+## Public behavior and compatibility
+
+Keep `Provider::__toString()`, `Provider::formatProviderAndModelList()`'s `name => model` return shape, `UntrustedUrl::resolveUsing()`, standalone conversation-store methods, queue options, custom gateways, middleware, stream event replay and protocol wire formats. In particular, upstream tests subclass `TextGenerationLoop::stepToolResults()` and `approvalAwareToolResults()`; do not add required protected parameters to pass invocation state.
+
+Apply these discussed differences and explain them proportionately:
+
+- `broadcast()` defaults to immediate delivery (`now: true`); retain `now: false` and queued generation. The default change and Filesystem contract additions are explicitly approved.
+- Release idle database leases before AI network waits. Transactions/cursors remain pinned automatically; deliberately session-dependent code spanning such a wait uses `withPinnedSession()`. This affects temporary tables, retained raw PDO/statements and session locks, not ordinary queries or framework session configurators.
+- Store-backed approval resumption requires `ClaimsPendingApprovals`. Custom stores without it still serve ordinary conversations but fail clearly before approved tools execute. Stateless resumption from caller-supplied history has no server record to claim; the application owns duplicate-submission prevention there.
+- Cache discovery only for configured fixed skill paths, with explicit flush/reload behavior and development watcher coverage.
+- Shipped migration types/indexes and new claim/replay columns differ from Laravel's schema; document migration of existing data rather than implying Laravel tables can be reused unchanged.
+
+Record deliberate lasting public differences briefly in the relevant package README and link to canonical docs. Explain affected edge cases in feature docs, and put actual migration/adaptation requirements in `src/docs/porting-from-laravel.md`. Do not catalogue internal optimizations or bug fixes as public API redesigns. Any additional compatibility change discovered during implementation needs owner approval.
+
+## Implementation order
+
+1. Audit and wire package/dependency metadata; establish the complete source/test port checklist with the explicit MCP boundary in §8.
+2. Implement database physical-session leasing, incremental HTTP streaming and existing-runtime streamed-response cancellation, plus the small framework contracts below. Their focused tests precede AI integration.
+3. Port AI, applying the ownership, provider, persistence, schema and cancellation designs below. Run each test file immediately after porting/changing it.
+4. Add the opt-in concurrency/broadcast features and bounded caches; validate behavior through public surfaces and realistic local providers.
+5. Port the imported `src/docs/ai-sdk.md` page to the final Hypervel implementation, then update related canonical docs, examples, facades, split-package wiring, CI and cleanup registrations; remove superseded source/comments/docs. Run required suites and the controlled performance checks.
+
+These are implementation dependencies, not reduced-scope releases. All required work must be complete before implementation signoff.
+
+## 1. Database connection ownership
+
+### Evidence and design
+
+`ConnectionResolver::connection()` borrows a `Pool\PooledConnection`, puts its mutable `Connection` in coroutine context, and releases it only in a coroutine-end defer. Query and schema builders retain that same object. `PooledConnection::release()` calls `resetForPool()`, clearing logical state such as callbacks, logs and sticky routing. Returning that wrapper early would allow another coroutine to mutate an object still held by the first caller.
+
+Split logical connection ownership from physical resource ownership:
+
+- One logical connection per coroutine and requested connection identity, of the existing concrete driver class, created through the existing factory/resolver extension paths. Builders retain this object through repeated borrow/release cycles.
+- Pool PDO session slots: a write PDO and lazily opened read PDO with existing pool health/lifetime/generation ownership. Preserve the current meaning of pool size and read/write aliases; do not accidentally double the configured pool capacity.
+- Logical PDO resolvers acquire a slot on first actual use and reacquire after early release. Keep sticky writes, selected read/write role, query listeners/logging/durations, transaction manager and reconnector on the logical object.
+- Each held lease settles exactly once. Coroutine-end cleanup settles the current owned lease, not a stale captured borrow. Early release must remove all references that let the logical object accidentally use a returned PDO.
+- PDO-backed subclasses participate through `PdoConnection`; non-PDO custom drivers retain their existing whole-connection ownership and early release is a no-op. Do not add new abstract methods to third-party drivers or replace concrete driver identities with a generic proxy.
+- Preserve non-coroutine task cleanup, connection-established events, custom connectors/resolvers, disconnect/purge/reconnect behavior, pooled SQLite's single shared in-memory database, and alias ownership. Repeated release/reborrow must not accumulate listeners or defers.
+
+Expose the additive operation:
+
+```php
+DB::releaseIdleConnections();
+
+DB::connection()->withPinnedSession(function () {
+ // Explicitly session-dependent operations may span external I/O here.
+});
+```
+
+`releaseIdleConnections()` acts on the current execution's connections. Skip transactions, active cursor/stream iteration, an in-progress driver operation and explicit nested pin scopes. Increment/decrement pin ownership in `try/finally`; do not expose a manual pin/unpin pair. `cursor()` stays pinned while suspended; `lazy()`/`chunk()` can release between completed query batches. A retained logical builder remains valid after release; deliberately retaining a raw PDO or PDOStatement across a release boundary requires the explicit scope.
+
+Keep physical session state in the existing `PdoConnection::$physicalSessionStates` WeakMap. `getPdo()`/`getReadPdo()` must synchronize configurators on every acquired physical session, applying only changed state and discarding unknown/failed sessions. Do not change session configurators to transaction-local SQL or claim new transaction-pooler support.
+
+Place AI release boundaries before each actual provider wait: text steps and stream startup, embeddings, images, audio, transcription, reranking, classification, files/stores and Bedrock credential/network operations. Include built-in network tools where they own the wait; custom tools can use the same DB API. No per-token release calls and no transactions around network operations. Avoid acquiring an otherwise unused database connection merely to release it.
+
+### Tests
+
+Extend database pool/lifecycle tests with retained query/schema builders across release and reacquisition; read/write aliases and sticky reads; custom PDO/non-PDO drivers; SQLite memory ownership; transactions, nested pins and abandoned cursors; cancellation/reconnect/setup failure; non-coroutine cleanup; and session-state changes between borrowers. Use a deliberately small pool with more concurrent fake-provider waits than slots to prove that completed history/auth queries do not monopolize the pool. Assert logical state preservation and exact lease settlement, not private storage layout.
+
+In `tests/Benchmarks/Database`, compare ordinary request database lifecycles against the unchanged baseline: logical construction, first query, acquisition/settlement and coroutine-end cleanup, at realistic concurrency. This redesign affects every database-using application. Require no unexplained throughput or allocation regression; optimize measured construction costs before considering any bounded wrapper recycling. Never recycle a logical object still retained by a builder/caller. Use the owner-arranged idle window required in §10.
+
+## 2. HTTP streaming and response cancellation
+
+### Incremental provider responses
+
+The current Guzzle StreamHandler path batches larger reads; simply replacing the SDK's byte reader with `fgets()`, `stream_get_line()` or `read(8192)` delays events. Fix the owning HTTP transport first, then use `Response::lines()`/`jsonLines()` from AI parsers.
+
+Extend the native HTTP transport architecture with a PSR response body that returns available chunks incrementally. Reuse named connection identities and existing ObjectPool/Coroutine ownership; gateways use `Http`, never raw cURL/socket implementations. Select the underlying supported engine using equivalent verified-TLS measurements and lifecycle correctness. Do not reverse the native branch's deliberate refusal of unsafe shared pending async operations merely because Guzzle exposes a promise API.
+
+Required behavior:
+
+- Bounded admission and buffering; a slow consumer applies backpressure without unbounded queued events. Deadlines account for pool/admission waits as well as network I/O.
+- Per-request headers, credentials, cookies, body and middleware remain operation-owned. Reused physical transports must not retain request secrets/callbacks; dynamic origins must not create an ever-growing worker registry.
+- Correct headers/informational responses, redirects, decompression, TLS verification, timeouts, middleware/fakes, stats and errors. Supported options that cannot use the native path retain a compatible fallback; do not silently ignore them or reject existing usage without approval.
+- EOF, explicit close, exceptions, cancellation and abandoned consumption settle resources once. If an owned producer exists, closing must actively stop and join it even when upstream is silent. Preserve pre-header errors and truncated-body errors instead of translating them to successful EOF.
+- Avoid redundant SDK-level `finally { close(); }` boilerplate where the owning response/generator chain already closes correctly. Test ownership before adding a close path.
+
+Use a stable named HTTP identity per configured provider so TLS connections can be reused. Do not derive names from tenant secrets, trace IDs or arbitrary per-request configurations. Dynamic provider credentials travel in request options, not a cached PendingRequest.
+
+### Caller disconnects on released runtimes
+
+Add `IterableStreamedResponse::cancelOnDisconnect(): static` as response-level opt-in. Default AI SSE and both stream protocols enable it. Scope registration to response production in `ResponseBridge`; remove it before terminable work. Do not cancel unrelated controller side effects, background jobs or every request globally.
+
+Where existing Swoole connection-close delivery is reliable, compose internal handling with the configured application callback at `Server::registerSwooleEvents()`. Preserve the application callback even if internal cleanup encounters an error. Register only in supported effective modes, avoiding Swoole startup warnings. Index active responses by connection identity and HTTP/2 stream ID, retaining per-response ownership tokens so cleanup removes only its own registration. HTTP/1 uses its connection entry without an HTTP/2 stream. Full connection close cancels every opted-in owner on that connection. Keep the map bounded by active responses; a future server-level stream-cancel event can select one stream through the same map.
+
+Current Swoole `RST_STREAM` handling has no PHP notification. A reset of one HTTP/2 stream while the provider is silent therefore remains detectable only by a subsequent failed write or operation completion/timeout. Document and test existing-runtime behavior honestly. The missing native capability is a server-level stream-cancel event identifying the connection and stream; a per-response native subscription is unnecessary. Keep this enhancement in `docs/todo.md` and integrate the actual released API when available; no per-stream polling, synthetic heartbeat solely for detection or dependency on an unaccepted/unreleased API.
+
+### Tests
+
+Extend HTTP/client/server suites using isolated loopback origins: paced SSE, split CRLF/UTF-8 lines, JSON lines, trailers, 1xx responses, truncated body, failures before headers, quiet upstream, slow consumer, explicit close and abandoned iteration. Assert cleanup and sibling-request progress under cancellation. Cover ordinary application close callbacks, opt-in boundaries and supported server modes; HTTP/2 reset tests must reflect the documented current limit. Reuse the native transport harness/origin rather than creating another benchmark server.
+
+Explicitly prove that cancelling a response producer interrupts the new streaming transport while it is waiting on a silent provider, both before headers and during a quiet response body. Synchronize the fixture so cancellation happens during the wait, without depending on a later provider chunk or timeout to unblock it. Assert that owned child work finishes, the upstream transfer is closed and its lease is settled, while sibling requests remain usable. Exercise the supported connection-close path through ResponseBridge as well as direct transport cancellation; merely observing a cancellation flag or dropping a generator is insufficient.
+
+## 3. AI lifetimes, providers and deferred execution
+
+### State ownership
+
+| State | Owner |
+|---|---|
+| Fixed configured provider definitions, safe gateways, immutable class metadata | Worker, bounded by application configuration/classes |
+| Agent instances and intrinsically caller-configured tool families | Fresh resolution (`Transient` where intrinsic to the hierarchy) |
+| On-demand/BYOK provider configuration and credentials | Operation/coroutine, never worker caches |
+| Parent invocation, repair setting and optional MCP request | Coroutine context with nested restoration |
+| Pending requests and stream protocol instances created with `new` | Existing caller-owned factory lifetime |
+| Active streaming bodies, promises, subprocesses and leases | The operation that starts them |
+
+Make the Agent contract extend `Transient`. Classify tools by real resolution paths: `FilesystemTool`, `AgentTool` and mutable approval/provider-tool families have caller-owned fluent configuration. Do not mark all tools or factory-created objects transient simply because they have mutable properties. Preserve worker-shared stateless middleware/services; document scoped/Transient alternatives for application middleware with operation-owned state.
+
+Move `ParentInvocation` and `TextGenerationLoop` repair settings into scoped context, with nested `finally` restoration behind the existing method signatures. The interleaved save/restore pattern on a shared property can leave the final shared value permanently wrong, not merely wrong during overlap.
+
+Accept `Provider` objects throughout prompt/stream/queue, pending generation, files/stores, macros and related contracts. Preserve the objects through failover and encrypted on-demand serialization rather than reducing them to names. Keep the public name/model formatting utility unchanged and introduce an internal object-preserving iterator. Explicit named lookups of `Ai::build()` results retain a coroutine-local registry; internal flows pass objects directly. Never cache resolver-backed instances merely by name for an entire coroutine: nested scopes can change identity within the same coroutine.
+
+Add these boot-only hooks on the `Ai` facade/AiManager, with individually owned callback slots, conflict checks, docblocks and test cleanup; no generic resolver registry:
+
+```php
+Ai::resolveProviderConfigUsing(
+ fn (string $name, array $baseConfig): array => $accounts->providerConfig($name, $baseConfig),
+);
+Ai::resolveConversationPartitionUsing('account_id', fn (): ?string => $accounts->currentId());
+Ai::resolveEmbeddingsCacheScopeUsing(fn (): ?string => $accounts->cacheScope());
+Ai::captureContextUsing($captureOperationContext);
+```
+
+Here `$accounts` and `$captureOperationContext` are application-supplied worker-safe services/callbacks, not new framework abstractions. The capture callback returns `?Closure`; the returned runner accepts a Closure and returns its result.
+
+Provider config is evaluated per operation, including default-provider and vector-query paths, without mutating global config. Return an array, not null; configured model defaults remain in that resolved record. No redundant model-default hook. Retain bounded reuse only for genuinely fixed configuration. Remove Octane/queue listeners that flush a global on-demand registry; classify remaining fake/gateway/global mutators as boot/tests only.
+
+Partition registration follows Permission's immutable boot registration: a second/conflicting/late registration throws; after registration null/empty resolution fails closed. Cache scope null means unscoped central use; an empty string is invalid and throws at operation identity resolution. Capture hook null means no wrapper. Keep each hook's documentation precise about registration and resolver lifetime.
+
+### Deferred operation context
+
+Capture a runner once at lazy operation creation. Its shape is `Closure(Closure): mixed`; the integration owns only the contextual state it understands. Apply it to generator creation and each resumption, AI-owned `each`/`then`/`catch` callbacks (including `then` after completion), event/broadcast callbacks and inner-generator disposal in the wrapper's `finally`. Restore the caller's context before outward yields; arbitrary application `foreach` bodies remain in the consumer's context. Include failover wrappers, sub-agent streaming and replay paths.
+
+The runner must represent a captured empty/central context too. Do not indiscriminately copy every coroutine-context entry. Synchronous calls use their caller's current context. Queue jobs use the framework's existing serialization/context lifecycle rather than serializing a stream runner.
+
+### Cancellation and exception correctness
+
+Trace real cancellation paths and rethrow `CanceledException` through AgentTool, approval execution, title generation, Bedrock exception conversion, filesystem tool catches and StreamProtocol masking. Clean up owned child operations. Do not add cancellation branches to unrelated catches.
+
+`TextGenerationOptions` intentionally ignores protected option methods and methods with required arguments. Use cached reflection to establish eligibility, then invoke valid methods without swallowing errors from their bodies. Preserve attribute fallback and dynamic option values; metadata caching must not cache an agent's runtime-returned value.
+
+## 4. Conversation storage, partitioning and approvals
+
+### Isolation and schema
+
+Implement generic opt-in row partitioning across `DatabaseConversationStore`, `Conversation`, `ConversationMessage`, their relationships, writes and model restoration. Filter reads, updates and deletes; stamp inserts; reject a model/message/conversation from a different resolved partition. A configured but unresolved partition fails closed. An application that has not registered partitioning continues using the unpartitioned schema. Use Permission's captured ownership/restoration patterns; a global scope alone is insufficient because model restoration can bypass scopes.
+
+Provide one protected `DatabaseConversationStore::ensureWriteAllowed(): void` extension point, called at its mutation boundaries including claims, result recording and turn settlement. Its default permits writes; a specialized store can enforce account lifecycle/read-only policies before raw-builder writes. A model observer cannot enforce those store writes, and a partition resolver must not block legitimate read access. Keep this a direct protected hook, not a general policy registry.
+
+Stock migrations use `uuid()`/`foreignUuid()`, `jsonb()` and nullable participant columns respecting `Builder::$defaultMorphKeyType`. Declare `participant_type` explicitly and choose `uuid`, `ulid` or `unsignedBigInteger` for `participant_id` with a migration-local match; do not use morph helpers that automatically add an index already covered by the composites below. Preserve configured table/connection names and message UUID ordering. On messages, add nullable UUID `approval_claim`, nullable timestamp `approval_claimed_at`, and boolean `has_replay_blocks` defaulting to false and maintained by the store. Keep replay content at `steps[].replay_blocks`; do not move/truncate payloads or change documented message accessors.
+
+Constrain `conversation_id` to the configured conversations table with `cascadeOnDelete()`. Upstream deletion leaves messages behind; `latestConversationId()` queries messages alone, so `continueLastConversation()` can resume a deleted conversation and append to its invisible orphaned history. The foreign key fixes ownership at storage rather than adding joins or model-only cleanup. Create a new conversation before its messages inside the atomic turn transaction. For existing schemas, document resolving orphaned rows before adding the constraint; do not silently delete or reassign historical data. Partitioned schemas use the corresponding same-partition composite foreign key.
+
+Indexes follow real query predicates:
+
+- Message history/pagination: `(conversation_id, id)`.
+- Conversations for a participant: `(participant_type, participant_id, updated_at)`, preserving upstream's index for `HasConversations::conversations()` and recency queries.
+- Latest conversation lookup is on the **messages** table: `(participant_type, participant_id, agent, id)`.
+- Paused/replay work: conversation plus the applicable status/replay predicates, then ordering as needed; validate plans and avoid redundant indexes.
+- Partitioned application schemas prepend their partition column to these indexes and enforce matching ownership in foreign keys. The stock migration stays unpartitioned, as Permission's does.
+
+InnoDB appends the primary key to secondary indexes; PostgreSQL does not, and SQLite's UUID primary key is not its rowid. Do not infer that every engine needs an identical redundant index or always sorts. Use EXPLAIN for the actual queries on supported drivers.
+
+### Atomic turn storage
+
+An ordinary upstream turn writes two messages and touches the conversation twice; writes are per turn, not per token. Add an optional `StoresConversationTurns` capability implemented by the database store:
+
+```php
+public function storeTurn(
+ string $conversationId,
+ ?string $newConversationTitle,
+ ?string $participantType,
+ string|int|null $participantId,
+ AgentPrompt $prompt,
+ AgentResponse $response,
+ ?Throwable $exception = null,
+): StoredTurn;
+```
+
+`StoredTurn` contains the conversation ID and nullable user/assistant message IDs. A non-null title requests creation with the supplied ID; preserve the pending ID already surfaced to stream protocols. Derive the user message from the prompt and omit it on approval resumption. One short store-owned transaction creates the conversation if necessary, stores the user/assistant or folds the paused row, cleans eligible replay data, and touches the conversation once. Generate the title before opening the transaction. Mutate agent/response stored IDs after commit. Apply the same atomic boundary to failed-turn persistence where a turn is recordable.
+
+Custom stores without this capability retain the existing sequential storage API. Do not wrap generic middleware in SQL facade transactions or impose SQL behavior on custom stores. Standalone store methods retain their behavior; internal helpers avoid repeating touches inside storeTurn.
+
+### Claim approved work before executing tools
+
+Add `Contracts\ClaimsPendingApprovals` and readonly `Approvals\ApprovalClaim` containing message ID and token:
+
+```php
+public function claimPendingApprovals(string $conversationId, array $toolCallIds): ?ApprovalClaim;
+public function recordApprovalResult(ApprovalClaim $claim, ToolResult $result): void;
+```
+
+Carry the claim on `AgentPrompt`, as with run context. Resolve and validate the exact paused row's pending-call set in the same short transaction that conditionally sets a token where the row is paused and unclaimed. Eager validation before a stream starts does not replace this check at actual execution. No claim for an unconsumed stream; no tool/network work while a DB lock is held.
+
+| Event | Required behavior |
+|---|---|
+| Claim succeeds | Execute that owned approval continuation. |
+| Already claimed/not resumable | Reject before tool side effects with the appropriate approval exception. |
+| Each completed/rejected tool | Merge its outcome under the claim token and row lock before starting the next side-effecting tool. Preserve edited arguments and result IDs. |
+| Completion, new pause or caught failure | Fold under the same token, preserve recorded outcomes and new steps, set status and clear the claim. Represent a started but unrecorded call as outcome unknown; do not make it executable again. |
+| Ordinary prompt while claimed | Use persisted outcomes plus in-memory “in progress or outcome unknown” results. Do not clear the claim, overwrite its steps or infer the owner died. Late owner results remain valid. |
+| Worker/process crash | Retain the claim. Reject automatic resumption of that claimed row; later history reports unknown outcomes rather than repeating effects. |
+
+Do not implement heartbeat/expiry/retry machinery for claims or promise exactly-once external effects. Existing tool-call IDs remain usable as provider/application idempotency keys. `settleAbandonedToolCalls()` only synthesizes messages; it does not prove another run is dead. Non-claimed abandoned pauses retain upstream behavior.
+
+Every read-modify-write of steps, including replay cleanup and legacy approval-result methods, must respect active claims and merge under a short row lock. Cleanup selects only rows with replay data and no active claim, then clears the marker atomically; previously emptied paused rows must not be fetched and decoded forever. Keep queries bounded to their actual conversation/partition.
+
+### Tests
+
+Port store/model/middleware/approval tests and extend them for partition reads/writes/restoration, unresolved scope, configured tables, UUID participants, rollback without partial turns, pending-ID preservation, one touch, concurrent double resume, result recording before the next tool, ordinary prompt racing a live claim, cancellation after an external effect, re-pause and retained hard-crash claims. Cover conversation deletion removing its messages, latest-conversation lookup selecting a remaining conversation or none, and explicit continuation of a deleted ID never recovering old history or persisting orphan messages. Verify the configured-table foreign key and atomic insertion order. Test custom stores with/without the capabilities and stateless resumption. Exercise locking on real supported databases, not only SQLite or facade mocks. Do not simulate a distributed recovery service that the design does not contain.
+
+## 5. Provider I/O and Bedrock
+
+Remote-file fetching uses Hypervel's Http destination policy, preserving host allowlists, redirects and DNS pinning. Keep the public custom resolver seam. Hooked DNS itself is not a blocking defect. Test redirects/private destinations and resolver overrides through the framework policy.
+
+Bedrock's current assume-role map hashes credentials plus all additional configuration: trace headers multiply retained STS clients, while timeout differences can reuse the first client's timeout. AWS default-chain creation also repeats work and its memoized pending promises cannot be concurrently driven by different coroutines.
+
+- Cache **fulfilled credentials** for bounded fixed configured identities. Coordinate refresh with the existing coroutine Mutex; recheck after acquisition, release in finally, and preserve timeout/cancellation. One owner creates and drives each STS/default-chain refresh; waiters receive their own settled promises. No queue dependency, distributed cache or cross-process refresh lock.
+- Keep on-demand/BYOK credentials operation-local. Credential identity includes credential-relevant role/source/region configuration, not per-request trace headers. A fresh refresh client avoids retaining the first request's timeout.
+- Create operation-owned Bedrock SDK clients/commands but route their `http_handler` through a named Hypervel Http connection. The gateway adapter preserves signed PSR requests/bodies, AWS error-array conventions, stats callbacks, retries and event-stream decoding. Use synchronous coroutine I/O and settled per-call promises for synchronous SDK calls; merely wrapping `buildClient()` in AWS's default `sendAsync` handler is insufficient.
+- Put request headers on the command's cloned handler list and timeouts in operation/command options. Reuse the underlying HTTP transport, not a second pool of credential-bearing SDK clients. Keep the AWS-to-Http adapter beside Bedrock because it has one consumer; no speculative shared integration package.
+- Test concurrent cold refresh, refresh expiry/failure/cancellation, header/timeout independence, static credentials, default chain, assume role, buffered/streamed requests and quiet-upstream cleanup. No live paid provider is needed for ownership tests.
+
+## 6. Caches, schema and aggregation
+
+### Embeddings
+
+Use one effective identity per operation: optional scope, provider name/driver, endpoint, model/deployment, dimensions, API version and result-affecting options/account routing. Include a non-secret account/project identity when available; otherwise hash credentials cryptographically once per operation. Never expose raw keys. Same-name dynamic providers within one scope must not collide across accounts; provider-file inputs are account-owned too.
+
+Compute normalized options/identity once per batch. Use seeded `xxh128` for internal input/key hashing, retaining cryptographic hashing at credential trust boundaries. Use `($scope === null ? '' : $scope . ':') . 'hypervel-embeddings:v1:' . $digest`, with a fixed-length digest of the structured effective identity/input. The trusted integration supplies its logical namespace; do not put secrets there. Keeping scope outside the digest lets integrations remove their owned cache entries. Document that literal key layout, preserve central/scoped separation, and never concatenate unhashed variable input fields ambiguously. Trace headers are not result identity.
+
+Encode vectors losslessly as little-endian float64 with a versioned base64 envelope. Raw packed bytes are incompatible with Redis JSON serialization; base64 is portable. Apply it to individual and whole-response caches, preserving ordering, duplicates, partial hits and response metadata. Keep the public array result and existing cache controls; use a new key version rather than misreading old JSON values. No float32 precision change or codec registry.
+
+Use existing cache multi-get/put APIs. Standalone Redis already has a single-script putMany; phpredis RedisCluster has no pipeline, and MULTI still exchanges a reply per command. Retain sequential Cluster TTL writes rather than multiplying pooled clients or forcing embedding keys into hot hash slots. Do not claim MGET across random Cluster slots is one server round trip.
+
+### Stable metadata, skills and response work
+
+- Extend existing `Hypervel\Support\ClassMetadataCache` in `src/reflection` with recursive trait membership and reuse existing reflection/attribute metadata. Cache class facts, not runtime agent options. Register static cleanup.
+- Configure `ai.skills.cached_paths`, defaulting to `[resource_path('skills')]`. Cache discovery/parsed immutable directory skills only for this finite boot-configured set, with flush/reload. Closure sources, arbitrary directories and caller-supplied skills retain dynamic semantics. Add `resources/skills/**` to watcher defaults and document custom watch paths.
+- Consolidate repeated stream-event aggregation into one owned aggregation pass, preserving text/reasoning block boundaries, citations, usage, step state, stored IDs and repeat iteration. Avoid duplicate Collections/large intermediate arrays. Preserve the public buffered/replay contract; do not claim streaming memory is constant while the API retains all events.
+- Guard optional event construction with `hasListeners()`, excluding broadcasts/jobs where dispatch is the actual side effect.
+- Remove obsolete AnyOf availability/string-class checks and `default: null` stripping. Generate schema fake data via `Type::toArray()`, serialize once and recurse, supporting UnionType/AnyOf and explicit null defaults while preserving fake return conventions. Add a small `ObjectType` property accessor for the MCP tool adapter's existing Type-object consumer; do not bind a closure into protected state.
+
+## 7. Optional parallelism and broadcasting
+
+Keep sequential tools as default. Add `#[ConcurrentTools(max: N)]` with positive bounded concurrency through framework primitives. Preserve returned tool order, approval decisions, coherent AgentTool streams and parent invocation IDs. Joined tool children execute under the operation's exact context runner, plus the required parent/repair state: reuse the captured runner for lazy operations, or call the capture hook at fan-out for synchronous operations. Enabling concurrency must not change the same tool's contextual permissions. Apply the runner inside child execution after framework startup propagation; restore on exit and cancel/join owned children on failure or cancellation. Separately launched background work retains normal framework propagation. Child database work has its own connection and does not inherit the parent's transaction.
+
+Keep title generation serial by default and add one opt-in boolean adjacent to `ai.conversations.generate_title`. Start concurrent title work only when the main call/stream is actually consumed and conversation persistence is eligible. Await it before the short persistence transaction; own its cleanup. A failed main call can still incur title cost when enabled, which needs documentation. Never start a paid title request for an unconsumed stream.
+
+Immediate broadcast is the approved default. Keep queued per-event delivery and whole-generation queues for their retry/durability/worker separation semantics. Add config-only opt-in delta batching with time and byte limits. Coalesce only adjacent compatible text/reasoning deltas with matching invocation/message/block identity; flush before structural events, completion and errors. Preserve order and scope. A time bound must also flush during upstream silence: use a bounded owned coroutine/channel/timer only when batching is enabled, and cancel/join it on every exit. Do not create a generic scheduler or add per-agent tuning machinery. Queued delivery retains the queue's ordering/retry semantics rather than claiming stronger ordering guarantees.
+
+## 8. MCP integration boundary
+
+Upstream AI lists `laravel/mcp` in development dependencies and suggestions, not runtime requirements. Ordinary agents, local AI tools, providers, streams, conversations and approvals run independently. The owner wants the entire MCP package audited and ported in a separate session with equivalent care.
+
+Keep `Tools/McpTool`, `Tools/McpServerTool`, `Tools/Concerns/NormalizesMcpResult`, `Schema/SchemaNormalizer` and both MCP adaptation branches in `GeneratesText::resolveTool()`. Their `Hypervel\Mcp` references remain optional and lazy, like upstream's references; no root runtime/development dependency on an unavailable package, eager resolution or placeholder production classes. Ordinary tool normalization, including nested ToolSearch, must work with MCP absent. Retain the optional feature suggestion with documentation that MCP tools require the separately available package.
+
+Fix `McpServerTool::handle()` using `CoroutineContext` keyed by its existing `MCP_REQUEST` constant (`Hypervel\Mcp\Request::class`), following the existing HTTP RequestContext's class-key convention, not global `Container::instance()`. Save the prior entry, install the call's request, and restore/forget it in finally after result/generator consumption. The later MCP provider resolves its Request from this same context without caching the resolved request for the worker or across nested calls. This narrow bridge needs no new general container override mechanism or additional unavailable context class.
+
+Port adapter tests/fixtures alongside AI, preserving their assertions. Run dependency-independent normalization/schema and MCP-absent behavior now. Tests requiring the actual package use an explicit dependency-availability check before loading MCP-derived fixtures, like the existing optional filesystem adapter tests; report them as unverified until the separate port supplies that dependency. This is the owner's deferred dependency, not permission to skip tests for implemented AI behavior. Do not weaken mixed test files' non-MCP coverage or pretend substitute MCP classes prove integration correctness.
+
+Preserve the adapters' upstream object/class-string boundaries. Where analysis cannot resolve the absent optional MCP symbols, use line- and identifier-scoped `@phpstan-ignore` annotations with the reason `optional MCP package`, following `FilesystemManager`'s optional-adapter precedent. Apply only to actual diagnostics after tracing the affected types; do not preemptively suppress every dynamic call. No neon patterns, analysis exclusions, stub files, eager production dependency or placeholder classes. Remove annotations made obsolete by the MCP port when that dependency becomes available; track this with its integration verification in `docs/todo.md`.
+
+Track the separate MCP port and integration verification in `docs/todo.md`. Its audit must cover shared request binding in MCP's server and nested tool/resource paths, lazy context ownership, connected-client/session lifetime, HTTP/stdio cleanup and bounded reuse of genuinely reusable sessions. Static credentials alone do not prove that a server session contains no caller-owned state. Do not choose a session-pooling design before that package's investigation.
+
+## 9. Package wiring and documentation
+
+- Wire root and split manifests: PSR-4, AI functions, replace entries, providers/aliases, actual runtime dependencies and optional feature suggestions. AI uses discovery, not DefaultProviders. Remove redundant concrete self-singleton bindings. Do not register upstream's currently unregistered ChatCommand without a deliberate decision.
+- Symfony YAML is runtime. AWS remains optional for the split AI package with its real minimum (`3.369.1` in the reference); update the root minimum consistently using Composer after checking current compatible versions. No MCP dependency is needed for this port. Do not add redundant bundled extension requirements or compatibility branches for unsupported library versions.
+- Add `Filesystem::fileExists()` and `directoryExists()` across contracts, concrete adapters, pooled/scoped proxies, instrumentation decorators and fixtures; `exists()` retains file-or-directory semantics. Then remove the AI capability/size fallback.
+- Ship typed config for all consumed settings, including conversations connection/tables/title behavior, embeddings TTL, remote allowed hosts, concurrency/batching options and skill paths. Defaults belong in config or one owning optional fallback constant, respecting shallow merges of replaceable nested records.
+- Port Pest/double tests to PHPUnit/Mockery using framework base cases. Put workbench classes/routes in package Fixtures and load them only where required. Preserve meaningful test coverage and approved exclusions; no blanket workbench/test omission.
+- Register process-global optional-package cleanup via grouped `callIfExists()` calls in `AfterEachTestSubscriber`; instance/context ownership is already reset by the framework. Add facade/type fixtures where public surfaces need them. Do not invent production observability APIs solely to inspect private test state.
+- Port the upstream AI SDK page already imported at `src/docs/ai-sdk.md` in place, after implementation is complete. Read it in full and update namespaces, installation/configuration, links, examples and behavior against the final code; retain applicable upstream explanations and MCP integration content, explaining its optional package requirement. Make the MCP section reflect the package's actual availability at merge: do not link to a missing MCP documentation page or present an unavailable package as installable. Finalize those links when the MCP package and page land. Add BYOK hooks, partitions and schema examples, custom stores/claims, unknown approval outcomes, queue choices, opt-in concurrency costs, stream context, cancellation limits and session pinning. Update the documentation navigation and related database, HTTP streaming, JSON schema, filesystem and watcher pages. Brief README differences and porting entries link to this canonical page. Remove all obsolete listener/config/shim descriptions after their code is removed; do not leave the imported page in its Laravel form or create a second AI documentation page.
+
+## 10. Verification and performance acceptance
+
+Run each changed/new test file immediately, then affected suites. For final broad framework changes use the required full verification workflow (`composer fix` where appropriate, including `composer test:parallel`); run `composer test:testbench` after Testbench changes. PHPStan checks source, not tests. External-service tests use existing isolation/skip traits and are added to the correct service workflows; real database locking tests cover MySQL/MariaDB/PostgreSQL and relevant SQLite behavior. Redis Cluster suites follow their serial isolation rules. Local loopback fixtures use assigned ports, bounded waits and unconditional cleanup.
+
+| Concern | Meaningful acceptance evidence |
+|---|---|
+| Worker/coroutine ownership | Interleaved same-name providers, fresh agents/configured tools, nested parent/repair restoration, failure/cancellation cleanup |
+| Deferred operations | Context A creation/B consumption, captured empty context, callbacks after completion, failover, abandoned generator disposal; consumer context restored |
+| Persistence | Real atomic claims/rollback, no network inside DB transactions, stable IDs, indexed history/cleanup, no repeated empty replay decoding |
+| Streaming | Incremental first-event delivery, slow-consumer bounds, errors not mistaken for EOF, silent-upstream close/cancellation, no orphan producer/lease |
+| Embeddings | Provider/account/scope isolation, vector-builder default path, partial/duplicate batch hits, lossless codec across cache serializers, whole-response metadata |
+| Public parity | Existing extension subclasses, provider formatting/string conversion, protocols/replay, fakes/macros/commands/jobs, optional split-package installs |
+| Optional concurrency | Maximum active tools, stable results, exact operation context in joined children, child ownership, stream coherence, serial defaults, no title work before consumption, timed batch flush while upstream is quiet |
+
+Maintain `tests/Benchmarks/Ai` for AI-level scenarios and reuse `tests/Benchmarks/HttpTransport` for transport origins. Measure cold/warm verified-TLS separately, paced/unpaced streams, complete start-to-first-event latency (including admission), CPU, RSS/heap, file descriptors, pool waits/hold durations, turn persistence, history/tool payload sizes, tenant/account churn and long-running cleanup. Count actual simultaneous active transfers, not total scheduled requests.
+
+Prior probes establish mechanisms, not acceptance thresholds: larger StreamHandler reads delay SSE; named transport reuse avoids repeated TLS setup; shared pending AWS promises are unsafe; old aggregation creates unnecessary passes/allocations; raw vector bytes fail Redis JSON serialization. Measurements mixed different TLS/setup conditions and machine load, so do not publish their ratios as expected gains. Notify the owner before benchmarks requiring an idle machine and wait for an agreed idle window. Functional tests use synchronization, not benchmark throughput assumptions. No brittle timing thresholds in CI.
+
+Acceptance requires no unbounded tenant-derived worker caches, leaked credentials/request state, orphan tasks or leases, avoidable round trips, or unexplained throughput/memory regression. Optimize measured costs at their owning layer; do not weaken API behavior, TLS verification or correctness to improve a benchmark.
diff --git a/docs/todo.md b/docs/todo.md
index 9e11cae10..6028de31e 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -36,12 +36,17 @@
- Investigate FTP support with Swoole's built-in coroutine FTP implementation. It supplies `ftp_*` functions but is not discoverable as `ext-ftp`, so Composer rejects `league/flysystem-ftp` and `RequiresPhpExtension('ftp')` skips the driver test. Resolve normal development and production installation without bypassing dependency checks, then add the adapter to root `require-dev`, use a test requirement that accepts either FTP implementation, and update the installation guidance. Basic transfers through Hypervel and Flysystem have been verified inside a Swoole coroutine.
+## AI and MCP
+
+- Port the MCP package in its separately assigned session, retaining the AI SDK's optional MCP adapters in the AI port. Audit client/session lifetimes and safe bounded reuse, HTTP/stdio cancellation and cleanup, coroutine-local request resolution through server/tool/resource paths, and lazy response iteration. Resolve the current MCP Request from CoroutineContext keyed by Request::class, shared with the AI adapter's MCP_REQUEST constant. Run the AI adapter's real MCP-dependent tests once the package is available and remove analysis annotations made obsolete by that dependency. Finalize the AI docs' MCP section and links when the MCP package and its documentation page land. AI's ordinary tools and providers must remain usable without MCP. Do not ship placeholder MCP classes or expand the AI port into the full MCP package.
+
## HTTP Client
- Once the HTTP client can optionally use Swoole's coroutine HTTP client as its transport instead of curl, explore and benchmark that transport for Inertia SSR requests. Each SSR render posts the page JSON to a local SSR server through the `inertia-ssr` connection (`HttpGateway::CONNECTION`), which makes it a good candidate for the alternative transport. Compare the curl and Swoole transports on that connection against a local SSR server under concurrent load, measuring latency, throughput, client CPU, memory and connection reuse, and use the Swoole transport for the SSR connection if it is a clear improvement.
## HTTP Server
+- Integrate a server-level HTTP/2 stream-cancel event once Swoole exposes it in a supported release, and raise the `ext-swoole` constraint to that release when integrating it. The current `RST_STREAM` path removes the stream without notifying PHP, so an AI response producer waiting on a silent provider cannot stop immediately when its caller cancels that stream. Use the event's connection and stream identities with Hypervel's active-response map to cancel only the affected opted-in producer. Preserve sibling streams, application close callbacks and terminable work. Whole-connection closes continue using existing onClose handling; no native per-response subscription is needed. Keep the AI package usable on existing supported Swoole releases with connection-close handling where available, failed-write cleanup and provider timeouts; document the remaining HTTP/2 limitation. This enhancement is not an AI-port release prerequisite. Use the actual released event name and arguments rather than polling or anticipating an unreleased API.
- Require a Swoole release that resets signal-listener state in forked server workers before releasing Hypervel 0.4. In Swoole 6.2.3 and earlier, a worker forked after the manager calls `Process::signal()` inherits the listener count, so `Coroutine\System::waitSignal()` fails in it. Hypervel's SIGINT shutdown handling registers a manager callback in both server modes, so after a reload, `max_request` recycling or a crash restart, replacement workers stop receiving configured signal handlers and Artisan traps. Once a fixed release is verified, raise the `ext-swoole` constraint and remove the version skip from `ShutdownOnInterruptListenerTest::testReplacementWorkersKeepTheirSignalHandlers()`.
- Remove trailer-stream one-chunk lookahead once the minimum supported Swoole release includes [swoole-src#6124](https://github.com/swoole/swoole-src/pull/6124). Current releases send an empty `END_STREAM` DATA frame before trailer HEADERS when `end()` receives no body after `write()`, so `ResponseBridge` retains the final chunk for `end($chunk)` and delays delivery by one chunk. Once fixed, raise the `ext-swoole` constraint, write every chunk immediately, emit trailers, call bare `end()`, invert the deterministic bridge ordering tests, and add real gRPC incremental-delivery coverage.
From 680c97ca4e9a70f20790324978ae70bd336bd50d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 06:05:22 +0000
Subject: [PATCH 03/22] Define flexible AI port review checkpoints
Group the public implementation into seven review checkpoints with explicit implementer and reviewer responsibilities. Allow dependencies to move forward while requiring complete files, meaningful tests and signoff before advancing. Preserve copy-first porting and permit coherent whole-file commits with detailed rationale and validation.
Build incremental HTTP streaming against the current framework handler boundaries and existing tests. Remove the unrelated transport-worktree prerequisite while preserving cancellation, resource ownership and performance acceptance requirements.
Validation: reviewed both plan updates and ran git diff --check. Documentation-only change; no source tests or benchmarks were run.
---
docs/plans/2026-10-07-ai-sdk-port.md | 41 ++++++++++++++++------------
1 file changed, 24 insertions(+), 17 deletions(-)
diff --git a/docs/plans/2026-10-07-ai-sdk-port.md b/docs/plans/2026-10-07-ai-sdk-port.md
index 254b80520..a3bc1992d 100644
--- a/docs/plans/2026-10-07-ai-sdk-port.md
+++ b/docs/plans/2026-10-07-ai-sdk-port.md
@@ -4,14 +4,11 @@
Port Laravel AI `1.x` to `hypervel/ai`, retaining its optional MCP adapters. The owner has assigned the MCP package port to a separate session; it is not a prerequisite for this port. Preserve upstream application APIs, named arguments, protected extension points, protocols and test coverage while making execution safe and efficient in long-lived coroutine workers. Ordinary Eloquent/query-builder usage and ordinary agent calls keep their familiar behavior.
-Work in the `components-ai` worktree on `feature/ai`, based on `0.4`. Dependencies are independently installed and `.env` is copied. No source implementation has started. Follow the current monorepo instructions and the owner's governing `/home/binaryfire/workspace/contrib/hypervel/components/AGENTS.md`, including its dependency-access guidance even where the worktree copy differs. Maintain a separate remaining-file checklist during implementation rather than expanding this plan into a source inventory. Never stage, commit, push, merge another worktree, or alter historical plans without authorization.
+Work in the `components-ai` worktree on `feature/ai`, based on `0.4`. Dependencies are independently installed and `.env` is copied. No source implementation has started. Follow the current monorepo instructions and the owner's governing `/home/binaryfire/workspace/contrib/hypervel/components/AGENTS.md`, including its dependency-access guidance even where the worktree copy differs. Maintain a separate remaining-file checklist during implementation rather than expanding this plan into a source inventory. The implementer is authorized to commit signed-off checkpoints under the workflow below; pushing, merging another worktree and altering historical plans remain unauthorized.
-The public reference is the local `references/laravel/ai` clone on `1.x` and its current tests/config/resources. Recheck upstream changes before copying. Use `src/cache/{composer.json,README.md,LICENSE.md}` as the first-party package skeleton. Copy and adapt files alphabetically, one at a time, including tests and fixtures. Exclude Boost resources only; agent skills remain supported. Real MCP integration execution depends on the separately assigned package, as described in §8. Apply current dependency-injection guidance: retain upstream access patterns in closely ported classes; use injection where a substantive redesign benefits from it.
+The public reference is the local `references/laravel/ai` clone on `1.x` and its current tests/config/resources. Recheck upstream changes before copying. Use `src/cache/{composer.json,README.md,LICENSE.md}` as the first-party package skeleton. Within dependency groups, port files alphabetically, one at a time: `cp` the upstream file, read the entire copy, then make targeted edits. Apply this to source, tests, fixtures and documents; only genuinely new Hypervel-specific files are written fresh. Exclude Boost resources only; agent skills remain supported. Real MCP integration execution depends on the separately assigned package, as described in §8. Apply current dependency-injection guidance: retain upstream access patterns in closely ported classes; use injection where a substantive redesign benefits from it.
-Two external dependencies have different treatment:
-
-- The existing native HTTP implementation in the `components-http` worktree supplies the transport selection, named connection and ObjectPool architecture. Reconcile against that implementation when it becomes available on the working base; do not implement competing APIs, silently merge it, or edit its historical plan. This plan owns the additional incremental streaming work and its acceptance tests.
-- A proposed Swoole per-response disconnect notification is **not** a prerequisite. Implement all behavior possible on supported released runtimes. The missing immediate HTTP/2 reset notification is tracked in `docs/todo.md`; no polling workaround, unreleased runtime requirement or speculative native API belongs in this port.
+Implement the database and HTTP changes directly against this worktree's framework code. The proposed Swoole server-level HTTP/2 stream-cancel event is **not** a prerequisite. Implement all behavior possible on supported released runtimes. The missing immediate HTTP/2 reset notification is tracked in `docs/todo.md`; no polling workaround, unreleased runtime requirement or speculative native API belongs in this port.
## Public behavior and compatibility
@@ -27,15 +24,25 @@ Apply these discussed differences and explain them proportionately:
Record deliberate lasting public differences briefly in the relevant package README and link to canonical docs. Explain affected edge cases in feature docs, and put actual migration/adaptation requirements in `src/docs/porting-from-laravel.md`. Do not catalogue internal optimizations or bug fixes as public API redesigns. Any additional compatibility change discovered during implementation needs owner approval.
-## Implementation order
+## Implementation checkpoints
+
+Establish the source/test checklist and dependency metadata first. Use these review boundaries for the public work:
+
+| Checkpoint | Completed work to review |
+|---|---|
+| 1 | Database connection ownership: physical leases, early release, pinning and lifecycle tests. |
+| 2 | HTTP streaming and cancellation: incremental transport, response ownership, disconnect handling and tests. |
+| 3 | AI foundations: package wiring, manager, agents, provider configuration, BYOK and coroutine/deferred context. |
+| 4 | Provider implementations: generation gateways across modalities, files/stores and Bedrock. |
+| 5 | Tools and streaming features: tools, skills, schemas, optional MCP adapters, concurrent execution and broadcasting. |
+| 6 | Conversation persistence and approvals: partitioning, atomic turns, claims, failure handling and tests. |
+| 7 | Public package completion: remaining caching/optimizations, in-place port of `src/docs/ai-sdk.md`, related docs, full framework validation and controlled performance checks. |
-1. Audit and wire package/dependency metadata; establish the complete source/test port checklist with the explicit MCP boundary in §8.
-2. Implement database physical-session leasing, incremental HTTP streaming and existing-runtime streamed-response cancellation, plus the small framework contracts below. Their focused tests precede AI integration.
-3. Port AI, applying the ownership, provider, persistence, schema and cancellation designs below. Run each test file immediately after porting/changing it.
-4. Add the opt-in concurrency/broadcast features and bounded caches; validate behavior through public surfaces and realistic local providers.
-5. Port the imported `src/docs/ai-sdk.md` page to the final Hypervel implementation, then update related canonical docs, examples, facades, split-package wiring, CI and cleanup registrations; remove superseded source/comments/docs. Run required suites and the controlled performance checks.
+Boundaries are flexible review groupings, not partial releases or rigid file assignments. Bring dependencies forward and complete them when needed; aim to finish each file in one pass, including its approved optimizations and meaningful tests. Do not create temporary implementations, placeholder methods or deliberately unfinished files to fit a checkpoint. Revisit completed files when integration, evidence or review requires it. Keep only remaining work in the checklist as scope moves, and include all brought-forward changes in the current review.
-These are implementation dependencies, not reduced-scope releases. All required work must be complete before implementation signoff.
+- **Implementer:** run every new/changed test file immediately, then the affected checks required by this plan and `AGENTS.md`. Keep directly affected docs accurate with each change; checkpoint 7 completes the canonical AI page and overall documentation audit. Request code review from the assigned peer after each completed checkpoint, supplying the full changes since the previous signoff and validation results. Investigate feedback, push back on incorrect or unnecessary suggestions, fix accepted findings, and loop until explicit signoff before committing or starting the next checkpoint.
+- **Reviewer:** review the actual checkpoint scope, including brought-forward dependencies and reported validation. Check correctness, API compatibility, coroutine/resource ownership, performance, Laravel ergonomics and unnecessary code/tests. Request missing verification from the implementer rather than rerunning checks; do not implement or commit in the reviewer role.
+- **Implementer, after signoff:** commit the reviewed work in its owning repository. Use multiple coherent commits when useful, with a detailed body for each explaining the problem, decisions, resulting behavior and relevant validation. Stage whole files only; never split hunks or temporarily rewrite a file to manufacture commit boundaries. Keep inseparable changes together, exclude unrelated work, and do not push. Checkpoint signoff does not replace the final complete-package verification in §10.
## 1. Database connection ownership
@@ -80,13 +87,13 @@ In `tests/Benchmarks/Database`, compare ordinary request database lifecycles aga
The current Guzzle StreamHandler path batches larger reads; simply replacing the SDK's byte reader with `fgets()`, `stream_get_line()` or `read(8192)` delays events. Fix the owning HTTP transport first, then use `Response::lines()`/`jsonLines()` from AI parsers.
-Extend the native HTTP transport architecture with a PSR response body that returns available chunks incrementally. Reuse named connection identities and existing ObjectPool/Coroutine ownership; gateways use `Http`, never raw cURL/socket implementations. Select the underlying supported engine using equivalent verified-TLS measurements and lifecycle correctness. Do not reverse the native branch's deliberate refusal of unsafe shared pending async operations merely because Guzzle exposes a promise API.
+Implement incremental streaming in this worktree's existing HTTP client, with a PSR response body that returns available chunks as they arrive. Integrate at `PendingRequest::buildHandlerStack()` and the factory's existing named-connection handler boundary, preserving Guzzle middleware and caller-supplied handlers/clients. Use framework Coroutine/ObjectPool primitives where ownership requires them; AI gateways use `Http`, never package-owned cURL/socket implementations. Validate the streaming implementation with equivalent verified-TLS measurements and lifecycle tests. Pending transfers and promises remain operation-owned rather than shared across coroutines.
Required behavior:
- Bounded admission and buffering; a slow consumer applies backpressure without unbounded queued events. Deadlines account for pool/admission waits as well as network I/O.
- Per-request headers, credentials, cookies, body and middleware remain operation-owned. Reused physical transports must not retain request secrets/callbacks; dynamic origins must not create an ever-growing worker registry.
-- Correct headers/informational responses, redirects, decompression, TLS verification, timeouts, middleware/fakes, stats and errors. Supported options that cannot use the native path retain a compatible fallback; do not silently ignore them or reject existing usage without approval.
+- Correct headers/informational responses, redirects, decompression, TLS verification, timeouts, middleware/fakes, stats and errors. Preserve supported options through the streaming handler or a compatible fallback; do not silently ignore them or reject existing usage without approval. Update transport capability guards, including the current `allow_url_fopen` check, to reflect the actual handler requirements.
- EOF, explicit close, exceptions, cancellation and abandoned consumption settle resources once. If an owned producer exists, closing must actively stop and join it even when upstream is silent. Preserve pre-header errors and truncated-body errors instead of translating them to successful EOF.
- Avoid redundant SDK-level `finally { close(); }` boilerplate where the owning response/generator chain already closes correctly. Test ownership before adding a close path.
@@ -102,7 +109,7 @@ Current Swoole `RST_STREAM` handling has no PHP notification. A reset of one HTT
### Tests
-Extend HTTP/client/server suites using isolated loopback origins: paced SSE, split CRLF/UTF-8 lines, JSON lines, trailers, 1xx responses, truncated body, failures before headers, quiet upstream, slow consumer, explicit close and abandoned iteration. Assert cleanup and sibling-request progress under cancellation. Cover ordinary application close callbacks, opt-in boundaries and supported server modes; HTTP/2 reset tests must reflect the documented current limit. Reuse the native transport harness/origin rather than creating another benchmark server.
+Extend the existing `HttpClientStreamingTest`, `HttpClientResponseStreamTest` and related client/server suites using isolated loopback origins: paced SSE, split CRLF/UTF-8 lines, JSON lines, trailers, 1xx responses, truncated body, failures before headers, quiet upstream, slow consumer, explicit close and abandoned iteration. Assert cleanup and sibling-request progress under cancellation. Cover ordinary application close callbacks, opt-in boundaries and supported server modes; HTTP/2 reset tests must reflect the documented current limit. Reuse and extend this worktree's streaming fixtures rather than introducing duplicate test servers.
Explicitly prove that cancelling a response producer interrupts the new streaming transport while it is waiting on a silent provider, both before headers and during a quiet response body. Synchronize the fixture so cancellation happens during the wait, without depending on a later provider chunk or timeout to unblock it. Assert that owned child work finishes, the upstream transfer is closed and its lease is settled, while sibling requests remain usable. Exercise the supported connection-close path through ResponseBridge as well as direct transport cancellation; merely observing a cancellation flag or dropping a generator is insufficient.
@@ -302,7 +309,7 @@ Run each changed/new test file immediately, then affected suites. For final broa
| Public parity | Existing extension subclasses, provider formatting/string conversion, protocols/replay, fakes/macros/commands/jobs, optional split-package installs |
| Optional concurrency | Maximum active tools, stable results, exact operation context in joined children, child ownership, stream coherence, serial defaults, no title work before consumption, timed batch flush while upstream is quiet |
-Maintain `tests/Benchmarks/Ai` for AI-level scenarios and reuse `tests/Benchmarks/HttpTransport` for transport origins. Measure cold/warm verified-TLS separately, paced/unpaced streams, complete start-to-first-event latency (including admission), CPU, RSS/heap, file descriptors, pool waits/hold durations, turn persistence, history/tool payload sizes, tenant/account churn and long-running cleanup. Count actual simultaneous active transfers, not total scheduled requests.
+Add `tests/Benchmarks/Ai` for AI-level scenarios and `tests/Benchmarks/HttpTransport` for transport scenarios, sharing origin fixtures where applicable. Measure cold/warm verified-TLS separately, paced/unpaced streams, complete start-to-first-event latency (including admission), CPU, RSS/heap, file descriptors, pool waits/hold durations, turn persistence, history/tool payload sizes, tenant/account churn and long-running cleanup. Count actual simultaneous active transfers, not total scheduled requests.
Prior probes establish mechanisms, not acceptance thresholds: larger StreamHandler reads delay SSE; named transport reuse avoids repeated TLS setup; shared pending AWS promises are unsafe; old aggregation creates unnecessary passes/allocations; raw vector bytes fail Redis JSON serialization. Measurements mixed different TLS/setup conditions and machine load, so do not publish their ratios as expected gains. Notify the owner before benchmarks requiring an idle machine and wait for an agreed idle window. Functional tests use synchronization, not benchmark throughput assumptions. No brittle timing thresholds in CI.
From 7fc20b32be14b7c6c50ddc897b50b03c22853a8f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 09:35:13 +0000
Subject: [PATCH 04/22] Separate logical database connections from pooled PDO
sessions
Keep coroutine-owned concrete connections stable while allowing idle physical sessions to return to the pool before long external waits. Add releaseIdleConnections and scoped session pinning, preserving retained builders, sticky routing, query state, transactions, cursors and event callbacks.
Keep config-first extensions and endpoints with different logical identities under whole-connection ownership. Select read endpoints per physical generation, preserve selected configuration metadata, settle leases exactly once, and clear hidden PDO holders promptly on disconnect and failed replacement. Preserve the current owner during terminal callbacks and forget lost resources before rollback callbacks can reconnect.
Cover ownership, read/write routing, custom drivers, session configuration, failure recovery and teardown with regression tests. Document extension and pinning behavior and add an independently runnable database lifecycle benchmark. Benchmarks remain required before opening the framework PR; reviewed checkpoints may proceed before the idle measurement window.
Validation of the complete checkpoint: formatting and source/type PHPStan passed; 40,181 framework tests, 632 Testbench tests and 6 package-consumer tests passed with expected service skips.
---
docs/plans/2026-10-07-ai-sdk-port.md | 35 +-
.../src/Concerns/ManagesTransactions.php | 5 +-
src/database/src/Connection.php | 232 ++++----
src/database/src/ConnectionResolver.php | 117 ++--
.../src/Connectors/ConnectionFactory.php | 32 ++
src/database/src/DatabaseManager.php | 19 +-
src/database/src/PdoConnection.php | 169 ++++--
src/database/src/Pool/ConnectionLease.php | 157 ++++++
src/database/src/Pool/DatabasePool.php | 89 ++-
src/database/src/Pool/PooledConnection.php | 271 +++++++---
src/docs/database.md | 34 +-
src/support/src/Facades/DB.php | 2 +
tests/Benchmarks/Database/README.md | 21 +
tests/Benchmarks/Database/benchmark.php | 210 ++++++++
tests/Database/ConnectionResolverTest.php | 42 +-
.../DatabaseConnectionFactoryTest.php | 40 ++
.../DatabaseConnectionLeaseLifecycleTest.php | 153 ++++++
.../Database/DatabaseConnectionLeaseTest.php | 508 ++++++++++++++++++
tests/Database/DatabasePdoConnectionTest.php | 86 ++-
tests/Database/DatabaseReadPoolTest.php | 132 +++++
tests/Database/PoolManagerTest.php | 4 +-
.../Database/PooledConnectionTest.php | 19 +-
.../Sqlite/ConnectionEstablishedTest.php | 23 +-
types/Database/Connection.php | 1 +
24 files changed, 2083 insertions(+), 318 deletions(-)
create mode 100644 src/database/src/Pool/ConnectionLease.php
create mode 100644 tests/Benchmarks/Database/README.md
create mode 100644 tests/Benchmarks/Database/benchmark.php
create mode 100644 tests/Database/DatabaseConnectionLeaseLifecycleTest.php
create mode 100644 tests/Database/DatabaseConnectionLeaseTest.php
create mode 100644 tests/Database/DatabaseReadPoolTest.php
diff --git a/docs/plans/2026-10-07-ai-sdk-port.md b/docs/plans/2026-10-07-ai-sdk-port.md
index a3bc1992d..9104896de 100644
--- a/docs/plans/2026-10-07-ai-sdk-port.md
+++ b/docs/plans/2026-10-07-ai-sdk-port.md
@@ -4,7 +4,9 @@
Port Laravel AI `1.x` to `hypervel/ai`, retaining its optional MCP adapters. The owner has assigned the MCP package port to a separate session; it is not a prerequisite for this port. Preserve upstream application APIs, named arguments, protected extension points, protocols and test coverage while making execution safe and efficient in long-lived coroutine workers. Ordinary Eloquent/query-builder usage and ordinary agent calls keep their familiar behavior.
-Work in the `components-ai` worktree on `feature/ai`, based on `0.4`. Dependencies are independently installed and `.env` is copied. No source implementation has started. Follow the current monorepo instructions and the owner's governing `/home/binaryfire/workspace/contrib/hypervel/components/AGENTS.md`, including its dependency-access guidance even where the worktree copy differs. Maintain a separate remaining-file checklist during implementation rather than expanding this plan into a source inventory. The implementer is authorized to commit signed-off checkpoints under the workflow below; pushing, merging another worktree and altering historical plans remain unauthorized.
+Work in the `components-ai` worktree on `feature/ai`, based on `0.4`. Dependencies are independently installed and `.env` is copied. Follow the current monorepo instructions and the owner's governing `/home/binaryfire/workspace/contrib/hypervel/components/AGENTS.md`, including its dependency-access guidance even where the worktree copy differs. Maintain a separate remaining-file checklist during implementation rather than expanding this plan into a source inventory. The implementer is authorized to commit signed-off checkpoints under the workflow below; pushing, merging another worktree and altering historical plans remain unauthorized.
+
+**Current delivery boundary:** complete all changes to existing framework packages before starting the AI package, so they can form a separate framework PR. Bring forward filesystem, JSON schema, reflection metadata, watcher and other supporting framework changes from later checkpoints. Finish checks, self-review and peer code-review loops for that entire framework scope, commit signed-off work, then notify the owner and stop. Do not begin AI source porting or create/push the PR until instructed to continue.
The public reference is the local `references/laravel/ai` clone on `1.x` and its current tests/config/resources. Recheck upstream changes before copying. Use `src/cache/{composer.json,README.md,LICENSE.md}` as the first-party package skeleton. Within dependency groups, port files alphabetically, one at a time: `cp` the upstream file, read the entire copy, then make targeted edits. Apply this to source, tests, fixtures and documents; only genuinely new Hypervel-specific files are written fresh. Exclude Boost resources only; agent skills remain supported. Real MCP integration execution depends on the separately assigned package, as described in §8. Apply current dependency-injection guidance: retain upstream access patterns in closely ported classes; use injection where a substantive redesign benefits from it.
@@ -44,22 +46,15 @@ Boundaries are flexible review groupings, not partial releases or rigid file ass
- **Reviewer:** review the actual checkpoint scope, including brought-forward dependencies and reported validation. Check correctness, API compatibility, coroutine/resource ownership, performance, Laravel ergonomics and unnecessary code/tests. Request missing verification from the implementer rather than rerunning checks; do not implement or commit in the reviewer role.
- **Implementer, after signoff:** commit the reviewed work in its owning repository. Use multiple coherent commits when useful, with a detailed body for each explaining the problem, decisions, resulting behavior and relevant validation. Stage whole files only; never split hunks or temporarily rewrite a file to manufacture commit boundaries. Keep inseparable changes together, exclude unrelated work, and do not push. Checkpoint signoff does not replace the final complete-package verification in §10.
-## 1. Database connection ownership
-
-### Evidence and design
+Code-reviewed checkpoints may be committed and subsequent work may proceed before performance measurements. Benchmarks remain mandatory before opening the framework PR; obtain peer review of the results and make any required improvements in additional reviewed commits. Use an owner-confirmed idle window.
-`ConnectionResolver::connection()` borrows a `Pool\PooledConnection`, puts its mutable `Connection` in coroutine context, and releases it only in a coroutine-end defer. Query and schema builders retain that same object. `PooledConnection::release()` calls `resetForPool()`, clearing logical state such as callbacks, logs and sticky routing. Returning that wrapper early would allow another coroutine to mutate an object still held by the first caller.
+## 1. Database connection ownership
-Split logical connection ownership from physical resource ownership:
+### Completed framework surface and downstream requirements
-- One logical connection per coroutine and requested connection identity, of the existing concrete driver class, created through the existing factory/resolver extension paths. Builders retain this object through repeated borrow/release cycles.
-- Pool PDO session slots: a write PDO and lazily opened read PDO with existing pool health/lifetime/generation ownership. Preserve the current meaning of pool size and read/write aliases; do not accidentally double the configured pool capacity.
-- Logical PDO resolvers acquire a slot on first actual use and reacquire after early release. Keep sticky writes, selected read/write role, query listeners/logging/durations, transaction manager and reconnector on the logical object.
-- Each held lease settles exactly once. Coroutine-end cleanup settles the current owned lease, not a stale captured borrow. Early release must remove all references that let the logical object accidentally use a returned PDO.
-- PDO-backed subclasses participate through `PdoConnection`; non-PDO custom drivers retain their existing whole-connection ownership and early release is a no-op. Do not add new abstract methods to third-party drivers or replace concrete driver identities with a generic proxy.
-- Preserve non-coroutine task cleanup, connection-established events, custom connectors/resolvers, disconnect/purge/reconnect behavior, pooled SQLite's single shared in-memory database, and alias ownership. Repeated release/reborrow must not accumulate listeners or defers.
+Logical connections now remain owned by their execution while physical PDO sessions can return to the pool between operations. Retained builders, sticky routing, logs and callbacks survive early release. Initial resolution still borrows a slot; later use reacquires lazily. Lifecycle, read-endpoint selection, physical-handle cleanup and transaction-context isolation fixes are implemented with regression coverage. See the source and database documentation for their internal details.
-Expose the additive operation:
+Use the additive operations:
```php
DB::releaseIdleConnections();
@@ -69,17 +64,17 @@ DB::connection()->withPinnedSession(function () {
});
```
-`releaseIdleConnections()` acts on the current execution's connections. Skip transactions, active cursor/stream iteration, an in-progress driver operation and explicit nested pin scopes. Increment/decrement pin ownership in `try/finally`; do not expose a manual pin/unpin pair. `cursor()` stays pinned while suspended; `lazy()`/`chunk()` can release between completed query batches. A retained logical builder remains valid after release; deliberately retaining a raw PDO or PDOStatement across a release boundary requires the explicit scope.
+`releaseIdleConnections()` acts only on the current execution's existing connections. Transactions, cursors, complete query/event callbacks, scoped FK suppression and explicit pin scopes prevent voluntary release. Pins do not prevent replacing a broken session. `lazy()`/`chunk()` may release between completed batches. Retained raw PDO/statements or manually changed SQL session state spanning release require `withPinnedSession()`; ordinary builders do not. Existing session configurators synchronize on acquired physical sessions; this does not add transaction-pooler support.
-Keep physical session state in the existing `PdoConnection::$physicalSessionStates` WeakMap. `getPdo()`/`getReadPdo()` must synchronize configurators on every acquired physical session, applying only changed state and discarding unknown/failed sessions. Do not change session configurators to transaction-local SQL or claim new transaction-pooler support.
+`DB::extend()` drivers retain whole-connection ownership and early release is a no-op. The same applies when selectable records differ in database or table prefix: write records for base pools, read records for `::read` pools. PDO-first custom drivers participate through `Connection::resolverFor()` and, when needed, `db.connector.{driver}`. Preserve concrete connection identities and these extension boundaries.
-Place AI release boundaries before each actual provider wait: text steps and stream startup, embeddings, images, audio, transcription, reranking, classification, files/stores and Bedrock credential/network operations. Include built-in network tools where they own the wait; custom tools can use the same DB API. No per-token release calls and no transactions around network operations. Avoid acquiring an otherwise unused database connection merely to release it.
+Child coroutines own separate connections and transaction records. `DatabaseTransactionState` is non-copyable; Testbench explicitly transfers the same bag across setup/test/teardown. Do not copy transaction ownership into AI child work.
-### Tests
+Place AI release boundaries before each actual provider wait: text steps and stream startup, embeddings, images, audio, transcription, reranking, classification, files/stores and Bedrock credential/network operations. Include built-in network tools where they own the wait; custom tools can use the same DB API. No per-token release calls and no transactions around network operations. Avoid acquiring an otherwise unused database connection merely to release it.
-Extend database pool/lifecycle tests with retained query/schema builders across release and reacquisition; read/write aliases and sticky reads; custom PDO/non-PDO drivers; SQLite memory ownership; transactions, nested pins and abandoned cursors; cancellation/reconnect/setup failure; non-coroutine cleanup; and session-state changes between borrowers. Use a deliberately small pool with more concurrent fake-provider waits than slots to prove that completed history/auth queries do not monopolize the pool. Assert logical state preservation and exact lease settlement, not private storage layout.
+### Remaining performance acceptance
-In `tests/Benchmarks/Database`, compare ordinary request database lifecycles against the unchanged baseline: logical construction, first query, acquisition/settlement and coroutine-end cleanup, at realistic concurrency. This redesign affects every database-using application. Require no unexplained throughput or allocation regression; optimize measured construction costs before considering any bounded wrapper recycling. Never recycle a logical object still retained by a builder/caller. Use the owner-arranged idle window required in §10.
+In `tests/Benchmarks/Database`, compare ordinary request database lifecycles against the unchanged baseline: logical construction, first query, query pinning, acquisition/settlement and coroutine-end cleanup, at realistic concurrency. Include GC runs/collected/roots, heap growth between collections and worker peak memory alongside concurrency and request counts. This redesign affects every database-using application. Require no unexplained throughput or allocation regression; optimize measured construction costs before considering any bounded wrapper recycling. Never recycle a logical object still retained by a builder/caller. Use the owner-arranged idle window required in §10.
## 2. HTTP streaming and response cancellation
@@ -128,7 +123,7 @@ Explicitly prove that cancelling a response producer interrupts the new streamin
Make the Agent contract extend `Transient`. Classify tools by real resolution paths: `FilesystemTool`, `AgentTool` and mutable approval/provider-tool families have caller-owned fluent configuration. Do not mark all tools or factory-created objects transient simply because they have mutable properties. Preserve worker-shared stateless middleware/services; document scoped/Transient alternatives for application middleware with operation-owned state.
-Move `ParentInvocation` and `TextGenerationLoop` repair settings into scoped context, with nested `finally` restoration behind the existing method signatures. The interleaved save/restore pattern on a shared property can leave the final shared value permanently wrong, not merely wrong during overlap.
+Move `ParentInvocation` and `TextGenerationLoop` repair settings into separate scoped context entries, using the parent-ID pair and boolean values with nested `finally` restoration behind the existing method signatures. They have different owners and are rarely read together, so no shared mutable invocation bag is needed. The interleaved save/restore pattern on a shared property can leave the final shared value permanently wrong, not merely wrong during overlap.
Accept `Provider` objects throughout prompt/stream/queue, pending generation, files/stores, macros and related contracts. Preserve the objects through failover and encrypted on-demand serialization rather than reducing them to names. Keep the public name/model formatting utility unchanged and introduce an internal object-preserving iterator. Explicit named lookups of `Ai::build()` results retain a coroutine-local registry; internal flows pass objects directly. Never cache resolver-backed instances merely by name for an entire coroutine: nested scopes can change identity within the same coroutine.
diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php
index 5890ec206..ad7318627 100644
--- a/src/database/src/Concerns/ManagesTransactions.php
+++ b/src/database/src/Concerns/ManagesTransactions.php
@@ -408,9 +408,10 @@ protected function handleRollBackException(Throwable $e): void
protected function forgetLostConnection(): void
{
try {
- $this->resetTransactionState();
- } finally {
$this->forgetDriverResources();
+ } finally {
+ // Rollback callbacks may reconnect; only forget the failed session.
+ $this->resetTransactionState();
}
}
diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php
index 969c14c93..be13df0f2 100755
--- a/src/database/src/Connection.php
+++ b/src/database/src/Connection.php
@@ -112,6 +112,11 @@ abstract class Connection implements ConnectionInterface, NonCopyableContext
*/
protected int $foreignKeyConstraintSuppressionDepth = 0;
+ /**
+ * The number of operations retaining the current physical session.
+ */
+ protected int $sessionPinDepth = 0;
+
/**
* The transaction manager instance.
*/
@@ -549,57 +554,63 @@ public function prepareBindings(array $bindings): array
*/
protected function run(string $query, array $bindings, Closure $callback): mixed
{
- foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) {
- $beforeExecutingCallback($query, $bindings, $this);
- }
+ ++$this->sessionPinDepth;
- $this->reconnectIfMissingConnection();
+ try {
+ foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) {
+ $beforeExecutingCallback($query, $bindings, $this);
+ }
- $start = hrtime(true) / 1e9;
+ $this->reconnectIfMissingConnection();
- // Here we will run this query. If an exception occurs we'll determine if it was
- // caused by a connection that has been lost. If that is the cause, we'll try
- // to re-establish connection and re-run the query with a fresh connection.
- try {
+ $start = hrtime(true) / 1e9;
+
+ // Here we will run this query. If an exception occurs we'll determine if it was
+ // caused by a connection that has been lost. If that is the cause, we'll try
+ // to re-establish connection and re-run the query with a fresh connection.
try {
- $result = $this->runQueryCallback($query, $bindings, $callback);
- } catch (QueryException $e) {
- $result = $this->handleQueryException(
- $e,
- $query,
- $bindings,
- $callback
- );
- }
- } catch (CanceledException $exception) {
- throw $exception;
- } catch (Throwable $exception) {
- $events = $this->events;
-
- if ($events?->hasListeners(QueryFailed::class)) {
- $events->dispatch(new QueryFailed(
- $query,
- $bindings,
- $this->getElapsedTime($start),
- $this,
- $exception,
- $this->latestReadWriteTypeUsed(),
- ));
+ try {
+ $result = $this->runQueryCallback($query, $bindings, $callback);
+ } catch (QueryException $e) {
+ $result = $this->handleQueryException(
+ $e,
+ $query,
+ $bindings,
+ $callback
+ );
+ }
+ } catch (CanceledException $exception) {
+ throw $exception;
+ } catch (Throwable $exception) {
+ $events = $this->events;
+
+ if ($events?->hasListeners(QueryFailed::class)) {
+ $events->dispatch(new QueryFailed(
+ $query,
+ $bindings,
+ $this->getElapsedTime($start),
+ $this,
+ $exception,
+ $this->latestReadWriteTypeUsed(),
+ ));
+ }
+
+ throw $exception;
}
- throw $exception;
- }
+ // Once we have run the query we will calculate the time that it took to run and
+ // then log the query, bindings, and execution time so we will report them on
+ // the event that the developer needs them. We'll log time in milliseconds.
+ $this->logQuery(
+ $query,
+ $bindings,
+ $this->getElapsedTime($start)
+ );
- // Once we have run the query we will calculate the time that it took to run and
- // then log the query, bindings, and execution time so we will report them on
- // the event that the developer needs them. We'll log time in milliseconds.
- $this->logQuery(
- $query,
- $bindings,
- $this->getElapsedTime($start)
- );
-
- return $result;
+ return $result;
+ } finally {
+ --$this->sessionPinDepth;
+ }
}
/**
@@ -617,67 +628,73 @@ protected function run(string $query, array $bindings, Closure $callback): mixed
*/
protected function runStreaming(string $query, array $bindings, Closure $callback): Generator
{
- foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) {
- $beforeExecutingCallback($query, $bindings, $this);
- }
+ ++$this->sessionPinDepth;
+
+ try {
+ foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) {
+ $beforeExecutingCallback($query, $bindings, $this);
+ }
- $this->reconnectIfMissingConnection();
+ $this->reconnectIfMissingConnection();
- $start = hrtime(true) / 1e9;
- $hasYielded = false;
+ $start = hrtime(true) / 1e9;
+ $hasYielded = false;
+
+ $execute = function (string $query, array $bindings) use ($callback, &$hasYielded): Generator {
+ try {
+ foreach ($callback($query, $bindings) as $key => $value) {
+ $readWriteType = $this->latestReadWriteTypeRetrieved;
+ $hasYielded = true;
+
+ try {
+ yield $key => $value;
+ } finally {
+ // A consumer may run another query while this operation is suspended.
+ $this->latestReadWriteTypeRetrieved = $readWriteType;
+ }
+ }
+ } catch (CanceledException|StreamClosedException $exception) {
+ throw $exception;
+ } catch (Exception $exception) {
+ ++$this->errorCount;
+
+ throw $this->newQueryException($query, $bindings, $exception);
+ }
+ };
- $execute = function (string $query, array $bindings) use ($callback, &$hasYielded): Generator {
try {
- foreach ($callback($query, $bindings) as $key => $value) {
- $readWriteType = $this->latestReadWriteTypeRetrieved;
- $hasYielded = true;
-
- try {
- yield $key => $value;
- } finally {
- // A consumer may run another query while this operation is suspended.
- $this->latestReadWriteTypeRetrieved = $readWriteType;
+ try {
+ yield from $execute($query, $bindings);
+ } catch (QueryException $exception) {
+ if ($hasYielded) {
+ throw $exception;
}
+
+ yield from $this->handleQueryException($exception, $query, $bindings, $execute);
}
} catch (CanceledException|StreamClosedException $exception) {
throw $exception;
- } catch (Exception $exception) {
- ++$this->errorCount;
-
- throw $this->newQueryException($query, $bindings, $exception);
- }
- };
-
- try {
- try {
- yield from $execute($query, $bindings);
- } catch (QueryException $exception) {
- if ($hasYielded) {
- throw $exception;
+ } catch (Throwable $exception) {
+ $events = $this->events;
+
+ if ($events?->hasListeners(QueryFailed::class)) {
+ $events->dispatch(new QueryFailed(
+ $query,
+ $bindings,
+ $this->getElapsedTime($start),
+ $this,
+ $exception,
+ $this->latestReadWriteTypeUsed(),
+ ));
}
- yield from $this->handleQueryException($exception, $query, $bindings, $execute);
- }
- } catch (CanceledException|StreamClosedException $exception) {
- throw $exception;
- } catch (Throwable $exception) {
- $events = $this->events;
-
- if ($events?->hasListeners(QueryFailed::class)) {
- $events->dispatch(new QueryFailed(
- $query,
- $bindings,
- $this->getElapsedTime($start),
- $this,
- $exception,
- $this->latestReadWriteTypeUsed(),
- ));
+ throw $exception;
}
- throw $exception;
+ $this->logQuery($query, $bindings, $this->getElapsedTime($start));
+ } finally {
+ --$this->sessionPinDepth;
}
-
- $this->logQuery($query, $bindings, $this->getElapsedTime($start));
}
/**
@@ -1056,6 +1073,37 @@ public function clearBeforeExecutingCallbacks(): void
$this->beforeExecutingCallbacks = [];
}
+ /**
+ * Retain the physical database session for the duration of the callback.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
+ */
+ public function withPinnedSession(Closure $callback): mixed
+ {
+ ++$this->sessionPinDepth;
+
+ try {
+ return $callback();
+ } finally {
+ --$this->sessionPinDepth;
+ }
+ }
+
+ /**
+ * Determine whether an operation requires the current physical session.
+ *
+ * @internal
+ */
+ public function hasPinnedSession(): bool
+ {
+ return $this->sessionPinDepth > 0
+ || $this->transactions > 0
+ || $this->foreignKeyConstraintSuppressionDepth > 0;
+ }
+
/**
* Begin a foreign key constraint suppression scope.
*
diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php
index fe5746646..1024bb9fc 100755
--- a/src/database/src/ConnectionResolver.php
+++ b/src/database/src/ConnectionResolver.php
@@ -8,6 +8,7 @@
use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Container\Container;
use Hypervel\Coroutine\Coroutine;
+use Hypervel\Database\Pool\ConnectionLease;
use Hypervel\Database\Pool\PooledConnection;
use Hypervel\Database\Pool\PoolManager;
use Swoole\Coroutine\CanceledException;
@@ -19,7 +20,7 @@
/**
* Resolves database connections from a connection pool.
*
- * Retains pooled wrappers until their owning coroutine or task ends.
+ * Retain logical connections while allowing idle physical sessions to be returned early.
*/
class ConnectionResolver implements ConnectionResolverInterface
{
@@ -45,7 +46,7 @@ class ConnectionResolver implements ConnectionResolverInterface
/**
* Pooled wrappers retained by non-coroutine task execution.
*
- * @var array
+ * @var array
*/
protected array $nonCoroutineConnections = [];
@@ -62,9 +63,8 @@ public function __construct(
/**
* Get a database connection instance.
*
- * The connection is retrieved from a pool and stored in the current
- * coroutine's context. When the coroutine ends, the connection is
- * automatically released back to the pool.
+ * Store a stable logical connection in the current coroutine's context.
+ * The current physical lease is settled when the coroutine ends.
*/
public function connection(UnitEnum|string|null $name = null): ConnectionInterface
{
@@ -80,12 +80,10 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
$connectionOwnerName = $connectionName->requested;
$contextKey = $this->getContextKey($connectionOwnerName);
- // Check if this coroutine already has a connection
- if (CoroutineContext::has($contextKey)) {
- $connection = CoroutineContext::get($contextKey);
- if ($connection instanceof ConnectionInterface) {
- return $connection;
- }
+ $connection = CoroutineContext::get($contextKey);
+
+ if ($connection instanceof ConnectionInterface) {
+ return $connection;
}
$pool = $this->poolManager->pool($connectionName->requested);
@@ -97,24 +95,33 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
$connectionOwnerName = $pool->getName();
$contextKey = $this->getContextKey($connectionOwnerName);
- if (CoroutineContext::has($contextKey)) {
- $connection = CoroutineContext::get($contextKey);
-
- if ($connection instanceof ConnectionInterface) {
- if ($connectionName->isWrite() && $connection instanceof Connection) {
- $connection->useWriteConnectionWhenReading();
- }
+ $connection = CoroutineContext::get($contextKey);
- return $connection;
+ if ($connection instanceof ConnectionInterface) {
+ if ($connectionName->isWrite() && $connection instanceof Connection) {
+ $connection->useWriteConnectionWhenReading();
}
+
+ return $connection;
}
}
+ if (! Coroutine::inCoroutine() && isset($this->nonCoroutineConnections[$connectionOwnerName])) {
+ // A purge can remove the context entry before the task ends.
+ $previous = $this->nonCoroutineConnections[$connectionOwnerName];
+ unset($this->nonCoroutineConnections[$connectionOwnerName]);
+ $previous->discard();
+ }
+
/** @var PooledConnection $pooledConnection */
$pooledConnection = $pool->borrow();
+ $leaseContextKey = $this->getLeaseContextKey($connectionOwnerName);
try {
- $connection = $pooledConnection->getConnection();
+ $owner = $pool->usesSessionLeases() ? $pooledConnection->newLease() : $pooledConnection;
+ $connection = $owner instanceof ConnectionLease
+ ? $owner->connection
+ : $pooledConnection->getConnection();
// Keep the borrowed alias so migrations and error rendering reuse this connection.
if ($connectionName->role !== null && ! $sharedInMemorySqlite) {
@@ -127,20 +134,29 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
CoroutineContext::set($contextKey, $connection);
+ if ($owner instanceof ConnectionLease) {
+ CoroutineContext::set($leaseContextKey, $owner);
+ }
+
// Listeners can resolve this connection. Notify before registering a
// deferred release, since a listener failure discards the wrapper.
$pooledConnection->dispatchConnectionEstablishedEvent();
if (Coroutine::inCoroutine()) {
- Coroutine::defer(function () use ($pooledConnection, $contextKey): void {
- CoroutineContext::forget($contextKey);
- $pooledConnection->release();
+ Coroutine::defer(function () use ($owner, $contextKey, $leaseContextKey): void {
+ try {
+ $owner->release();
+ } finally {
+ CoroutineContext::forget($contextKey);
+ CoroutineContext::forget($leaseContextKey);
+ }
});
} else {
- $this->nonCoroutineConnections[$connectionOwnerName] = $pooledConnection;
+ $this->nonCoroutineConnections[$connectionOwnerName] = $owner;
}
} catch (Throwable $exception) {
CoroutineContext::forget($contextKey);
+ CoroutineContext::forget($leaseContextKey);
unset($this->nonCoroutineConnections[$connectionOwnerName]);
$this->discardFailedConnection($pooledConnection, $exception);
@@ -151,6 +167,18 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
return $connection;
}
+ /**
+ * Return idle physical sessions owned by the current execution.
+ */
+ public function releaseIdleConnections(): void
+ {
+ foreach (CoroutineContext::getContainer() ?? [] as $value) {
+ if ($value instanceof ConnectionLease) {
+ $value->releaseIfIdle();
+ }
+ }
+ }
+
/**
* Release connections retained by non-coroutine task execution.
*
@@ -159,7 +187,7 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
public function releaseConnections(): void
{
$this->terminateConnections(
- static function (PooledConnection $connection): void {
+ static function (ConnectionLease|PooledConnection $connection): void {
$connection->release();
},
);
@@ -173,7 +201,7 @@ static function (PooledConnection $connection): void {
public function discardConnections(): void
{
$this->terminateConnections(
- static function (PooledConnection $connection): void {
+ static function (ConnectionLease|PooledConnection $connection): void {
$connection->discard();
},
);
@@ -215,6 +243,14 @@ protected function getContextKey(string $name): string
return sprintf('__database.connection.%s', $name);
}
+ /**
+ * Get the context key for the current logical connection's lease.
+ */
+ protected function getLeaseContextKey(string $name): string
+ {
+ return sprintf('__database.lease.%s', $name);
+ }
+
/**
* Discard a failed connection while preserving cancellation precedence.
*/
@@ -239,24 +275,25 @@ protected function terminateConnections(Closure $terminate): void
$connections = $this->nonCoroutineConnections;
$this->nonCoroutineConnections = [];
- foreach (array_keys($connections) as $name) {
- CoroutineContext::forget($this->getContextKey($name));
- }
-
- CoroutineContext::forget(self::DEFAULT_CONNECTION_CONTEXT_KEY);
-
$exception = null;
- foreach ($connections as $connection) {
- try {
- $terminate($connection);
- } catch (Throwable $throwable) {
- if ($exception === null
- || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)
- ) {
- $exception = $throwable;
+ try {
+ foreach ($connections as $name => $connection) {
+ try {
+ $terminate($connection);
+ } catch (Throwable $throwable) {
+ if ($exception === null
+ || ($throwable instanceof CanceledException && ! $exception instanceof CanceledException)
+ ) {
+ $exception = $throwable;
+ }
+ } finally {
+ CoroutineContext::forget($this->getContextKey($name));
+ CoroutineContext::forget($this->getLeaseContextKey($name));
}
}
+ } finally {
+ CoroutineContext::forget(self::DEFAULT_CONNECTION_CONTEXT_KEY);
}
if ($exception !== null) {
diff --git a/src/database/src/Connectors/ConnectionFactory.php b/src/database/src/Connectors/ConnectionFactory.php
index 34c6be73c..15f6a3ada 100755
--- a/src/database/src/Connectors/ConnectionFactory.php
+++ b/src/database/src/Connectors/ConnectionFactory.php
@@ -65,6 +65,25 @@ public function make(array $config, ?string $name = null): Connection
return $this->createPdoConnectionFromConfig($config);
}
+ /**
+ * Create a logical connection from selected endpoint configuration and PDO resolvers.
+ *
+ * @param Closure(): PDO $pdo
+ * @param null|Closure(): PDO $readPdo
+ *
+ * @internal
+ */
+ public function makeWithPdoResolvers(array $config, Closure $pdo, ?Closure $readPdo = null): PdoConnection
+ {
+ return $this->createConnection(
+ $config['driver'],
+ $pdo,
+ $config['database'],
+ $config['prefix'],
+ $config,
+ )->setReadPdo($readPdo);
+ }
+
/**
* Get the extension resolver for a connection configuration.
*/
@@ -178,6 +197,16 @@ public function configForRead(array $config): array
);
}
+ /**
+ * Get the write configuration for a read / write connection.
+ *
+ * @internal
+ */
+ public function configForWrite(array $config): array
+ {
+ return $this->getWriteConfig($this->parseConfig($config, $config['name'] ?? null));
+ }
+
/**
* Create a PDO-backed connection from its configuration.
*/
@@ -213,6 +242,9 @@ protected function createReadWriteConnection(array $config): PdoConnection
{
$connection = $this->createSingleConnection($this->getWriteConfig($config));
+ // The PDO resolver and its metadata must describe the same selected endpoint.
+ $config['read'] = $this->getReadWriteConfig($config, 'read');
+
return $connection
->setReadPdo($this->createReadPdo($config))
->setReadPdoConfig($this->getReadConfig($config));
diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php
index 9f265e8cc..19d07a98e 100755
--- a/src/database/src/DatabaseManager.php
+++ b/src/database/src/DatabaseManager.php
@@ -292,12 +292,25 @@ public function purge(UnitEnum|string|null $name = null): void
}
}
+ /**
+ * Return the current execution's idle database sessions to their pools.
+ */
+ public function releaseIdleConnections(): void
+ {
+ $resolver = $this->app->make('db.resolver');
+
+ if ($resolver instanceof ConnectionResolver) {
+ $resolver->releaseIdleConnections();
+ }
+ }
+
/**
* Disconnect from the given database.
*
- * In pooled mode, this disconnects the current coroutine's driver resources
- * (if one exists), forcing a reconnect on the next query. Does not clear
- * context or affect the pool - the connection is still released at coroutine end.
+ * In pooled mode, close the current execution's driver resources and retain
+ * its logical connection, which reconnects on next use. Session leases
+ * invalidate their physical holder and settle the slot at the next release.
+ * Whole-connection pools retain their wrapper until execution ends.
*
* In non-pooled mode, disconnects the connection stored in the manager's cache
* for SimpleConnectionResolver or in a CachedConnectionResolver's cache.
diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php
index d9f601495..5e1b575cb 100755
--- a/src/database/src/PdoConnection.php
+++ b/src/database/src/PdoConnection.php
@@ -36,6 +36,11 @@ class PdoConnection extends Connection
*/
protected PDO|Closure|null $readPdo = null;
+ /**
+ * The owner notified when the current driver resources are forgotten.
+ */
+ protected ?Closure $resourceReleaser = null;
+
/**
* The default fetch mode of the connection.
*/
@@ -137,52 +142,63 @@ public function selectResultSets(string $query, array $bindings = [], bool $useR
*/
public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator
{
- $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
- if ($this->pretending()) {
- return null;
- }
+ ++$this->sessionPinDepth;
+ $statement = null;
- // First we will create a statement for the query. Then, we will set the fetch
- // mode and prepare the bindings for the query. Once that's done we will be
- // ready to execute the query against the database and return the cursor.
- $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
- ->prepare($query));
+ try {
+ $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
+ if ($this->pretending()) {
+ return null;
+ }
- $this->bindValues(
- $statement,
- $this->prepareBindings($bindings)
- );
+ // First we will create a statement for the query. Then, we will set the fetch
+ // mode and prepare the bindings for the query. Once that's done we will be
+ // ready to execute the query against the database and return the cursor.
+ $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
+ ->prepare($query));
- // Next, we'll execute the query against the database and return the statement
- // so we can return the cursor. The cursor will use a PHP generator to give
- // back one row at a time without using a bunch of memory to render them.
- $statement->execute();
+ $this->bindValues(
+ $statement,
+ $this->prepareBindings($bindings)
+ );
- return $statement;
- });
+ // Next, we'll execute the query against the database and return the statement
+ // so we can return the cursor. The cursor will use a PHP generator to give
+ // back one row at a time without using a bunch of memory to render them.
+ $statement->execute();
- if ($statement === null) {
- return;
- }
+ return $statement;
+ });
- if ($fetchUsing !== []) {
- // fetchAll() supplies default column and class arguments that setFetchMode()
- // demands explicitly, so a mode-only call keeps the same meaning when streamed.
- if (count($fetchUsing) === 1) {
- $mode = $fetchUsing[0] & ~(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE | PDO::FETCH_CLASSTYPE | PDO::FETCH_PROPS_LATE);
+ if ($statement === null) {
+ return;
+ }
+
+ if ($fetchUsing !== []) {
+ // fetchAll() supplies default column and class arguments that setFetchMode()
+ // demands explicitly, so a mode-only call keeps the same meaning when streamed.
+ if (count($fetchUsing) === 1) {
+ $mode = $fetchUsing[0] & ~(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE | PDO::FETCH_CLASSTYPE | PDO::FETCH_PROPS_LATE);
- if ($mode === PDO::FETCH_COLUMN) {
- $fetchUsing[] = 0;
- } elseif ($mode === PDO::FETCH_CLASS && ($fetchUsing[0] & PDO::FETCH_CLASSTYPE) === 0) {
- $fetchUsing[] = stdClass::class;
+ if ($mode === PDO::FETCH_COLUMN) {
+ $fetchUsing[] = 0;
+ } elseif ($mode === PDO::FETCH_CLASS && ($fetchUsing[0] & PDO::FETCH_CLASSTYPE) === 0) {
+ $fetchUsing[] = stdClass::class;
+ }
}
- }
- $statement->setFetchMode(...$fetchUsing);
- }
+ $statement->setFetchMode(...$fetchUsing);
+ }
- foreach ($statement as $record) {
- yield $record;
+ foreach ($statement as $record) {
+ yield $record;
+ }
+ } finally {
+ try {
+ $statement?->closeCursor();
+ } finally {
+ --$this->sessionPinDepth;
+ }
}
}
@@ -420,6 +436,69 @@ protected function resolveReadPdo(): PDO
return $this->resolvePdo();
}
+ /**
+ * Resolve a physical handle without applying caller-specific session state.
+ *
+ * @internal
+ */
+ public function resolveRawPdo(bool $read = false): PDO
+ {
+ return $read ? $this->resolveReadPdo() : $this->resolvePdo();
+ }
+
+ /**
+ * Adopt resolved handles and endpoint metadata without changing caller state.
+ *
+ * @internal
+ */
+ public function attachPdoResources(self $source): void
+ {
+ if ($source->pdo instanceof PDO) {
+ $this->pdo = $source->pdo;
+ }
+
+ if ($source->readPdo instanceof PDO) {
+ $this->readPdo = $source->readPdo;
+ }
+
+ $this->config = $source->config;
+ $this->readConnectionConfig = $source->readConnectionConfig;
+ }
+
+ /**
+ * Restore lazy resolvers after returning the physical resources to their owner.
+ *
+ * @param Closure(): PDO $pdo
+ * @param null|Closure(): PDO $readPdo
+ *
+ * @internal
+ */
+ public function detachPdoResources(Closure $pdo, ?Closure $readPdo): void
+ {
+ if ($this->foreignKeyConstraintSuppressionDepth > 0) {
+ $this->markCurrentSessionStateUnknown();
+ }
+
+ $this->resourceReleaser = null;
+ $this->pdo = $pdo;
+ $this->readPdo = $readPdo;
+ $this->lockForPopping = null;
+ $this->maxBindings = null;
+ $this->errorCount = 0;
+ }
+
+ /**
+ * Set the resource owner's callback for forgotten driver resources.
+ *
+ * @param Closure(): void $callback
+ *
+ * @internal
+ */
+ public function setResourceReleaser(Closure $callback): void
+ {
+ $this->resourceReleaser = $callback;
+ }
+
/**
* Synchronize the desired state for a physical database session.
*/
@@ -676,6 +755,10 @@ public function setReadPdoConfig(array $config): static
protected function forgetDriverResources(): void
{
$this->setPdo(null)->setReadPdo(null);
+
+ $releaser = $this->resourceReleaser;
+ $this->resourceReleaser = null;
+ $releaser?->__invoke();
}
/**
@@ -712,11 +795,17 @@ protected function disconnectDriverResources(): void
protected function replaceDriverResources(Connection $fresh): void
{
/** @var self $fresh */
- $fresh->getPdo();
- $fresh->getReadPdo();
+ try {
+ $fresh->getPdo();
+ $fresh->getReadPdo();
+
+ $pdo = $fresh->getRawPdo();
+ $readPdo = $fresh->getRawReadPdo();
+ } finally {
+ // Its grammar retains the temporary connection until cyclic GC runs.
+ $fresh->setPdo(null)->setReadPdo(null);
+ }
- $pdo = $fresh->getRawPdo();
- $readPdo = $fresh->getRawReadPdo();
$database = $fresh->database;
$configuredDatabase = $fresh->configuredDatabase;
$tablePrefix = $fresh->tablePrefix;
diff --git a/src/database/src/Pool/ConnectionLease.php b/src/database/src/Pool/ConnectionLease.php
new file mode 100644
index 000000000..a3e07f002
--- /dev/null
+++ b/src/database/src/Pool/ConnectionLease.php
@@ -0,0 +1,157 @@
+pooledConnection = $pooledConnection;
+ $this->pdoResolver = fn (): PDO => $this->resolvePdo();
+ $this->readPdoResolver = $physicalConnection->getRawReadPdo() !== null
+ ? fn (): PDO => $this->resolvePdo(read: true)
+ : null;
+ $this->connection = $factory->makeWithPdoResolvers(
+ $physicalConnection->getConfig(),
+ $this->pdoResolver,
+ $this->readPdoResolver,
+ );
+ $this->connection->attachPdoResources($physicalConnection);
+ $this->connection->setReconnector($this->reconnect(...));
+ $pooledConnection->attachLease($this);
+ }
+
+ /**
+ * Resolve a physical handle and publish its generation to the logical owner.
+ */
+ protected function resolvePdo(bool $read = false): PDO
+ {
+ if ($this->pooledConnection === null) {
+ /** @var PooledConnection $pooledConnection */
+ $pooledConnection = $this->pool->borrow();
+ $this->pooledConnection = $pooledConnection;
+ $pooledConnection->attachLease($this);
+ }
+
+ $pooledConnection = $this->pooledConnection;
+
+ try {
+ /** @var PdoConnection $physicalConnection */
+ $physicalConnection = $pooledConnection->getDriverConnection();
+ $pdo = $physicalConnection->resolveRawPdo($read);
+
+ // A PDO-first extension may resolve its constructor argument eagerly.
+ if (isset($this->connection)) { // @phpstan-ignore isset.initializedProperty (PDO resolvers may run during construction.)
+ $this->connection->attachPdoResources($physicalConnection);
+ $pooledConnection->dispatchConnectionEstablishedEvent();
+ }
+
+ return $pdo;
+ } catch (Throwable $exception) {
+ if (isset($this->connection)) { // @phpstan-ignore isset.initializedProperty (PDO resolvers may run during construction.)
+ $this->discardAfterFailure($exception);
+ }
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * Replace the held physical session and reconnect the logical owner.
+ */
+ public function reconnect(): PdoConnection
+ {
+ $this->connection->disconnect();
+
+ if ($this->pooledConnection !== null) {
+ $this->pooledConnection->reconnect();
+ $this->pooledConnection->attachLease($this);
+ }
+
+ $this->connection->setPdo($this->pdoResolver)->setReadPdo($this->readPdoResolver);
+ $this->connection->getPdo();
+
+ return $this->connection;
+ }
+
+ /**
+ * Release the held session when no operation requires it.
+ */
+ public function releaseIfIdle(): void
+ {
+ if (! $this->connection->hasPinnedSession()) {
+ $this->release();
+ }
+ }
+
+ /**
+ * Settle the currently held physical session.
+ */
+ public function release(): void
+ {
+ $this->pooledConnection?->release();
+ }
+
+ /**
+ * Discard the currently held physical session.
+ */
+ public function discard(): void
+ {
+ $this->pooledConnection?->discard();
+ }
+
+ /**
+ * Remove references to a session before its slot becomes available again.
+ */
+ public function detach(): void
+ {
+ $this->pooledConnection = null;
+ $this->connection->detachPdoResources($this->pdoResolver, $this->readPdoResolver);
+ }
+
+ /**
+ * Discard a failed acquisition without replacing its exception.
+ */
+ protected function discardAfterFailure(Throwable $exception): void
+ {
+ try {
+ $this->discard();
+ } catch (CanceledException $cancellation) {
+ if (! $exception instanceof CanceledException) {
+ throw $cancellation;
+ }
+ } catch (Throwable) {
+ // Preserve the acquisition or connection-listener failure.
+ }
+ }
+}
diff --git a/src/database/src/Pool/DatabasePool.php b/src/database/src/Pool/DatabasePool.php
index 2b57953db..9f82bfcbd 100644
--- a/src/database/src/Pool/DatabasePool.php
+++ b/src/database/src/Pool/DatabasePool.php
@@ -6,6 +6,7 @@
use Hypervel\ConnectionPool\BorrowRateTracker;
use Hypervel\ConnectionPool\ConnectionPool;
+use Hypervel\ConnectionPool\PoolOptions;
use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection;
use Hypervel\Contracts\ConnectionPool\UsageTracker;
use Hypervel\Contracts\Container\Container;
@@ -31,6 +32,8 @@ class DatabasePool extends ConnectionPool
{
protected array $config;
+ protected bool $usesSessionLeases;
+
protected ?Timer $heartbeatTimer = null;
protected ?int $heartbeatTimerId = null;
@@ -64,19 +67,13 @@ public function __construct(Container $container, string $name)
$poolConfig = $config;
if ($connectionName->isRead() && $factory->hasReadConfig($config)) {
- $poolConfig = $factory->configForRead($config);
- $this->ensureNotDerivedInMemorySqlitePool($connectionName, $poolConfig);
-
- if ($factory->getExtension($config, $connectionName->base) === null) {
- $config = $poolConfig;
- } else {
- // Extensions own endpoint selection, but the pool still uses
- // the selected read record's pool options and SQLite metadata.
- $config[Connection::READ_WRITE_TYPE_CONFIG_KEY] = ConnectionName::READ;
- }
+ $poolConfig = $this->readPoolConfig($factory, $connectionName, $config);
+ $config[Connection::READ_WRITE_TYPE_CONFIG_KEY] = ConnectionName::READ;
}
$this->config = $config;
+ $this->usesSessionLeases = $factory->getExtension($config, $connectionName->base) === null
+ && $this->hasConsistentLogicalIdentity($factory, $connectionName, $config);
$poolOptions = Arr::except(
Arr::get($poolConfig, 'pool', []),
@@ -125,6 +122,44 @@ public function getSharedInMemorySqlitePdo(): ?PDO
return $this->sharedInMemorySqlitePdo;
}
+ /**
+ * Determine whether callers can return physical sessions before execution ends.
+ */
+ public function usesSessionLeases(): bool
+ {
+ return $this->usesSessionLeases;
+ }
+
+ /**
+ * Determine whether selectable endpoints share the same logical database identity.
+ */
+ protected function hasConsistentLogicalIdentity(ConnectionFactory $factory, ConnectionName $name, array $config): bool
+ {
+ $role = $name->isRead() && $factory->hasReadConfig($config) ? 'read' : 'write';
+
+ if (! isset($config[$role][0])) {
+ return true;
+ }
+
+ $identity = null;
+
+ foreach ($config[$role] as $record) {
+ $candidate = array_replace($config, [$role => $record]);
+ $endpoint = $role === 'read'
+ ? $factory->configForRead($candidate)
+ : $factory->configForWrite($candidate);
+ $candidateIdentity = [$endpoint['database'], $endpoint['prefix']];
+
+ if ($identity !== null && $identity !== $candidateIdentity) {
+ return false;
+ }
+
+ $identity = $candidateIdentity;
+ }
+
+ return true;
+ }
+
/**
* Create a new pooled connection.
*/
@@ -152,7 +187,12 @@ protected function createSharedInMemorySqlitePdo(): PDO
$factory = $this->container->make('db.factory');
$connection = $factory->makeSharedInMemorySqliteConnection($this->config, $this->name);
- return $connection->getPdo();
+ try {
+ return $connection->resolveRawPdo();
+ } finally {
+ // Only the pool should retain this PDO after its bootstrap holder is discarded.
+ $connection->setPdo(null)->setReadPdo(null);
+ }
}
/**
@@ -177,6 +217,33 @@ protected function isInMemorySqlite(array $config): bool
return SQLiteDatabase::isInMemory($database);
}
+ /**
+ * Resolve consistent pool settings without choosing a physical read endpoint.
+ */
+ protected function readPoolConfig(ConnectionFactory $factory, ConnectionName $name, array $config): array
+ {
+ $records = isset($config['read'][0]) ? $config['read'] : [$config['read']];
+ $poolConfig = null;
+ $poolOptions = null;
+
+ foreach ($records as $record) {
+ $readConfig = $factory->configForRead(array_replace($config, ['read' => $record]));
+ $this->ensureNotDerivedInMemorySqlitePool($name, $readConfig);
+ $options = PoolOptions::fromArray(Arr::except($readConfig['pool'] ?? [], ['testing_enabled']));
+
+ if ($poolOptions !== null && $poolOptions != $options) {
+ throw new InvalidArgumentException(
+ "Read records for database connection [{$name->requested}] must use the same effective pool options."
+ );
+ }
+
+ $poolConfig ??= $readConfig;
+ $poolOptions ??= $options;
+ }
+
+ return $poolConfig;
+ }
+
/**
* Ensure a derived read pool does not point at an in-memory SQLite database.
*/
diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php
index 52278153d..8ac998e2d 100644
--- a/src/database/src/Pool/PooledConnection.php
+++ b/src/database/src/Pool/PooledConnection.php
@@ -12,8 +12,10 @@
use Hypervel\Contracts\Log\StdoutLoggerInterface;
use Hypervel\Coroutine\Coroutine as FrameworkCoroutine;
use Hypervel\Database\Connection;
+use Hypervel\Database\ConnectionName;
use Hypervel\Database\Connectors\ConnectionFactory;
use Hypervel\Database\Events\ConnectionEstablished;
+use Hypervel\Database\PdoConnection;
use Hypervel\Engine\Channel;
use Hypervel\Engine\Coroutine;
use Hypervel\Engine\Exceptions\CoroutineCreateException;
@@ -37,6 +39,10 @@ class PooledConnection implements PoolConnection
protected ?Connection $connection = null;
+ protected ?ConnectionLease $lease = null;
+
+ protected bool $driverConnectionConfigured = false;
+
protected ConnectionFactory $factory;
protected LoggerInterface $logger;
@@ -81,6 +87,28 @@ public function getConnection(): Connection
* Get the active connection, reconnecting if necessary.
*/
public function getActiveConnection(): Connection
+ {
+ if ($this->lease !== null) {
+ return $this->lease->connection;
+ }
+
+ $connection = $this->getDriverConnection();
+
+ if (! $this->driverConnectionConfigured) {
+ $this->configureConnection($connection);
+ $connection->setReconnector($this->refresh(...));
+ $this->driverConnectionConfigured = true;
+ }
+
+ return $connection;
+ }
+
+ /**
+ * Get the internal driver resource holder for the current generation.
+ *
+ * @internal
+ */
+ public function getDriverConnection(): Connection
{
if ($this->check()) {
$this->availableForReuse = false;
@@ -95,26 +123,59 @@ public function getActiveConnection(): Connection
return $this->connection;
}
+ /**
+ * Create a caller-owned logical connection for this borrowed slot.
+ *
+ * @internal
+ */
+ public function newLease(): ConnectionLease
+ {
+ /** @var PdoConnection $connection */
+ $connection = $this->getDriverConnection();
+ $lease = new ConnectionLease($this->pool, $this, $this->factory, $connection);
+ $this->configureConnection($lease->connection);
+
+ return $lease;
+ }
+
+ /**
+ * Attach a logical owner without retaining its state between borrowers.
+ *
+ * @internal
+ */
+ public function attachLease(ConnectionLease $lease): void
+ {
+ $this->lease = $lease;
+ $this->connection?->unsetEventDispatcher();
+ $this->connection?->unsetTransactionManager();
+ $this->driverConnectionConfigured = false;
+ $lease->connection->setResourceReleaser($this->forgetDriverConnection(...));
+ }
+
+ /**
+ * Forget physical resources already disconnected by their logical owner.
+ */
+ protected function forgetDriverConnection(): void
+ {
+ /** @var null|PdoConnection $connection */
+ $connection = $this->connection;
+
+ // The connection's grammar retains it until cyclic garbage collection.
+ $connection?->setPdo(null)->setReadPdo(null);
+ $this->connection = null;
+ $this->connectionEstablishedEventPending = false;
+ $this->markInvalid();
+ }
+
/**
* Reconnect to the database.
*/
public function reconnect(): bool
{
- $this->close();
+ $this->closeDriverConnection();
$sharedPdo = $this->pool->getSharedInMemorySqlitePdo();
-
- if ($sharedPdo !== null) {
- // In-memory SQLite: use shared PDO so all pool slots see same data
- $this->connection = $this->factory->makeSqliteFromSharedPdo(
- $sharedPdo,
- $this->config,
- $this->config['name'] ?? null
- );
- } else {
- // Normal path: factory creates a fresh connection with new driver resources.
- $this->connection = $this->factory->make($this->config, $this->config['name'] ?? null);
- }
+ $this->connection = $this->makeConnection();
if (! $this->connection->isReusable()) {
$this->markInvalid();
@@ -128,21 +189,12 @@ public function reconnect(): bool
throw new RuntimeException('Database connection is not reusable after reconnecting.');
}
- // Configure event dispatcher for query events
- if ($this->container->bound('events')) {
- $this->connection->setEventDispatcher($this->container->make('events'));
- }
-
- // Configure transaction manager for after-commit callbacks
- if ($this->container->has('db.transactions')) {
- $this->connection->setTransactionManager($this->container->make('db.transactions'));
+ if (! $this->pool->usesSessionLeases()) {
+ $this->configureConnection($this->connection);
+ $this->connection->setReconnector($this->refresh(...));
+ $this->driverConnectionConfigured = true;
}
- // Set up reconnector for the connection
- $this->connection->setReconnector(function ($connection) {
- $this->refresh($connection);
- });
-
$now = hrtime(true) / 1e9;
$this->lastUseTime = $now;
$this->stampGeneration($now);
@@ -153,6 +205,20 @@ public function reconnect(): bool
return true;
}
+ /**
+ * Configure services used by the caller-visible connection.
+ */
+ protected function configureConnection(Connection $connection): void
+ {
+ if ($this->container->bound('events')) {
+ $connection->setEventDispatcher($this->container->make('events'));
+ }
+
+ if ($this->container->has('db.transactions')) {
+ $connection->setTransactionManager($this->container->make('db.transactions'));
+ }
+ }
+
/**
* Notify listeners after the resolver has registered the connection.
*
@@ -173,7 +239,10 @@ public function dispatchConnectionEstablishedEvent(): void
$events = $this->container->make('events');
if ($events->hasListeners(ConnectionEstablished::class)) {
- $events->dispatch(new ConnectionEstablished($this->connection));
+ $connection = $this->lease->connection ?? $this->connection;
+ $connection->withPinnedSession(
+ static fn () => $events->dispatch(new ConnectionEstablished($connection))
+ );
}
}
}
@@ -302,8 +371,30 @@ private function cancelHeartbeatCoroutine(?int $coroutineId): void
* Close the database connection.
*/
public function close(): bool
+ {
+ try {
+ if ($this->lease !== null) {
+ $this->lease->connection->disconnect();
+ } else {
+ $this->closeDriverConnection();
+ }
+ } finally {
+ $this->lease?->detach();
+ $this->lease = null;
+ $this->connection = null;
+ $this->connectionEstablishedEventPending = false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Close the internal resource holder without settling its borrowed slot.
+ */
+ protected function closeDriverConnection(): void
{
$this->connectionEstablishedEventPending = false;
+ $this->driverConnectionConfigured = false;
if ($this->connection instanceof Connection) {
try {
@@ -314,8 +405,47 @@ public function close(): bool
$this->connection = null;
}
}
+ }
- return true;
+ /**
+ * Clean up the owning connection and notify listeners before detachment.
+ */
+ protected function prepareForRelease(): void
+ {
+ $connection = $this->lease->connection ?? $this->connection;
+
+ if ($connection !== null) {
+ $errorCount = $connection->getErrorCount();
+
+ if ($this->lease === null) {
+ $connection->resetForPool();
+ }
+
+ if ($errorCount > self::MAX_ERROR_COUNT) {
+ $this->logger->warning('Connection has too many errors, marking as stale.');
+ $this->markInvalid();
+ }
+
+ if ($connection->transactionLevel() > 0) {
+ $connection->rollBack(0);
+ $this->logger->error('Database transaction was not committed or rolled back before release.');
+ }
+ }
+
+ $this->lastReleaseTime = hrtime(true) / 1e9;
+ $events = $this->pool->getOptions()->events;
+
+ if (in_array(ConnectionReleasing::class, $events, true)
+ && $this->container->bound('events')
+ ) {
+ // Event::fake() can replace the dispatcher after this connection was created.
+ /** @var Dispatcher $dispatcher */
+ $dispatcher = $this->container->make('events');
+
+ if ($dispatcher->hasListeners(ConnectionReleasing::class)) {
+ $dispatcher->dispatch(new ConnectionReleasing($this));
+ }
+ }
}
/**
@@ -327,39 +457,10 @@ public function release(): void
$ordinaryFailure = null;
try {
- if ($this->connection instanceof Connection) {
- $errorCount = $this->connection->getErrorCount();
-
- // Reset wrapper state before another coroutine borrows it.
- $this->connection->resetForPool();
-
- // Check error count and mark as stale if too high
- if ($errorCount > self::MAX_ERROR_COUNT) {
- $this->logger->warning('Connection has too many errors, marking as stale.');
- $this->markInvalid();
- }
-
- // Roll back any uncommitted transactions (including nested savepoints)
- if ($this->connection->transactionLevel() > 0) {
- $this->connection->rollBack(0);
- $this->logger->error('Database transaction was not committed or rolled back before release.');
- }
- }
-
- $this->lastReleaseTime = hrtime(true) / 1e9;
-
- // Dispatch release event if configured
- $events = $this->pool->getOptions()->events;
- if (in_array(ConnectionReleasing::class, $events, true)
- && $this->container->bound('events')
- ) {
- // Event::fake() can replace the dispatcher after this connection was created.
- /** @var Dispatcher $dispatcher */
- $dispatcher = $this->container->make('events');
-
- if ($dispatcher->hasListeners(ConnectionReleasing::class)) {
- $dispatcher->dispatch(new ConnectionReleasing($this));
- }
+ if ($this->lease !== null) {
+ $this->lease->connection->withPinnedSession($this->prepareForRelease(...));
+ } else {
+ $this->prepareForRelease();
}
} catch (CanceledException $cancellation) {
$cancellationFailure = $cancellation;
@@ -374,6 +475,9 @@ public function release(): void
} catch (Throwable $loggingException) {
$ordinaryFailure = $loggingException;
}
+ } finally {
+ $this->lease?->detach();
+ $this->lease = null;
}
try {
@@ -488,26 +592,41 @@ private function stampGeneration(float $now): void
}
/**
- * Refresh the database connection resources.
+ * Create driver resources for a new physical connection generation.
*/
- protected function refresh(Connection $connection): void
+ protected function makeConnection(): Connection
{
$sharedPdo = $this->pool->getSharedInMemorySqlitePdo();
- try {
- if ($sharedPdo !== null) {
- // For shared in-memory SQLite, rebind to the same PDO.
- // Creating a fresh PDO would give us a new empty database.
- $fresh = $this->factory->makeSqliteFromSharedPdo(
- $sharedPdo,
- $this->config,
- $this->config['name'] ?? null
- );
- } else {
- $fresh = $this->factory->make($this->config, $this->config['name'] ?? null);
- }
+ if ($sharedPdo !== null) {
+ // Creating a fresh PDO would discard the shared in-memory database.
+ return $this->factory->makeSqliteFromSharedPdo(
+ $sharedPdo,
+ $this->config,
+ $this->config['name'] ?? null
+ );
+ }
- $connection->refreshFrom($fresh);
+ $config = $this->config;
+ $name = $config['name'] ?? null;
+
+ if (($config[Connection::READ_WRITE_TYPE_CONFIG_KEY] ?? null) === ConnectionName::READ
+ && $this->factory->hasReadConfig($config)
+ && $this->factory->getExtension($config, $name) === null
+ ) {
+ $config = $this->factory->configForRead($config);
+ }
+
+ return $this->factory->make($config, $name);
+ }
+
+ /**
+ * Refresh the database connection resources.
+ */
+ protected function refresh(Connection $connection): void
+ {
+ try {
+ $connection->refreshFrom($this->makeConnection());
} catch (Throwable $exception) {
$this->markInvalid();
diff --git a/src/docs/database.md b/src/docs/database.md
index 92fbd8dd6..c783394f7 100644
--- a/src/docs/database.md
+++ b/src/docs/database.md
@@ -9,6 +9,7 @@
- [PostgreSQL Server Options](#postgresql-server-options)
- [Read and Write Connections](#read-and-write-connections)
- [Connection Pooling](#connection-pooling)
+ - [Releasing Idle Connections](#releasing-idle-connections)
- [Configuring Database Session State](#configuring-database-session-state)
- [Extending Database Connections](#extending-database-connections)
- [Static Analysis](#static-analysis)
@@ -227,12 +228,12 @@ Eloquent models created or retrieved through `::write` retain that connection fo
#### The `sticky` Option
-The `sticky` option is an *optional* value that can be used to allow the immediate reading of records that have been written to the database during the current request cycle. If the `sticky` option is enabled and a "write" operation has been performed against the database during the current request cycle, any further "read" operations will use the "write" connection. This ensures that any data written during the request cycle can be immediately read back from the database during that same request. In Hypervel, sticky state is reset when the coroutine's connection is returned to the pool, so it will not leak into another request. It is up to you to decide if this is the desired behavior for your application.
+The `sticky` option is an *optional* value that can be used to allow the immediate reading of records that have been written to the database during the current request cycle. If the `sticky` option is enabled and a "write" operation has been performed against the database during the current request cycle, any further "read" operations will use the "write" connection. This ensures that any data written during the request cycle can be immediately read back from the database during that same request. In Hypervel, sticky state belongs to the coroutine's connection and survives an early release of its physical session. Other coroutines have independent routing state. It is up to you to decide if this is the desired behavior for your application.
### Connection Pooling
-Hypervel uses connection pools to keep database access efficient within long-lived Swoole workers. When a coroutine needs a database connection, Hypervel borrows a connection from the worker's pool, stores it for the current coroutine, and returns it to the pool when the coroutine ends. Before a connection is returned to the pool, Hypervel resets per-request state such as query logs, query duration tracking, transaction callbacks, read / write routing state, and uncommitted transactions.
+Hypervel uses connection pools to keep database access efficient within long-lived Swoole workers. When a coroutine resolves a database connection, Hypervel borrows a physical session from the worker's pool. The coroutine keeps its own connection object, including query logs, callbacks and read / write routing state. Query and schema builders retain that object even if its idle session is returned to the pool and another session is borrowed later. When the coroutine ends, Hypervel rolls back unfinished transactions and returns its remaining borrowed sessions.
Each connection may define its own `pool` configuration:
@@ -270,6 +271,8 @@ For a connection with separate read and write hosts, each base pool slot may laz
When a read side is configured, explicit `::read` connections use a separate read-side pool built from the merged read configuration, including the base `pool` settings unless the read configuration overrides them. Drivers registered through `DB::extend` still receive the complete connection configuration so they can select their own endpoints; the pool's options come from the merged read configuration. Without a read side, `::read` uses the base pool. Explicit `::write` connections do not create a separate pool, but a coroutine that uses both `mysql` and `mysql::write` at the same time may borrow two slots from the base pool. Most applications do not need these suffixes in normal query paths because Hypervel already routes reads, writes, transactions, and sticky reads automatically.
+If your `read` configuration contains a list of connection records, Hypervel chooses a record for each new physical connection and chooses again when reconnecting. All records in an explicit `::read` pool must have the same effective `pool` options, including inherited defaults. Conflicting options throw an exception when the pool is created, since the records share one pool capacity and lifecycle policy. Derived read pools cannot use in-memory SQLite databases.
+
Heartbeat and max lifetime recycling apply to Hypervel's worker pool whether the connection points directly at the database or through a proxy / pooler. They help long-running workers avoid stale sockets and rotate old idle connection generations before those connections are used by a request.
Hypervel's default database configuration also includes a `pgsql-pooled` connection. This connection is intended for PostgreSQL transaction poolers such as PgBouncer and uses separate `DB_POOLED_*` environment variables. It also sets `migrations_connection` to `pgsql`, allowing your application to use the pooled connection at runtime while migration commands use the direct PostgreSQL connection.
@@ -278,6 +281,31 @@ You may use `migrations_connection` on any database connection to instruct migra
When you need a direct connection for migrations or schema operations, configure it as a normal connection and reference it with `migrations_connection`. Hypervel does not use Laravel's `::direct` connection suffix.
+
+#### Releasing Idle Connections
+
+A slow external request can keep a database session occupied long after the last query finishes. Before waiting on an external service, you may return the current execution's idle sessions to their pools:
+
+```php
+DB::releaseIdleConnections();
+
+$response = Http::post($url, $payload);
+```
+
+The next database operation borrows a session automatically. Existing connection objects, builders, query logs and sticky reads remain valid. Registered [session configurators](#configuring-database-session-state) apply the current execution's settings before a borrowed PDO is used. This method does not open a connection when none has been used.
+
+Active queries, transactions, open cursors and `Schema::withoutForeignKeyConstraints` callbacks retain their sessions automatically. Completed `chunk` or `lazy` query batches may release between batches. If your own code needs the same physical session across a release boundary, wrap that work in `withPinnedSession`:
+
+```php
+DB::connection()->withPinnedSession(function () {
+ // Work that must retain the same physical database session...
+});
+```
+
+This is needed for temporary tables, session-level locks, retained raw PDOs or statements, and manual session changes spanning a release boundary. It also applies to a manual `disableForeignKeyConstraints` / `enableForeignKeyConstraints` pair; prefer the scoped `withoutForeignKeyConstraints` method. Pinning prevents early release but does not prevent replacing a broken connection during the normal lost-connection retry.
+
+Connections registered through `DB::extend` retain their complete driver object until execution ends; early release leaves them alone. The same applies when selectable write records (or read records in an explicit `::read` pool) specify different database names or table prefixes. PDO drivers registered through `Connection::resolverFor` participate in early release when those values agree.
+
### Configuring Database Session State
@@ -380,6 +408,8 @@ Connection::resolverFor('mysql', function (
});
```
+For a custom PDO driver name, also bind `db.connector.{driver}` in your service provider. The connector must implement `Hypervel\Database\Connectors\ConnectorInterface`; its `connect` method receives the configuration and returns a PDO instance. Existing driver names use Hypervel's built-in connectors unless you replace their binding.
+
If your driver uses an HTTP client, native extension, or another non-PDO transport, extend the driver-neutral `Connection` class and register it during application boot using the `DB::extend` method:
```php
diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php
index 82a18d208..c56a4b5ec 100644
--- a/src/support/src/Facades/DB.php
+++ b/src/support/src/Facades/DB.php
@@ -30,6 +30,7 @@
* @method static void purge(\UnitEnum|string|null $name = null)
* @method static void purgeConnections()
* @method static \Hypervel\Database\Connection reconnect(\UnitEnum|string|null $name = null)
+ * @method static void releaseIdleConnections()
* @method static \Hypervel\Database\DatabaseManager setApplication(\Hypervel\Contracts\Foundation\Application $app)
* @method static void setDefaultConnection(string|null $name)
* @method static void setReconnector(callable $reconnector)
@@ -119,6 +120,7 @@
* @method static \Hypervel\Database\Connection useWriteConnectionWhenReading(bool $value = true)
* @method static mixed withoutPretending(\Closure $callback)
* @method static mixed withoutTablePrefix(\Closure $callback)
+ * @method static mixed withPinnedSession(\Closure $callback)
* @method static void bindValues(\PDOStatement $statement, array $bindings)
* @method static void configureSessionUsing(\Hypervel\Database\SessionConfigurator $configurator)
* @method static \PDO getPdo()
diff --git a/tests/Benchmarks/Database/README.md b/tests/Benchmarks/Database/README.md
new file mode 100644
index 000000000..f44cb7b54
--- /dev/null
+++ b/tests/Benchmarks/Database/README.md
@@ -0,0 +1,21 @@
+# Database lifecycle benchmark
+
+This developer-only harness measures ordinary coroutine-owned database lifecycles: initial connection resolution, queries, transactions, and deferred pool cleanup. File-backed SQLite isolates framework CPU and allocation costs without network latency; it does not predict a database server's throughput. It uses the real pool and resolver with Testbench pooling enabled.
+
+Run only during an owner-confirmed idle window. Run the same harness in independently installed baseline and modified worktrees, using the same dependency versions, PHP configuration and machine. Record each revision and any uncommitted changes with the reports. Alternate the order of baseline and modified runs to reduce ordering bias.
+
+```shell
+php tests/Benchmarks/Database/benchmark.php --concurrency=1 --queries=0 > /tmp/db-resolution.json
+php tests/Benchmarks/Database/benchmark.php --concurrency=1 --queries=1 > /tmp/db-first-query.json
+php tests/Benchmarks/Database/benchmark.php --concurrency=1 --queries=10 > /tmp/db-queries.json
+php tests/Benchmarks/Database/benchmark.php --concurrency=32 --queries=10 --wait-us=1000 > /tmp/db-concurrent.json
+php tests/Benchmarks/Database/benchmark.php --concurrency=32 --queries=10 --wait-us=1000 --transaction > /tmp/db-transactions.json
+```
+
+The short simulated external wait lets requests overlap and hold real pool slots. Without a wait, SQLite may complete each coroutine before the next starts; use the reported peak active requests and borrowed slots rather than treating configured concurrency as observed concurrency. Compare CPU per request as well as wall-clock throughput when waits are enabled.
+
+On a checkout supporting early release, add `--release` to the concurrent scenario. It returns idle sessions before the wait while retaining the logical connection for subsequent queries. Combining it with `--transaction` measures the pinned path. Baselines without the API reject `--release`; ordinary lifecycle scenarios remain directly comparable.
+
+Each scenario warms the pool, then reports repeated samples. Collection runs only between samples, never within the measured request loop. Reports include CPU time, resolution/query/deferred-cleanup timing, peak heap and ending heap growth, GC runs/collected objects/roots/collector time, and pool ownership after completion. GC roots are sampled after each bounded batch; peak heap captures growth between collections. Memory figures describe the worker, not independent per-request peaks. Harness scheduling and measurement overhead are present in both versions.
+
+Use `--requests`, `--samples`, `--concurrency`, `--queries` and `--wait-us` to vary workload. Keep raw reports outside the repository. Do not use throughput thresholds as CI assertions or add production GC calls to improve benchmark results.
diff --git a/tests/Benchmarks/Database/benchmark.php b/tests/Benchmarks/Database/benchmark.php
new file mode 100644
index 000000000..749fdb502
--- /dev/null
+++ b/tests/Benchmarks/Database/benchmark.php
@@ -0,0 +1,210 @@
+#!/usr/bin/env php
+ 10000, 'concurrency' => 32, 'queries' => 10, 'samples' => 7, 'wait-us' => 0];
+
+foreach ($settings as $name => $default) {
+ $value = filter_var($options[$name] ?? $default, FILTER_VALIDATE_INT);
+ $minimum = in_array($name, ['queries', 'wait-us'], true) ? 0 : 1;
+
+ if ($value === false || $value < $minimum) {
+ throw new InvalidArgumentException("--{$name} must be an integer of at least {$minimum}.");
+ }
+
+ $settings[$name] = $value;
+}
+
+$release = isset($options['release']);
+$transaction = isset($options['transaction']);
+
+if ($release && ! method_exists(DatabaseManager::class, 'releaseIdleConnections')) {
+ throw new RuntimeException('This checkout does not support early release; compare ordinary lifecycle scenarios against it.');
+}
+
+Bootstrapper::bootstrap();
+$databasePath = tempnam(sys_get_temp_dir(), 'hypervel-database-benchmark-');
+
+if ($databasePath === false) {
+ throw new RuntimeException('Cannot create the benchmark database.');
+}
+
+$application = null;
+
+try {
+ $application = Application::create(options: ['load_environment_variables' => false]);
+ $application->make('config')->set('database.connections.benchmark', [
+ 'driver' => 'sqlite',
+ 'database' => $databasePath,
+ 'prefix' => '',
+ 'foreign_key_constraints' => true,
+ 'pool' => [
+ 'testing_enabled' => true,
+ 'min_retained_connections' => 0,
+ 'max_connections' => $settings['concurrency'],
+ 'heartbeat_interval' => null,
+ 'idle_check_interval' => null,
+ ],
+ ]);
+ $database = $application->make(DatabaseManager::class);
+ $pool = $application->make(PoolManager::class)->pool('benchmark');
+ $reports = [];
+ $failure = null;
+
+ run(function () use ($database, $pool, $settings, $release, $transaction, &$reports, &$failure): void {
+ try {
+ for ($sample = -1; $sample < $settings['samples']; ++$sample) {
+ // Collect between samples, never inside the measured request loop.
+ gc_collect_cycles();
+ memory_reset_peak_usage();
+ $gcBefore = gc_status();
+ $heapBefore = memory_get_usage();
+ $cpuBefore = getrusage();
+ $started = hrtime(true);
+ $active = 0;
+ $peakActive = 0;
+ $peakBorrowed = 0;
+ $peakRoots = $gcBefore['roots'];
+ $resolutionNanoseconds = 0;
+ $queryNanoseconds = 0;
+ $cleanupNanoseconds = 0;
+ $requests = $sample < 0 ? max(100, $settings['concurrency']) : $settings['requests'];
+
+ $request = function () use (
+ $database,
+ $pool,
+ $settings,
+ $release,
+ $transaction,
+ &$active,
+ &$peakActive,
+ &$peakBorrowed,
+ &$resolutionNanoseconds,
+ &$queryNanoseconds,
+ &$cleanupNanoseconds,
+ ): void {
+ ++$active;
+ $peakActive = max($peakActive, $active);
+ $cleanupStarted = 0;
+ // Registered first, so database release runs before this measurement.
+ Coroutine::defer(static function () use (&$active, &$cleanupStarted, &$cleanupNanoseconds): void {
+ $cleanupNanoseconds += hrtime(true) - $cleanupStarted;
+ --$active;
+ });
+
+ try {
+ $resolving = hrtime(true);
+ $connection = $database->connection('benchmark');
+ $resolutionNanoseconds += hrtime(true) - $resolving;
+ $peakBorrowed = max($peakBorrowed, $pool->getBorrowedCount());
+
+ if ($transaction) {
+ $connection->beginTransaction();
+ }
+
+ for ($query = 0; $query < max(1, $settings['queries']); ++$query) {
+ if ($settings['queries'] > 0) {
+ $queryStarted = hrtime(true);
+ $connection->selectOne('select 1 as value');
+ $queryNanoseconds += hrtime(true) - $queryStarted;
+ }
+
+ if ($query === 0) {
+ if ($release) {
+ $database->releaseIdleConnections();
+ }
+
+ if ($settings['wait-us'] > 0) {
+ usleep($settings['wait-us']);
+ }
+ }
+ }
+
+ if ($transaction) {
+ $connection->commit();
+ }
+ } finally {
+ $cleanupStarted = hrtime(true);
+ }
+ };
+
+ for ($completed = 0; $completed < $requests; $completed += $settings['concurrency']) {
+ parallel(array_fill(0, min($settings['concurrency'], $requests - $completed), $request));
+ $peakRoots = max($peakRoots, gc_status()['roots']);
+ }
+
+ $elapsed = hrtime(true) - $started;
+ $cpuAfter = getrusage();
+ $gcAfter = gc_status();
+
+ if ($sample >= 0) {
+ $reports[] = [
+ 'requests_per_second' => $requests * 1e9 / $elapsed,
+ 'cpu_microseconds_per_request' => (
+ ($cpuAfter['ru_utime.tv_sec'] + $cpuAfter['ru_stime.tv_sec'] - $cpuBefore['ru_utime.tv_sec'] - $cpuBefore['ru_stime.tv_sec']) * 1e6
+ + $cpuAfter['ru_utime.tv_usec'] + $cpuAfter['ru_stime.tv_usec'] - $cpuBefore['ru_utime.tv_usec'] - $cpuBefore['ru_stime.tv_usec']
+ ) / $requests,
+ 'resolution_ns_per_request' => $resolutionNanoseconds / $requests,
+ 'query_ns_per_request' => $queryNanoseconds / $requests,
+ 'deferred_cleanup_ns_per_request' => $cleanupNanoseconds / $requests,
+ 'peak_active_requests' => $peakActive,
+ 'peak_borrowed_slots' => $peakBorrowed,
+ 'borrowed_slots_after_sample' => $pool->getBorrowedCount(),
+ 'heap_growth_bytes' => memory_get_usage() - $heapBefore,
+ 'peak_heap_bytes' => memory_get_peak_usage(),
+ 'gc_runs' => $gcAfter['runs'] - $gcBefore['runs'],
+ 'gc_collected' => $gcAfter['collected'] - $gcBefore['collected'],
+ 'gc_roots' => $gcAfter['roots'],
+ 'peak_sampled_gc_roots' => $peakRoots,
+ 'gc_collector_seconds' => $gcAfter['collector_time'] - $gcBefore['collector_time'],
+ ];
+ }
+ }
+ } catch (Throwable $exception) {
+ $failure = $exception;
+ }
+ });
+
+ if ($failure !== null) {
+ throw $failure;
+ }
+
+ echo json_encode([
+ 'php' => PHP_VERSION,
+ 'swoole' => swoole_version(),
+ 'opcache_cli' => ini_get('opcache.enable_cli'),
+ 'jit' => ini_get('opcache.jit'),
+ 'settings' => $settings + ['release' => $release, 'transaction' => $transaction],
+ 'samples' => $reports,
+ ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), PHP_EOL;
+} finally {
+ try {
+ $application?->make(PoolManager::class)->purgeAll();
+ $application?->terminate();
+ } finally {
+ unlink($databasePath);
+ }
+}
diff --git a/tests/Database/ConnectionResolverTest.php b/tests/Database/ConnectionResolverTest.php
index d5b62009b..9a58e178a 100644
--- a/tests/Database/ConnectionResolverTest.php
+++ b/tests/Database/ConnectionResolverTest.php
@@ -129,7 +129,7 @@ public function testNestedOverrideRestoresExactPriorValue(): void
public function testNonCoroutineConnectionIsRetainedUntilTerminalRelease(): void
{
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$firstWrapper = m::mock(PooledConnection::class);
$secondWrapper = m::mock(PooledConnection::class);
$firstConnection = m::mock(Connection::class);
@@ -163,7 +163,7 @@ public function testTerminalReleaseOwnsEveryRequestedConnectionRole(): void
$resolver = $this->makeResolver('mysql', $poolManager);
foreach (['mysql' => null, 'mysql::read' => 'read', 'mysql::write' => 'write'] as $name => $role) {
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$connection = m::mock(Connection::class);
@@ -191,7 +191,7 @@ public function testTerminalReleaseOwnsEveryRequestedConnectionRole(): void
public function testBorrowedConnectionRetainsItsRequestedRole(): void
{
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$resolver = $this->makeResolver('sqlite', $poolManager);
$pool->allows('getSharedInMemorySqlitePdo')->andReturnNull();
@@ -214,7 +214,7 @@ public function testBorrowedConnectionRetainsItsRequestedRole(): void
public function testSharedInMemorySqliteAliasesReuseOneConnectionOwner(): void
{
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$connection = m::mock(Connection::class);
@@ -237,10 +237,10 @@ public function testSharedInMemorySqliteAliasesReuseOneConnectionOwner(): void
$resolver->releaseConnections();
}
- public function testTerminalStateIsDetachedBeforeReleaseCanReenterTheResolver(): void
+ public function testTerminalStateRemainsAvailableUntilItsOwnerIsReleased(): void
{
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$firstWrapper = m::mock(PooledConnection::class);
$secondWrapper = m::mock(PooledConnection::class);
$firstConnection = m::mock(Connection::class);
@@ -258,14 +258,16 @@ public function testTerminalStateIsDetachedBeforeReleaseCanReenterTheResolver():
$resolver = $this->makeResolver('mysql', $poolManager);
$resolver->setDefaultConnection('reporting');
- $firstWrapper->expects('release')->andReturnUsing(function () use ($resolver, $secondConnection): void {
- $this->assertSame('mysql', $resolver->getDefaultConnection());
- $this->assertSame($secondConnection, $resolver->connection('mysql'));
+ $firstWrapper->expects('release')->andReturnUsing(function () use ($resolver, $firstConnection): void {
+ $this->assertSame('reporting', $resolver->getDefaultConnection());
+ $this->assertSame($firstConnection, $resolver->connection('mysql'));
});
$this->assertSame($firstConnection, $resolver->connection('mysql'));
$resolver->releaseConnections();
+ $this->assertSame('mysql', $resolver->getDefaultConnection());
+ $this->assertSame($secondConnection, $resolver->connection('mysql'));
$resolver->releaseConnections();
}
@@ -280,7 +282,7 @@ public function testTerminalReleaseExhaustsConnectionsAndPreservesTheFirstFailur
['first', $firstException],
['second', $secondException],
] as [$name, $exception]) {
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with($name)->andReturn($pool);
@@ -316,7 +318,7 @@ public function testTerminalReleasePrioritizesTheFirstCancellationAndStillExhaus
['second', $firstCancellation],
['third', $secondCancellation],
] as [$name, $failure]) {
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with($name)->andReturn($pool);
@@ -345,7 +347,7 @@ public function testTerminalDiscardExhaustsExactConnections(): void
$resolver = $this->makeResolver('first', $poolManager);
foreach (['first', 'second'] as $name) {
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with($name)->andReturn($pool);
@@ -366,7 +368,7 @@ public function testConnectionRetrievalFailureDiscardsTheExactWrapper(): void
{
$exception = new RuntimeException('Connection retrieval failed.');
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with('mysql')->andReturn($pool);
@@ -389,7 +391,7 @@ public function testListenerFailureDiscardsTheExactWrapperWithoutDeferringReleas
{
$exception = new RuntimeException('Connection listener failed.');
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->with('mysql')->andReturn($pool);
@@ -418,7 +420,7 @@ public function testWriteRoleConfigurationFailureDiscardsTheExactWrapper(): void
{
$exception = new RuntimeException('Write role configuration failed.');
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$connection = m::mock(Connection::class);
@@ -445,7 +447,7 @@ public function testDiscardFailureDoesNotReplaceTheSetupFailure(): void
$setupException = new RuntimeException('Connection retrieval failed.');
$discardException = new RuntimeException('Discard failed.');
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with('mysql')->andReturn($pool);
@@ -469,7 +471,7 @@ public function testDiscardCancellationReplacesAnOrdinarySetupFailure(): void
$setupException = new RuntimeException('Connection retrieval failed.');
$discardCancellation = new CanceledException('Discard was canceled.');
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with('mysql')->andReturn($pool);
@@ -493,7 +495,7 @@ public function testSetupCancellationRemainsPrimaryOverAnOrdinaryDiscardFailure(
$setupCancellation = new CanceledException('Connection setup was canceled.');
$discardException = new RuntimeException('Discard failed.');
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with('mysql')->andReturn($pool);
@@ -517,7 +519,7 @@ public function testSetupCancellationRemainsPrimaryOverDiscardCancellation(): vo
$setupCancellation = new CanceledException('Connection setup was canceled.');
$discardCancellation = new CanceledException('Discard was canceled.');
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$poolManager->expects('pool')->once()->with('mysql')->andReturn($pool);
@@ -539,7 +541,7 @@ public function testSetupCancellationRemainsPrimaryOverDiscardCancellation(): vo
public function testCoroutineConnectionRemainsDeferOwned(): void
{
$poolManager = m::mock(PoolManager::class);
- $pool = m::mock(DatabasePool::class);
+ $pool = m::mock(DatabasePool::class, ['usesSessionLeases' => false]);
$wrapper = m::mock(PooledConnection::class);
$connection = m::mock(Connection::class);
diff --git a/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php
index 3b2be9740..9b25cd230 100755
--- a/tests/Database/DatabaseConnectionFactoryTest.php
+++ b/tests/Database/DatabaseConnectionFactoryTest.php
@@ -315,6 +315,31 @@ public function testReadWriteConnectionSetsReadConnectionConfig(): void
$this->assertSame(':memory:', $config['database']);
}
+ public function testReadEndpointMetadataMatchesTheSelectedPdoConfiguration(): void
+ {
+ $container = new Container;
+ $pdo = m::mock(PDO::class);
+ $connector = m::mock(ConnectorInterface::class);
+ $connector->expects('connect')->withArgs(
+ static fn (array $config): bool => $config['host'] === 'first-reader'
+ && $config['options'][PDO::ATTR_EMULATE_PREPARES] === true
+ )->andReturn($pdo);
+ $container->instance('db.connector.pgsql', $connector);
+ $factory = new AlternatingReadConnectionFactory($container);
+ $connection = $factory->make([
+ 'driver' => 'pgsql',
+ 'database' => 'app',
+ 'write' => [],
+ 'read' => [
+ ['host' => 'first-reader', 'options' => [PDO::ATTR_EMULATE_PREPARES => true]],
+ ['host' => 'second-reader', 'options' => [PDO::ATTR_EMULATE_PREPARES => false]],
+ ],
+ ], 'read-options');
+
+ $this->assertSame($pdo, $connection->getReadPdo());
+ $this->assertSame(['true', 'false'], $connection->prepareBindings([true, false]));
+ }
+
// REMOVED: Laravel's external-pooler configuration tests. Hypervel configures
// the direct endpoint as a normal named connection and uses migrations_connection
// instead of the ::direct suffix.
@@ -636,6 +661,21 @@ class FactorySqliteConnection extends SQLiteConnection
{
}
+class AlternatingReadConnectionFactory extends ConnectionFactory
+{
+ protected int $readSelection = 0;
+
+ /**
+ * Select consecutive read records to expose repeated endpoint selection.
+ */
+ protected function getReadWriteConfig(array $config, string $type): array
+ {
+ return $type === 'read' && isset($config[$type][0])
+ ? $config[$type][$this->readSelection++ % count($config[$type])]
+ : parent::getReadWriteConfig($config, $type);
+ }
+}
+
class FactoryTestConnectionFactory extends ConnectionFactory
{
/**
diff --git a/tests/Database/DatabaseConnectionLeaseLifecycleTest.php b/tests/Database/DatabaseConnectionLeaseLifecycleTest.php
new file mode 100644
index 000000000..0fbaeda60
--- /dev/null
+++ b/tests/Database/DatabaseConnectionLeaseLifecycleTest.php
@@ -0,0 +1,153 @@
+make('config')->set('app.stdout_log.level', []);
+ $app->make('config')->set('database.connections.leases', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'pool' => [
+ 'testing_enabled' => true,
+ 'max_connections' => 1,
+ 'wait_timeout' => 0.1,
+ 'events' => [ConnectionReleasing::class],
+ ],
+ ]);
+ }
+
+ #[DataProvider('terminalReleases')]
+ public function testTerminalCallbacksResolveTheirOwningConnection(bool $coroutine, bool $listenerFails): void
+ {
+ $resolver = $this->app->make('db.resolver');
+ $pool = $this->app->make(PoolManager::class)->pool('leases');
+ $connection = null;
+ $callbacks = [];
+ $registeredAfterCleanup = null;
+ Event::listen(ConnectionReleasing::class, static function (ConnectionReleasing $event) use (&$callbacks, $listenerFails): void {
+ $resolved = DB::connection();
+ $callbacks[] = ['release', $resolved, $resolved->selectOne('select 2 as value')->value];
+
+ if ($listenerFails) {
+ throw new RuntimeException('Release listener failed after querying its owner.');
+ }
+ });
+ $execute = static function () use (&$connection, &$callbacks): void {
+ DB::setDefaultConnection('leases');
+ $connection = DB::connection();
+ $connection->beginTransaction();
+ $connection->afterRollBack(static function () use (&$callbacks): void {
+ $resolved = DB::connection();
+ $callbacks[] = ['rollback', $resolved, $resolved->selectOne('select 1 as value')->value];
+ });
+ };
+
+ try {
+ if ($coroutine) {
+ $this->runInCoroutine(static function () use ($execute, &$registeredAfterCleanup): void {
+ Coroutine::defer(static function () use (&$registeredAfterCleanup): void {
+ $registeredAfterCleanup = array_key_exists('leases', DB::getConnections());
+ });
+ $execute();
+ });
+ } else {
+ $execute();
+ $resolver->releaseConnections();
+ $registeredAfterCleanup = array_key_exists('leases', DB::getConnections());
+ $this->assertSame(config('database.default'), DB::getDefaultConnection());
+ }
+
+ $this->assertSame([['rollback', $connection, 1], ['release', $connection, 2]], $callbacks);
+ $this->assertFalse($registeredAfterCleanup);
+ $this->assertSame(0, $connection->transactionLevel());
+ $this->assertSame(0, $pool->getBorrowedCount());
+ $this->assertSame(1, $pool->getIdleCount());
+ } finally {
+ $resolver->discardConnections();
+ $pool->close();
+ }
+ }
+
+ /**
+ * Provide terminal cleanup triggers and a failed release callback.
+ */
+ public static function terminalReleases(): array
+ {
+ return [
+ 'coroutine end' => [true, false],
+ 'task end' => [false, false],
+ 'failed task release listener' => [false, true],
+ ];
+ }
+
+ public function testTaskCleanupSettlesTheCurrentLeaseAfterRepeatedEarlyReleases(): void
+ {
+ $resolver = $this->app->make('db.resolver');
+ $connection = DB::connection('leases');
+ $pool = $this->app->make(PoolManager::class)->pool('leases');
+
+ try {
+ foreach ([1, 2] as $value) {
+ $this->assertSame($value, $connection->selectOne('select ? as value', [$value])->value);
+ DB::releaseIdleConnections();
+ $this->assertSame(1, $pool->getIdleCount());
+ }
+
+ $connection->selectOne('select 1');
+ $resolver->releaseConnections();
+ $resolver->releaseConnections();
+
+ $this->assertSame(1, $pool->getIdleCount());
+ $this->assertNotSame($connection, DB::connection('leases'));
+ $resolver->discardConnections();
+ $this->assertSame(0, $pool->getManagedCount());
+ } finally {
+ $resolver->discardConnections();
+ $pool->close();
+ }
+ }
+
+ public function testPurgingAndReplacingAConnectionDoesNotLoseItsPreviousOwner(): void
+ {
+ $resolver = $this->app->make('db.resolver');
+ $pools = $this->app->make(PoolManager::class);
+ $connection = DB::connection('leases');
+ $connection->selectOne('select 1');
+ $previousPool = $pools->pool('leases');
+
+ try {
+ DB::purge('leases');
+ $replacement = DB::connection('leases');
+ $replacement->selectOne('select 2');
+ $resolver->releaseConnections();
+
+ $this->assertNotSame($connection, $replacement);
+ $this->assertTrue($previousPool->isClosed());
+ $this->assertSame(0, $previousPool->getManagedCount());
+ $this->assertSame(1, $pools->pool('leases')->getIdleCount());
+ } finally {
+ $resolver->discardConnections();
+ $pools->purgeAll();
+ }
+ }
+}
diff --git a/tests/Database/DatabaseConnectionLeaseTest.php b/tests/Database/DatabaseConnectionLeaseTest.php
new file mode 100644
index 000000000..36194e762
--- /dev/null
+++ b/tests/Database/DatabaseConnectionLeaseTest.php
@@ -0,0 +1,508 @@
+make('config')->set('database.connections.leases', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ 'foreign_key_constraints' => true,
+ 'pool' => [
+ 'testing_enabled' => true,
+ 'min_retained_connections' => 1,
+ 'max_connections' => 1,
+ 'wait_timeout' => 1.0,
+ 'events' => [ConnectionReleasing::class],
+ ],
+ ]);
+ $app->make('config')->set('database.connections.physical_leases', [
+ 'driver' => 'pgsql',
+ 'database' => 'app',
+ 'read' => ['host' => 'replica'],
+ 'write' => ['host' => 'primary'],
+ 'sticky' => true,
+ 'pool' => ['testing_enabled' => true, 'max_connections' => 1],
+ ]);
+ $app->make('config')->set('database.connections.identity_leases', [
+ 'driver' => 'pgsql',
+ 'database' => 'app',
+ 'read' => [['prefix' => 'first_'], ['prefix' => 'second_']],
+ 'write' => [['prefix' => 'first_'], ['prefix' => 'second_']],
+ 'sticky' => true,
+ 'pool' => ['testing_enabled' => true, 'max_connections' => 1],
+ ]);
+ $app->make('config')->set('database.connections.insert_id_leases', [
+ 'driver' => 'mysql',
+ 'database' => 'app',
+ 'pool' => ['testing_enabled' => true, 'max_connections' => 1],
+ ]);
+ $app->instance('db.connector.pgsql', new LeasePdoConnector);
+ $app->instance('db.connector.mysql', new LeasePdoConnector);
+ }
+
+ public function testRetainedBuildersAndCallerStateSurviveAnotherBorrower(): void
+ {
+ $connection = DB::connection('leases');
+ $schema = $connection->getSchemaBuilder();
+ $schema->create('records', static fn (Blueprint $table) => $table->integer('id'));
+ $builder = $connection->table('records');
+ $builder->insert(['id' => 7]);
+ $connection->enableQueryLog();
+ $queries = 0;
+ $connection->beforeExecuting(static function () use (&$queries): void {
+ ++$queries;
+ });
+
+ DB::releaseIdleConnections();
+ $this->assertSame(1, $this->pool()->getIdleCount());
+
+ [$other] = parallel([static function (): Connection {
+ $connection = DB::connection('leases');
+ $connection->selectOne('select 1');
+ $connection->setTablePrefix('other_');
+ $connection->setDatabaseName('other');
+
+ return $connection;
+ }]);
+
+ $this->assertNotSame($connection, $other);
+ $this->assertSame($connection, DB::connection('leases'));
+ $this->assertSame('', $connection->getTablePrefix());
+ $this->assertSame(':memory:', $connection->getDatabaseName());
+ $this->assertSame(7, $builder->value('id'));
+ $this->assertTrue($schema->hasTable('records'));
+ $this->assertTrue($connection->hasModifiedRecords());
+ $this->assertTrue($connection->logging());
+ $this->assertGreaterThanOrEqual(2, $queries);
+ $this->assertCount($queries, $connection->getQueryLog());
+ }
+
+ public function testConcurrentExternalWaitsDoNotRetainTheOnlyPoolSlot(): void
+ {
+ $ready = new Channel(2);
+ $resume = new Channel(2);
+ $waitForProvider = static function () use ($ready, $resume): int {
+ $connection = DB::connection('leases');
+ $value = $connection->selectOne('select 1 as value')->value;
+ DB::releaseIdleConnections();
+ $ready->push(true);
+
+ if ($resume->pop(3.0) !== true) {
+ throw new RuntimeException('The simulated provider was not resumed.');
+ }
+
+ return $value + $connection->selectOne('select 2 as value')->value;
+ };
+
+ $results = parallel([
+ $waitForProvider,
+ $waitForProvider,
+ function () use ($ready, $resume): void {
+ try {
+ $this->assertTrue($ready->pop(3.0));
+ $this->assertTrue($ready->pop(3.0));
+ $this->assertSame(1, $this->pool()->getIdleCount());
+ } finally {
+ $resume->push(true);
+ $resume->push(true);
+ }
+ },
+ ]);
+
+ $this->assertSame([3, 3, null], $results);
+ $this->assertSame(1, $this->pool()->getIdleCount());
+ }
+
+ #[DataProvider('pinnedScopes')]
+ public function testSessionDependentScopesPreventEarlyRelease(string $scope): void
+ {
+ $connection = DB::connection('leases');
+ $check = function () use ($connection): void {
+ $this->assertSame(1, $connection->selectOne('select 1 as value')->value);
+ DB::releaseIdleConnections();
+ $this->assertSame(0, $this->pool()->getIdleCount());
+ };
+
+ match ($scope) {
+ 'explicit' => $connection->withPinnedSession(
+ fn () => $connection->withPinnedSession($check)
+ ),
+ 'transaction' => $connection->transaction($check),
+ 'foreign keys' => $connection->getSchemaBuilder()->withoutForeignKeyConstraints($check),
+ };
+
+ DB::releaseIdleConnections();
+ $this->assertSame(1, $this->pool()->getIdleCount());
+ }
+
+ /**
+ * Provide scopes whose work requires the same physical session.
+ */
+ public static function pinnedScopes(): array
+ {
+ return [['explicit'], ['transaction'], ['foreign keys']];
+ }
+
+ public function testAnAbandonedCursorReleasesItsPin(): void
+ {
+ $connection = DB::connection('leases');
+ $cursor = $connection->cursor('select 1 as value union all select 2');
+ $this->assertSame(1, $cursor->current()->value);
+ DB::releaseIdleConnections();
+ $this->assertSame(0, $this->pool()->getIdleCount());
+
+ unset($cursor);
+ DB::releaseIdleConnections();
+
+ $this->assertSame(1, $this->pool()->getIdleCount());
+ $this->assertSame(2, $connection->selectOne('select 2 as value')->value);
+ }
+
+ public function testConnectionAndQueryListenersKeepTheLogicalOwnerAttached(): void
+ {
+ $established = null;
+ $released = null;
+ Event::listen(ConnectionEstablished::class, function (ConnectionEstablished $event) use (&$established): void {
+ if ($event->connection->getName() !== 'leases') {
+ return;
+ }
+
+ $established = $event->connection;
+ DB::releaseIdleConnections();
+ $this->assertSame(0, $this->pool()->getIdleCount());
+ $this->assertSame(1, $event->connection->selectOne('select 1 as value')->value);
+ });
+ Event::listen(QueryExecuted::class, function (QueryExecuted $event): void {
+ if ($event->connection->getName() === 'leases') {
+ DB::releaseIdleConnections();
+ $this->assertSame(0, $this->pool()->getIdleCount());
+ }
+ });
+ Event::listen(ConnectionReleasing::class, function (ConnectionReleasing $event) use (&$released): void {
+ $released = $event->connection->getConnection();
+ $this->assertSame(2, $released->selectOne('select 2 as value')->value);
+ });
+
+ $connection = DB::connection('leases');
+ DB::releaseIdleConnections();
+
+ $this->assertSame($connection, $established);
+ $this->assertSame($connection, $released);
+ $this->assertSame(1, $this->pool()->getIdleCount());
+ }
+
+ public function testAnEagerPdoResolverCanConstructTheLogicalDriver(): void
+ {
+ Connection::resolverFor(
+ 'sqlite',
+ static fn (PDO|Closure $pdo, string $database, string $prefix, array $config): LeaseSqliteConnection => new LeaseSqliteConnection($pdo instanceof Closure ? $pdo() : $pdo, $database, $prefix, $config)
+ );
+
+ $connection = DB::connection('leases');
+ $this->assertInstanceOf(LeaseSqliteConnection::class, $connection);
+ $this->assertSame(1, $connection->selectOne('select 1 as value')->value);
+ DB::releaseIdleConnections();
+ $this->assertSame(1, $this->pool()->getIdleCount());
+ $this->assertSame(2, $connection->selectOne('select 2 as value')->value);
+ }
+
+ #[DataProvider('differentConfiguredPrefixesProvider')]
+ public function testDifferentConfiguredPrefixesRetainWholeConnectionOwnership(string $name): void
+ {
+ $connection = DB::connection($name);
+ $pool = $this->app->make(PoolManager::class)->pool($name);
+ $connection->statement('create table ' . $connection->getTablePrefix() . 'records (id integer)');
+ $builder = $connection->table('records');
+ $builder->insert(['id' => 7]);
+
+ DB::releaseIdleConnections();
+
+ $this->assertSame(0, $pool->getIdleCount());
+ $this->assertSame($connection, DB::connection($name));
+ $this->assertSame(7, $builder->value('id'));
+ }
+
+ /**
+ * Provide pools whose selectable endpoints use different table prefixes.
+ */
+ public static function differentConfiguredPrefixesProvider(): array
+ {
+ return [
+ 'read records' => ['identity_leases::read'],
+ 'write records' => ['identity_leases'],
+ ];
+ }
+
+ public function testDisconnectDropsThePhysicalPdoAndReusesTheLogicalConnection(): void
+ {
+ $connection = DB::connection('physical_leases');
+ $pdo = WeakReference::create($connection->getPdo());
+ $readPdo = WeakReference::create($connection->getReadPdo());
+ $this->assertNotSame($pdo->get(), $readPdo->get());
+
+ DB::disconnect('physical_leases');
+
+ $this->assertNull($pdo->get());
+ $this->assertNull($readPdo->get());
+ $this->assertNull($connection->getRawPdo());
+ $this->assertNull($connection->getRawReadPdo());
+ $this->assertSame($connection, DB::connection('physical_leases'));
+ $this->assertSame(2, $connection->selectOne('select 2 as value')->value);
+ }
+
+ public function testCapturedMySqlInsertIdsSurviveEarlyRelease(): void
+ {
+ $connection = DB::connection('insert_id_leases');
+ $connection->statement('create table records (id integer primary key)');
+ $connection->insert('insert into records default values');
+
+ DB::releaseIdleConnections();
+
+ $this->assertSame('1', $connection->getLastInsertId());
+ $this->assertSame(1, $this->app->make(PoolManager::class)->pool('insert_id_leases')->getIdleCount());
+ $connection->insert('insert into records default values');
+ $this->assertSame('2', $connection->getLastInsertId());
+ }
+
+ public function testReadWriteRolesAndStickyRoutingSurviveEarlyRelease(): void
+ {
+ $connection = DB::connection('physical_leases');
+ $this->assertNotSame($connection->getPdo(), $connection->getReadPdo());
+ $connection->statement('create table records (id integer)');
+
+ DB::releaseIdleConnections();
+
+ [$otherWasSticky] = parallel([static function (): bool {
+ $other = DB::connection('physical_leases');
+
+ return $other->getPdo() === $other->getReadPdo();
+ }]);
+
+ $this->assertFalse($otherWasSticky);
+ $this->assertSame($connection->getPdo(), $connection->getReadPdo());
+ DB::releaseIdleConnections();
+
+ $read = DB::connection('physical_leases::read');
+ $write = DB::connection('physical_leases::write');
+ $this->assertSame('replica', $read->getConfig('host'));
+ $this->assertSame('primary', $write->getConfig('host'));
+ $this->assertSame($write->getPdo(), $write->getReadPdo());
+ DB::releaseIdleConnections();
+
+ $this->assertSame(1, $read->selectOne('select 1 as value')->value);
+ $this->assertSame(2, $write->selectOne('select 2 as value')->value);
+ $this->assertSame('replica', $read->getConfig('host'));
+ $this->assertSame('primary', $write->getConfig('host'));
+ }
+
+ public function testConfigFirstExtensionsRetainWholeConnectionOwnership(): void
+ {
+ $constructions = 0;
+ DB::extend('physical_leases', static function (array $config) use (&$constructions): PostgresConnection {
+ ++$constructions;
+
+ return new PostgresConnection(new PDO('sqlite::memory:'), $config['database'], '', $config);
+ });
+ $connection = DB::connection('physical_leases');
+ $connection->selectOne('select 1');
+
+ DB::releaseIdleConnections();
+
+ $this->assertSame(0, $this->app->make(PoolManager::class)->pool('physical_leases')->getIdleCount());
+ $this->assertSame($connection, DB::connection('physical_leases'));
+ $this->assertSame(2, $connection->selectOne('select 2 as value')->value);
+ $this->assertSame(1, $constructions);
+ }
+
+ public function testSessionCapabilitiesAreResolvedAgainAfterEarlyRelease(): void
+ {
+ Connection::resolverFor(
+ 'pgsql',
+ static fn (PDO|Closure $pdo, string $database, string $prefix, array $config): LeaseCapabilityConnection => new LeaseCapabilityConnection($pdo, $database, $prefix, $config)
+ );
+ $connection = DB::connection('physical_leases');
+ $connection->getPdo()->exec('pragma user_version = 100');
+ $this->assertSame(100, $connection->maxBindings());
+
+ DB::releaseIdleConnections();
+
+ parallel([static function (): void {
+ DB::connection('physical_leases')->getPdo()->exec('pragma user_version = 200');
+ }]);
+
+ $this->assertSame(200, $connection->maxBindings());
+ }
+
+ public function testSessionConfiguratorsRunOnTheLogicalOwnerAfterEachBorrowerChangesState(): void
+ {
+ $configurator = new LeaseSessionConfigurator;
+ PdoConnection::configureSessionUsing($configurator);
+ CoroutineContext::set('__lease_test.account', '1');
+ $connection = DB::connection('leases');
+ $this->assertSame(1, $connection->selectOne('pragma user_version')->user_version);
+ DB::releaseIdleConnections();
+
+ [$other] = parallel([function (): Connection {
+ CoroutineContext::set('__lease_test.account', '2');
+ $connection = DB::connection('leases');
+ $this->assertSame(2, $connection->selectOne('pragma user_version')->user_version);
+
+ return $connection;
+ }]);
+
+ $this->assertSame(1, $connection->selectOne('pragma user_version')->user_version);
+ $this->assertSame([
+ [spl_object_id($connection), '1'],
+ [spl_object_id($other), '2'],
+ [spl_object_id($connection), '1'],
+ ], $configurator->applied);
+ }
+
+ #[DataProvider('connectionListenerFailures')]
+ public function testConnectionListenerFailuresSettleTheLeaseOnce(bool $reacquire, bool $cancel): void
+ {
+ $connection = null;
+
+ if ($reacquire) {
+ $connection = DB::connection('leases');
+ $connection->disconnect();
+ DB::releaseIdleConnections();
+ }
+
+ $failure = $cancel ? new CanceledException('listener canceled') : new RuntimeException('listener failed');
+ $fail = true;
+ Event::listen(ConnectionEstablished::class, static function (ConnectionEstablished $event) use ($failure, &$fail): void {
+ if ($event->connection->getName() === 'leases' && $fail) {
+ $fail = false;
+ throw $failure;
+ }
+ });
+
+ try {
+ if ($reacquire) {
+ $connection->getPdo();
+ } else {
+ DB::connection('leases');
+ }
+
+ $this->fail('Expected connection publication to fail.');
+ } catch (Throwable $exception) {
+ $this->assertSame($failure, $exception);
+ }
+
+ $this->assertSame(0, $this->pool()->getManagedCount());
+ $connection ??= DB::connection('leases');
+ $this->assertSame(1, $connection->selectOne('select 1 as value')->value);
+ DB::releaseIdleConnections();
+ $this->assertSame(1, $this->pool()->getManagedCount());
+ $this->assertSame(1, $this->pool()->getIdleCount());
+ }
+
+ /**
+ * Provide publication failures at initial acquisition and reacquisition.
+ */
+ public static function connectionListenerFailures(): array
+ {
+ return [
+ 'initial failure' => [false, false],
+ 'initial cancellation' => [false, true],
+ 'reacquisition failure' => [true, false],
+ 'reacquisition cancellation' => [true, true],
+ ];
+ }
+
+ /**
+ * Get the pool used by the test's connections.
+ */
+ protected function pool(): DatabasePool
+ {
+ return $this->app->make(PoolManager::class)->pool('leases');
+ }
+}
+
+class LeaseSqliteConnection extends SQLiteConnection
+{
+}
+
+class LeaseCapabilityConnection extends PostgresConnection
+{
+ /**
+ * Read a session-specific capability from the fixture's SQLite database.
+ */
+ protected function resolveMaxBindings(): int
+ {
+ return (int) $this->getPdo()->query('pragma user_version')->fetchColumn();
+ }
+}
+
+class LeasePdoConnector implements ConnectorInterface
+{
+ /**
+ * Provide independent physical PDOs without an external database server.
+ */
+ public function connect(array $config): PDO
+ {
+ return new PDO('sqlite::memory:');
+ }
+}
+
+class LeaseSessionConfigurator implements SessionConfigurator
+{
+ public array $applied = [];
+
+ /**
+ * Resolve the current borrower's desired session state.
+ */
+ public function state(PdoConnection $connection): ?string
+ {
+ return $connection->getName() === 'leases'
+ ? CoroutineContext::get('__lease_test.account')
+ : null;
+ }
+
+ /**
+ * Apply a visible SQLite session value and record its logical owner.
+ */
+ public function apply(PDO $pdo, string $state, PdoConnection $connection): void
+ {
+ $pdo->exec('pragma user_version = ' . $state);
+ $this->applied[] = [spl_object_id($connection), $state];
+ }
+}
diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php
index e7869a54c..4c1dda55a 100755
--- a/tests/Database/DatabasePdoConnectionTest.php
+++ b/tests/Database/DatabasePdoConnectionTest.php
@@ -26,6 +26,7 @@
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use stdClass;
+use WeakReference;
class DatabasePdoConnectionTest extends TestCase
{
@@ -875,6 +876,67 @@ public function testLostManagedCommitTerminallyDetachesTransactionState(): void
$this->assertNull($connection->getRawReadPdo());
}
+ #[DataProvider('lostTransactionOperations')]
+ public function testLostTransactionCleanupPreservesAConnectionOpenedByARollbackCallback(string $operation): void
+ {
+ $failure = new PDOException('server has gone away');
+ $pdo = m::mock(PDO::class);
+ $pdo->expects('beginTransaction')->andReturnTrue();
+ $pdo->shouldNotReceive('prepare');
+
+ if ($operation === 'rollback') {
+ $pdo->expects('inTransaction')->andReturnTrue();
+ $pdo->expects('rollBack')->andThrow($failure);
+ } else {
+ $pdo->expects('commit')->andThrow($failure);
+ }
+
+ $connection = $this->getMockConnection([], $pdo);
+ $connection->setTransactionManager(new DatabaseTransactionsManager);
+ $replacement = new PDO('sqlite::memory:');
+ $reconnections = 0;
+ $connection->setReconnector(static function (PdoConnection $connection) use ($replacement, &$reconnections): void {
+ ++$reconnections;
+ $connection->setPdo($replacement);
+ });
+ $result = null;
+ $registerCallback = static function (Connection $connection) use (&$result): void {
+ $connection->afterRollBack(static function () use ($connection, &$result): void {
+ $result = $connection->selectOne('select 1 as value')->value;
+ });
+ };
+
+ try {
+ if ($operation === 'rollback') {
+ $connection->beginTransaction();
+ $registerCallback($connection);
+ $connection->rollBack();
+ } else {
+ $connection->transaction($registerCallback);
+ }
+
+ $this->fail('Expected the lost transaction operation to fail.');
+ } catch (PDOException $exception) {
+ $this->assertSame($failure, $exception);
+ }
+
+ $this->assertSame(1, $result);
+ $this->assertSame(1, $reconnections);
+ $this->assertSame($replacement, $connection->getRawPdo());
+ $this->assertSame(0, $connection->transactionLevel());
+ }
+
+ /**
+ * Provide operations that terminally forget a lost transaction connection.
+ */
+ public static function lostTransactionOperations(): array
+ {
+ return [
+ 'explicit rollback' => ['rollback'],
+ 'managed commit' => ['commit'],
+ ];
+ }
+
public function testNonLostPhysicalRollbackFailureKeepsActiveStateAndMarksTheSessionUnknown(): void
{
$failure = new RuntimeException('rollback failure');
@@ -1035,6 +1097,26 @@ public function testDisconnectPreservesManagerFailureAfterLostPhysicalRollbackFa
$this->assertNull($connection->getRawReadPdo());
}
+ public function testRefreshTransfersOwnershipOfBothPhysicalHandles(): void
+ {
+ $connection = new PdoConnection(new PDO('sqlite::memory:'));
+ $fresh = new PdoConnection(new PDO('sqlite::memory:'));
+ $fresh->setReadPdo(new PDO('sqlite::memory:'));
+ $writePdo = WeakReference::create($fresh->getRawPdo());
+ $readPdo = WeakReference::create($fresh->getRawReadPdo());
+
+ $connection->refreshFrom($fresh);
+ unset($fresh);
+
+ $this->assertSame($writePdo->get(), $connection->getPdo());
+ $this->assertSame($readPdo->get(), $connection->getReadPdo());
+
+ $connection->disconnect();
+
+ $this->assertNull($writePdo->get());
+ $this->assertNull($readPdo->get());
+ }
+
public function testRefreshRetainsConfiguredBaselinesWhenPreparationFails(): void
{
$oldPdo = new PDOStub;
@@ -1048,11 +1130,12 @@ public function testRefreshRetainsConfiguredBaselinesWhenPreparationFails(): voi
$connection->setTablePrefix('tenant_');
$failure = new RuntimeException('read connection failed');
$fresh = new PdoConnection(
- new PDOStub,
+ new PDO('sqlite::memory:'),
'fresh_database',
'fresh_',
['name' => 'test', 'driver' => 'sqlite', 'endpoint' => 'fresh']
);
+ $replacementPdo = WeakReference::create($fresh->getRawPdo());
$fresh->setReadPdo(static fn (): never => throw $failure);
try {
@@ -1063,6 +1146,7 @@ public function testRefreshRetainsConfiguredBaselinesWhenPreparationFails(): voi
}
$this->assertSame($oldPdo, $connection->getRawPdo());
+ $this->assertNull($replacementPdo->get());
$this->assertSame('old', $connection->getConfig('endpoint'));
$connection->resetForPool();
diff --git a/tests/Database/DatabaseReadPoolTest.php b/tests/Database/DatabaseReadPoolTest.php
new file mode 100644
index 000000000..5b9aafd3d
--- /dev/null
+++ b/tests/Database/DatabaseReadPoolTest.php
@@ -0,0 +1,132 @@
+ [
+ 'driver' => 'pgsql',
+ 'database' => 'app',
+ 'read' => [['host' => 'first-reader'], ['host' => 'second-reader']],
+ 'pool' => ['min_retained_connections' => 0, 'max_connections' => 2],
+ ]]);
+ $factory = new ReadPoolConnectionFactory($this->app);
+ $this->app->instance('db.factory', $factory);
+ $hosts = [];
+ $connector = m::mock(ConnectorInterface::class);
+ $connector->expects('connect')->times(3)->andReturnUsing(static function (array $config) use (&$hosts): PDO {
+ $hosts[] = $config['host'];
+
+ return new PDO('sqlite::memory:');
+ });
+ $this->app->instance('db.connector.pgsql', $connector);
+ $pool = new DatabasePool($this->app, 'read_pool::read');
+ $first = $second = null;
+
+ try {
+ $first = $pool->borrow();
+ $second = $pool->borrow();
+ $first->getConnection()->getPdo();
+ $connection = $second->getConnection();
+ $connection->getPdo();
+ $connection->reconnect();
+
+ $this->assertSame(['first-reader', 'second-reader', 'first-reader'], $hosts);
+ $this->assertSame($connection, $second->getConnection());
+ $this->assertSame('first-reader', $connection->getConfig('host'));
+ } finally {
+ $first?->release();
+ $second?->release();
+ $pool->close();
+ }
+ }
+
+ #[DataProvider('readPoolOptions')]
+ public function testReadRecordsRequireTheSameEffectivePoolOptions(array $secondOptions, bool $conflicting): void
+ {
+ config(['database.connections.read_pool_options' => [
+ 'driver' => 'pgsql',
+ 'database' => 'app',
+ 'pool' => ['max_connections' => 5],
+ 'read' => [
+ ['host' => 'first-reader', 'pool' => ['max_connections' => 2, 'connect_timeout' => 1]],
+ ['host' => 'second-reader', 'pool' => $secondOptions],
+ ],
+ ]]);
+
+ if ($conflicting) {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('must use the same effective pool options');
+ }
+
+ $pool = new DatabasePool($this->app, 'read_pool_options::read');
+
+ try {
+ $this->assertSame(2, $pool->getOptions()->maxConnections);
+ $this->assertSame(1.0, $pool->getOptions()->connectTimeout);
+ } finally {
+ $pool->close();
+ }
+ }
+
+ /**
+ * Provide equivalent and conflicting read pool configurations.
+ */
+ public static function readPoolOptions(): array
+ {
+ return [
+ 'matching normalized values' => [[
+ 'max_connections' => 2,
+ 'connect_timeout' => 1.0,
+ 'wait_timeout' => 3.0,
+ 'testing_enabled' => true,
+ ], false],
+ 'conflicting capacity' => [['max_connections' => 3, 'connect_timeout' => 1], true],
+ ];
+ }
+
+ public function testEveryReadRecordIsCheckedForAnInMemorySqliteDatabase(): void
+ {
+ config(['database.connections.read_pool_memory' => [
+ 'driver' => 'sqlite',
+ 'database' => '/unused/write.sqlite',
+ 'read' => [
+ ['database' => '/unused/read.sqlite'],
+ ['url' => 'sqlite:///:memory:'],
+ ],
+ ]]);
+ $this->app->instance('db.factory', new ReadPoolConnectionFactory($this->app));
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('cannot use a derived read pool for in-memory SQLite');
+
+ new DatabasePool($this->app, 'read_pool_memory::read');
+ }
+}
+
+class ReadPoolConnectionFactory extends ConnectionFactory
+{
+ protected int $readSelection = 0;
+
+ /**
+ * Select consecutive replicas whenever a new read record is needed.
+ */
+ protected function getReadWriteConfig(array $config, string $type): array
+ {
+ return $type === 'read' && isset($config[$type][0])
+ ? $config[$type][$this->readSelection++ % count($config[$type])]
+ : parent::getReadWriteConfig($config, $type);
+ }
+}
diff --git a/tests/Database/PoolManagerTest.php b/tests/Database/PoolManagerTest.php
index 22e59bc47..94b26abe9 100644
--- a/tests/Database/PoolManagerTest.php
+++ b/tests/Database/PoolManagerTest.php
@@ -12,6 +12,7 @@
use Hypervel\Contracts\Container\Container as ContainerContract;
use Hypervel\Contracts\Log\StdoutLoggerInterface;
use Hypervel\Coordinator\Timer;
+use Hypervel\Database\Connection as DatabaseConnection;
use Hypervel\Database\Connectors\ConnectionFactory;
use Hypervel\Database\Pool\DatabasePool;
use Hypervel\Database\Pool\PoolManager;
@@ -621,7 +622,8 @@ public function testReadConnectionUsesSeparatePoolWhenReadConfigComesFromUrl():
$this->assertSame('default::read', $pool->getName());
$this->assertNotSame($manager->pool('default'), $pool);
$this->assertInstanceOf(PoolManagerTestPool::class, $pool);
- $this->assertSame(['replica.test'], $pool->configForTest()['host']);
+ $this->assertSame(['replica.test'], $pool->configForTest()['read']['host']);
+ $this->assertSame('read', $pool->configForTest()[DatabaseConnection::READ_WRITE_TYPE_CONFIG_KEY]);
}
public function testReadConnectionUsesBasePoolWhenReadConfigIsMissingOrNull(): void
diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php
index 134ce913f..176bad13f 100644
--- a/tests/Integration/Database/PooledConnectionTest.php
+++ b/tests/Integration/Database/PooledConnectionTest.php
@@ -35,6 +35,7 @@
use ReflectionProperty;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
+use WeakReference;
/**
* Tests for PooledConnection — the adapter that wraps a database Connection
@@ -884,8 +885,8 @@ public function testHeartbeatDoesNotComputeOrInvalidateSessionState(): void
$configurator = new PoolSessionConfigurator;
PdoConnection::configureSessionUsing($configurator);
$pool = new DatabasePool($this->app, 'pool_test');
- $stateCallsAfterCreation = $configurator->stateCalls;
- $applyCallsAfterCreation = $configurator->applyCalls;
+ $this->assertSame(0, $configurator->stateCalls);
+ $this->assertSame(0, $configurator->applyCalls);
/** @var PooledConnection $pooledConnection */
$pooledConnection = $pool->borrow();
@@ -895,8 +896,8 @@ public function testHeartbeatDoesNotComputeOrInvalidateSessionState(): void
$stateCallsBeforePing = $configurator->stateCalls;
$applyCallsBeforePing = $configurator->applyCalls;
- $this->assertGreaterThanOrEqual($stateCallsAfterCreation, $stateCallsBeforePing);
- $this->assertSame($applyCallsAfterCreation, $applyCallsBeforePing);
+ $this->assertSame(1, $stateCallsBeforePing);
+ $this->assertSame(1, $applyCallsBeforePing);
$this->assertTrue($pooledConnection->ping(1.0));
$this->assertSame($stateCallsBeforePing, $configurator->stateCalls);
$this->assertSame($applyCallsBeforePing, $configurator->applyCalls);
@@ -1445,6 +1446,16 @@ public function testReleaseResetsErrorCountForNextBorrowWindow(): void
$nextPooledConnection->release();
}
+ public function testClosingAnUnusedSharedMemoryPoolClosesItsPhysicalSession(): void
+ {
+ $pool = new DatabasePool($this->app, 'pool_test');
+ $pdo = WeakReference::create($pool->getSharedInMemorySqlitePdo());
+
+ $pool->close();
+
+ $this->assertNull($pdo->get());
+ }
+
public function testSharedPdoPersistsAcrossInMemorySqliteBorrows(): void
{
$pool = new DatabasePool($this->app, 'pool_test');
diff --git a/tests/Integration/Database/Sqlite/ConnectionEstablishedTest.php b/tests/Integration/Database/Sqlite/ConnectionEstablishedTest.php
index 9cd70020a..83770ef83 100644
--- a/tests/Integration/Database/Sqlite/ConnectionEstablishedTest.php
+++ b/tests/Integration/Database/Sqlite/ConnectionEstablishedTest.php
@@ -128,16 +128,27 @@ public function testOnlyNewGenerationsNotifyAfterRegistration(): void
});
$first = null;
- $this->runInCoroutine(function () use (&$first): void {
+ $firstPdo = null;
+ $this->runInCoroutine(function () use (&$first, &$firstPdo): void {
$first = DB::connection('established::write');
+ $firstPdo = $first->getPdo();
});
- $this->runInCoroutine(function () use ($first): void {
- $this->assertSame($first, DB::connection('established::write'));
+ $this->runInCoroutine(function () use ($first, $firstPdo): void {
+ $connection = DB::connection('established::write');
+ $this->assertNotSame($first, $connection);
+ $this->assertSame($firstPdo, $connection->getPdo());
+ $this->assertSame($firstPdo, $connection->getRawPdo());
});
$this->assertSame([$first], $connections);
- $this->runInCoroutine(fn () => DB::connection('established::write')->reconnect());
- $this->assertSame([$first, $first], $connections);
+ $reconnected = null;
+ $this->runInCoroutine(function () use (&$reconnected, $firstPdo): void {
+ $reconnected = DB::connection('established::write');
+ $reconnected->reconnect();
+ $this->assertSame($reconnected, DB::connection('established::write'));
+ $this->assertNotSame($firstPdo, $reconnected->getRawPdo());
+ });
+ $this->assertSame([$first, $reconnected], $connections);
$pool = $this->app->make(PoolManager::class)->pool('established');
$pooled = $pool->borrow();
@@ -152,7 +163,7 @@ public function testOnlyNewGenerationsNotifyAfterRegistration(): void
$replacement = DB::connection('established::write');
});
$this->assertNotSame($first, $replacement);
- $this->assertSame([$first, $first, $replacement], $connections);
+ $this->assertSame([$first, $reconnected, $replacement], $connections);
}
#[TestWith([true, false])]
diff --git a/types/Database/Connection.php b/types/Database/Connection.php
index c80690bba..073f5e240 100644
--- a/types/Database/Connection.php
+++ b/types/Database/Connection.php
@@ -30,6 +30,7 @@ function testCallbackWrappersPreserveCallbackReturns(Connection $connection): vo
{
assertType("'foo'", $connection->withoutPretending(fn () => 'foo'));
assertType("'foo'", $connection->withoutTablePrefix(fn () => 'foo'));
+ assertType("'foo'", $connection->withPinnedSession(fn () => 'foo'));
}
function testTransactionPreservesCallbackReturn(ConnectionInterface $connection): void
From f72287ca41073932804effc5077f3098ffa4ad90 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 09:35:32 +0000
Subject: [PATCH 05/22] Isolate transaction records from copied coroutine
context
Group pending, committed and current transaction records in one non-copyable DatabaseTransactionState. Child coroutines acquire independent transaction ownership instead of sharing parent records and running parent callbacks prematurely or twice.
Preserve transaction callback extension points while retrieving related state together. Keep the intentional Testbench setup-to-test transfer explicit through manager copy methods and a tests-only non-coroutine context setter, so normal child context propagation cannot inherit transactions.
Add regressions for interleaved parent and child callbacks and explicit lifecycle transfer with nested transactions. Validation of the complete checkpoint passed formatting, source/type PHPStan, 40,181 framework tests, 632 Testbench tests and 6 package-consumer tests with expected service skips.
---
src/context/src/CoroutineContext.php | 15 ++
src/database/src/DatabaseTransactionState.php | 41 +++++
.../src/DatabaseTransactionsManager.php | 173 +++++++-----------
.../InteractsWithTestCaseLifecycle.php | 3 +-
.../Testing/Concerns/RunTestsInCoroutine.php | 1 +
.../DatabaseTransactionsManagerTest.php | 93 ++++++++++
6 files changed, 216 insertions(+), 110 deletions(-)
create mode 100644 src/database/src/DatabaseTransactionState.php
diff --git a/src/context/src/CoroutineContext.php b/src/context/src/CoroutineContext.php
index dc7199a15..6fcdf339b 100644
--- a/src/context/src/CoroutineContext.php
+++ b/src/context/src/CoroutineContext.php
@@ -270,6 +270,21 @@ private static function prepareForCopy(array $values): array
return $values;
}
+ /**
+ * Store a value in non-coroutine context only.
+ *
+ * Tests only. This writes worker-global storage even from a coroutine,
+ * so request-time use can leak state between concurrent requests.
+ *
+ * @param TKey $id
+ * @param TValue $value
+ * @return TValue
+ */
+ public static function setNonCoroutine(UnitEnum|string $id, mixed $value): mixed
+ {
+ return static::$nonCoroutineContext[is_string($id) ? $id : enum_value($id)] = $value;
+ }
+
/**
* Get a value from non-coroutine context only.
*
diff --git a/src/database/src/DatabaseTransactionState.php b/src/database/src/DatabaseTransactionState.php
new file mode 100644
index 000000000..8b5f5e24a
--- /dev/null
+++ b/src/database/src/DatabaseTransactionState.php
@@ -0,0 +1,41 @@
+ */
+ public Collection $committed;
+
+ /** @var Collection */
+ public Collection $pending;
+
+ /** @var array */
+ public array $current = [];
+
+ /**
+ * Create transaction state owned by one execution.
+ */
+ public function __construct()
+ {
+ $this->committed = new Collection;
+ $this->pending = new Collection;
+ }
+
+ /**
+ * Get the current execution's transaction state.
+ */
+ public static function current(): self
+ {
+ return CoroutineContext::get(self::CONTEXT_KEY)
+ ?? CoroutineContext::set(self::CONTEXT_KEY, new self);
+ }
+}
diff --git a/src/database/src/DatabaseTransactionsManager.php b/src/database/src/DatabaseTransactionsManager.php
index f496ed14b..739fdb351 100755
--- a/src/database/src/DatabaseTransactionsManager.php
+++ b/src/database/src/DatabaseTransactionsManager.php
@@ -17,12 +17,6 @@
*/
class DatabaseTransactionsManager
{
- protected const string COMMITTED_CONTEXT_KEY = '__database.transactions.committed';
-
- protected const string PENDING_CONTEXT_KEY = '__database.transactions.pending';
-
- protected const string CURRENT_CONTEXT_KEY = '__database.transactions.current';
-
/**
* Get all committed transactions for the current coroutine.
*
@@ -30,17 +24,7 @@ class DatabaseTransactionsManager
*/
protected function getCommittedTransactionsInternal(): Collection
{
- return CoroutineContext::get(self::COMMITTED_CONTEXT_KEY, new Collection);
- }
-
- /**
- * Set committed transactions for the current coroutine.
- *
- * @param Collection $transactions
- */
- protected function setCommittedTransactions(Collection $transactions): void
- {
- CoroutineContext::set(self::COMMITTED_CONTEXT_KEY, $transactions);
+ return DatabaseTransactionState::current()->committed;
}
/**
@@ -50,37 +34,7 @@ protected function setCommittedTransactions(Collection $transactions): void
*/
protected function getPendingTransactionsInternal(): Collection
{
- return CoroutineContext::get(self::PENDING_CONTEXT_KEY, new Collection);
- }
-
- /**
- * Set pending transactions for the current coroutine.
- *
- * @param Collection $transactions
- */
- protected function setPendingTransactions(Collection $transactions): void
- {
- CoroutineContext::set(self::PENDING_CONTEXT_KEY, $transactions);
- }
-
- /**
- * Get current transaction map for the current coroutine.
- *
- * @return array
- */
- protected function getCurrentTransaction(): array
- {
- return CoroutineContext::get(self::CURRENT_CONTEXT_KEY, []);
- }
-
- /**
- * Set current transaction for a connection.
- */
- protected function setCurrentTransactionForConnection(string $connection, ?DatabaseTransactionRecord $transaction): void
- {
- $current = $this->getCurrentTransaction();
- $current[$connection] = $transaction;
- CoroutineContext::set(self::CURRENT_CONTEXT_KEY, $current);
+ return DatabaseTransactionState::current()->pending;
}
/**
@@ -88,7 +42,7 @@ protected function setCurrentTransactionForConnection(string $connection, ?Datab
*/
protected function getCurrentTransactionForConnection(string $connection): ?DatabaseTransactionRecord
{
- return $this->getCurrentTransaction()[$connection] ?? null;
+ return DatabaseTransactionState::current()->current[$connection] ?? null;
}
/**
@@ -96,17 +50,16 @@ protected function getCurrentTransactionForConnection(string $connection): ?Data
*/
public function begin(string $connection, int $level): void
{
- $pending = $this->getPendingTransactionsInternal();
+ $state = DatabaseTransactionState::current();
$newTransaction = new DatabaseTransactionRecord(
$connection,
$level,
- $this->getCurrentTransactionForConnection($connection)
+ $state->current[$connection] ?? null
);
- $pending->push($newTransaction);
- $this->setPendingTransactions($pending);
- $this->setCurrentTransactionForConnection($connection, $newTransaction);
+ $state->pending->push($newTransaction);
+ $state->current[$connection] = $newTransaction;
}
/**
@@ -118,9 +71,10 @@ public function commit(string $connection, int $levelBeingCommitted, int $newTra
{
$this->stageTransactions($connection, $levelBeingCommitted);
- $currentForConnection = $this->getCurrentTransactionForConnection($connection);
+ $state = DatabaseTransactionState::current();
+ $currentForConnection = $state->current[$connection] ?? null;
if ($currentForConnection !== null) {
- $this->setCurrentTransactionForConnection($connection, $currentForConnection->parent);
+ $state->current[$connection] = $currentForConnection->parent;
}
if (! $this->afterCommitCallbacksShouldBeExecuted($newTransactionLevel)
@@ -129,18 +83,15 @@ public function commit(string $connection, int $levelBeingCommitted, int $newTra
}
// Clear pending transactions for this connection at or above the committed level
- $pending = $this->getPendingTransactionsInternal()->reject(
+ $state->pending = $state->pending->reject(
fn ($transaction) => $transaction->connection === $connection
&& $transaction->level >= $levelBeingCommitted
)->values();
- $this->setPendingTransactions($pending);
-
- $committed = $this->getCommittedTransactionsInternal();
- [$forThisConnection, $forOtherConnections] = $committed->partition(
+ [$forThisConnection, $forOtherConnections] = $state->committed->partition(
fn ($transaction) => $transaction->connection === $connection
);
- $this->setCommittedTransactions($forOtherConnections->values());
+ $state->committed = $forOtherConnections->values();
$this->executeCommitCallbacks($forThisConnection);
@@ -152,21 +103,18 @@ public function commit(string $connection, int $levelBeingCommitted, int $newTra
*/
public function stageTransactions(string $connection, int $levelBeingCommitted): void
{
- $pending = $this->getPendingTransactionsInternal();
- $committed = $this->getCommittedTransactionsInternal();
+ $state = DatabaseTransactionState::current();
- $toStage = $pending->filter(
+ $toStage = $state->pending->filter(
fn ($transaction) => $transaction->connection === $connection
&& $transaction->level >= $levelBeingCommitted
);
- $this->setCommittedTransactions($committed->merge($toStage));
+ $state->committed = $state->committed->merge($toStage);
- $this->setPendingTransactions(
- $pending->reject(
- fn ($transaction) => $transaction->connection === $connection
- && $transaction->level >= $levelBeingCommitted
- )
+ $state->pending = $state->pending->reject(
+ fn ($transaction) => $transaction->connection === $connection
+ && $transaction->level >= $levelBeingCommitted
);
}
@@ -181,15 +129,14 @@ public function rollback(string $connection, int $newTransactionLevel): void
return;
}
- $this->setPendingTransactions(
- $this->getPendingTransactionsInternal()->reject(
- fn ($transaction) => $transaction->connection === $connection
- && $transaction->level > $newTransactionLevel
- )->values()
- );
+ $state = DatabaseTransactionState::current();
+ $state->pending = $state->pending->reject(
+ fn ($transaction) => $transaction->connection === $connection
+ && $transaction->level > $newTransactionLevel
+ )->values();
$transactions = new Collection;
- $currentForConnection = $this->getCurrentTransactionForConnection($connection);
+ $currentForConnection = $state->current[$connection] ?? null;
while ($currentForConnection !== null
&& $currentForConnection->level > $newTransactionLevel) {
@@ -200,14 +147,14 @@ public function rollback(string $connection, int $newTransactionLevel): void
$currentForConnection = $currentForConnection->parent;
}
- $this->setCurrentTransactionForConnection($connection, $currentForConnection);
+ $state->current[$connection] = $currentForConnection;
- [$stagedTransactions, $remainingCommitted] = $this->getCommittedTransactionsInternal()->partition(
+ [$stagedTransactions, $remainingCommitted] = $state->committed->partition(
fn (DatabaseTransactionRecord $committed): bool => $transactions->contains(
fn (DatabaseTransactionRecord $transaction): bool => $transaction === $committed
)
);
- $this->setCommittedTransactions($remainingCommitted->values());
+ $state->committed = $remainingCommitted->values();
$this->executeRollbackCallbacks(
$transactions
@@ -223,14 +170,13 @@ public function rollback(string $connection, int $newTransactionLevel): void
*/
protected function removeAllTransactionsForConnection(string $connection): void
{
- [$committedForConnection, $committedForOtherConnections] = $this
- ->getCommittedTransactionsInternal()
- ->partition(
- fn (DatabaseTransactionRecord $transaction): bool => $transaction->connection === $connection
- );
+ $state = DatabaseTransactionState::current();
+ [$committedForConnection, $committedForOtherConnections] = $state->committed->partition(
+ fn (DatabaseTransactionRecord $transaction): bool => $transaction->connection === $connection
+ );
$currentTransactions = new Collection;
- $currentForConnection = $this->getCurrentTransactionForConnection($connection);
+ $currentForConnection = $state->current[$connection] ?? null;
for ($current = $currentForConnection; $current !== null; $current = $current->parent) {
$currentTransactions->push($current);
@@ -243,15 +189,13 @@ protected function removeAllTransactionsForConnection(string $connection): void
->values();
// User callbacks must observe the transaction records as already detached.
- $this->setCurrentTransactionForConnection($connection, null);
+ $state->current[$connection] = null;
- $this->setPendingTransactions(
- $this->getPendingTransactionsInternal()->reject(
- fn ($transaction) => $transaction->connection === $connection
- )->values()
- );
+ $state->pending = $state->pending->reject(
+ fn ($transaction) => $transaction->connection === $connection
+ )->values();
- $this->setCommittedTransactions($committedForOtherConnections->values());
+ $state->committed = $committedForOtherConnections->values();
$this->executeRollbackCallbacks($transactions);
}
@@ -264,14 +208,14 @@ protected function removeAllTransactionsForConnection(string $connection): void
protected function removeCommittedTransactionsThatAreChildrenOf(
DatabaseTransactionRecord $transaction
): Collection {
- $committed = $this->getCommittedTransactionsInternal();
+ $state = DatabaseTransactionState::current();
- [$removedTransactions, $remaining] = $committed->partition(
+ [$removedTransactions, $remaining] = $state->committed->partition(
fn ($committed) => $committed->connection === $transaction->connection
&& $committed->parent === $transaction
);
- $this->setCommittedTransactions($remaining);
+ $state->committed = $remaining;
foreach ($removedTransactions as $removedTransaction) {
$removedTransactions = $removedTransactions->concat(
@@ -435,9 +379,10 @@ public function getCommittedTransactions(): Collection
*/
public static function hasNonCoroutinePendingTransactions(): bool
{
- $pending = CoroutineContext::getFromNonCoroutine(self::PENDING_CONTEXT_KEY);
+ /** @var null|DatabaseTransactionState $state */
+ $state = CoroutineContext::getFromNonCoroutine(DatabaseTransactionState::CONTEXT_KEY);
- return $pending instanceof Collection && $pending->isNotEmpty();
+ return $state !== null && $state->pending->isNotEmpty();
}
/**
@@ -448,11 +393,25 @@ public static function hasNonCoroutinePendingTransactions(): bool
*/
public static function copyToNonCoroutineState(): void
{
- CoroutineContext::copyToNonCoroutine([
- self::COMMITTED_CONTEXT_KEY,
- self::PENDING_CONTEXT_KEY,
- self::CURRENT_CONTEXT_KEY,
- ]);
+ CoroutineContext::setNonCoroutine(
+ DatabaseTransactionState::CONTEXT_KEY,
+ DatabaseTransactionState::current(),
+ );
+ }
+
+ /**
+ * Restore the test lifecycle's transaction state in the current coroutine.
+ *
+ * Tests only. This deliberately shares transaction ownership across setup,
+ * test and teardown; normal child coroutines must own separate transactions.
+ */
+ public static function copyFromNonCoroutineState(): void
+ {
+ $state = CoroutineContext::getFromNonCoroutine(DatabaseTransactionState::CONTEXT_KEY);
+
+ if ($state !== null) {
+ CoroutineContext::set(DatabaseTransactionState::CONTEXT_KEY, $state);
+ }
}
/**
@@ -463,10 +422,6 @@ public static function copyToNonCoroutineState(): void
*/
public static function clearNonCoroutineState(): void
{
- CoroutineContext::clearFromNonCoroutine([
- self::COMMITTED_CONTEXT_KEY,
- self::PENDING_CONTEXT_KEY,
- self::CURRENT_CONTEXT_KEY,
- ]);
+ CoroutineContext::clearFromNonCoroutine([DatabaseTransactionState::CONTEXT_KEY]);
}
}
diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php
index de62085eb..e5b5a6ef6 100644
--- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php
+++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php
@@ -293,7 +293,8 @@ protected function callBeforeApplicationDestroyedCallbacks()
* RefreshDatabase and DatabaseTransactions store transaction state in Context.
* Since setUpTraits runs in a temporary coroutine (separate from the test method's
* coroutine), we must copy this state to non-coroutine context. The test coroutine
- * will then copy from non-coroutine context via copyFromNonCoroutine().
+ * restores the same transaction state explicitly; normal context copying
+ * omits it so child coroutines cannot inherit their parent's transactions.
*/
protected function preserveTransactionContext(): void
{
diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
index 1b882d2c4..bb9ea30e4 100644
--- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
+++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
@@ -53,6 +53,7 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m
if ($this->copyNonCoroutineContext) {
CoroutineContext::copyFromNonCoroutine();
+ DatabaseTransactionsManager::copyFromNonCoroutineState();
}
$shouldBootFramework = $this->shouldBootFrameworkForTest();
diff --git a/tests/Database/DatabaseTransactionsManagerTest.php b/tests/Database/DatabaseTransactionsManagerTest.php
index 517ee75ff..bd6455532 100755
--- a/tests/Database/DatabaseTransactionsManagerTest.php
+++ b/tests/Database/DatabaseTransactionsManagerTest.php
@@ -4,14 +4,107 @@
namespace Hypervel\Tests\Database;
+use Hypervel\Context\CoroutineContext;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Database\DatabaseTransactionRecord;
use Hypervel\Database\DatabaseTransactionsManager;
+use Hypervel\Engine\Channel;
use Hypervel\Tests\TestCase;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
+use Throwable;
+
+use function Hypervel\Coroutine\parallel;
class DatabaseTransactionsManagerTest extends TestCase
{
+ public function testForkedTransactionsCannotRunOrCaptureParentCallbacks(): void
+ {
+ $manager = new DatabaseTransactionsManager;
+ $events = [];
+ $manager->begin('default', 1);
+ $manager->addCallback(static function () use (&$events): void {
+ $events[] = 'parent-before';
+ });
+ $ready = new Channel(1);
+ $resume = new Channel(1);
+ $finished = new Channel(1);
+ $failure = null;
+
+ $child = Coroutine::fork(function () use ($manager, &$events, $ready, $resume, $finished, &$failure): void {
+ try {
+ $manager->begin('default', 1);
+ $manager->addCallback(static function () use (&$events): void {
+ $events[] = 'child';
+ });
+ $ready->push(true);
+ $this->assertTrue($resume->pop(1));
+ $manager->commit('default', 1, 0);
+ } catch (Throwable $exception) {
+ $failure = $exception;
+ } finally {
+ $finished->push(true);
+ }
+ });
+
+ try {
+ $this->assertTrue($ready->pop(1));
+ $manager->addCallback(static function () use (&$events): void {
+ $events[] = 'parent-after';
+ });
+ } finally {
+ $resume->push(true, 0);
+ $completed = $finished->pop(1);
+
+ if ($completed !== true && Coroutine::exists($child)) {
+ Coroutine::cancelById($child, throwException: true);
+ $finished->pop(1);
+ }
+ }
+
+ $this->assertTrue($completed);
+
+ if ($failure !== null) {
+ throw $failure;
+ }
+
+ $this->assertSame(['child'], $events);
+ $manager->commit('default', 1, 0);
+ $this->assertSame(['child', 'parent-before', 'parent-after'], $events);
+ }
+
+ public function testExplicitTestLifecycleTransferPreservesCurrentTransactionOwnership(): void
+ {
+ $manager = new DatabaseTransactionsManager;
+ $events = [];
+ $manager->begin('default', 1);
+ DatabaseTransactionsManager::copyToNonCoroutineState();
+
+ try {
+ parallel([function () use ($manager, &$events): void {
+ CoroutineContext::copyFromNonCoroutine();
+ $this->assertCount(0, $manager->getPendingTransactions());
+
+ DatabaseTransactionsManager::copyFromNonCoroutineState();
+ $manager->begin('default', 2);
+ $manager->addCallback(static function () use (&$events): void {
+ $events[] = 'nested';
+ });
+ $manager->commit('default', 2, 1);
+ }]);
+
+ $this->assertSame([], $events);
+ $manager->addCallback(static function () use (&$events): void {
+ $events[] = 'outer';
+ });
+ $manager->commit('default', 1, 0);
+ $this->assertSame(['nested', 'outer'], $events);
+ $this->assertFalse(DatabaseTransactionsManager::hasNonCoroutinePendingTransactions());
+ } finally {
+ DatabaseTransactionsManager::clearNonCoroutineState();
+ }
+ }
+
public function testBeginningTransactions(): void
{
$manager = new DatabaseTransactionsManager;
From cbf177cf820242d6cb55280df50ca5142aef39c8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 09:35:44 +0000
Subject: [PATCH 06/22] Avoid duplicate coroutine context lookups in framework
services
Retrieve scoped container instances, auth defaults, Inertia state, static instances and Blade context values once instead of repeating has/get lookups on request paths.
Preserve null-as-absent behavior, false and zero scoped values, identity-based scoped removal and lazy fallback evaluation. In particular, Blade must not read an uninitialized fallback property when coroutine context already supplies the path.
Existing behavioral coverage verifies the supported values and scoped resolution semantics. The complete checkpoint passed formatting, source/type PHPStan, 40,181 framework tests, 632 Testbench tests and 6 package-consumer tests with expected service skips.
---
src/auth/src/AuthManager.php | 7 ++-----
src/auth/src/Passwords/PasswordBrokerManager.php | 6 ++++--
src/container/src/Container.php | 14 +++++++++-----
src/inertia/src/InertiaState.php | 6 +++---
src/support/src/Traits/StaticInstance.php | 6 +-----
src/view/src/Compilers/BladeCompiler.php | 9 ++-------
6 files changed, 21 insertions(+), 27 deletions(-)
diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php
index 7c8714c76..fd4f6dcd6 100755
--- a/src/auth/src/AuthManager.php
+++ b/src/auth/src/AuthManager.php
@@ -181,11 +181,8 @@ protected function getConfig(string $name): ?array
*/
public function getDefaultDriver(): string
{
- if (CoroutineContext::has(self::DEFAULT_GUARD_CONTEXT_KEY)) {
- return CoroutineContext::get(self::DEFAULT_GUARD_CONTEXT_KEY);
- }
-
- return $this->app->make('config')->string('auth.defaults.guard');
+ return CoroutineContext::get(self::DEFAULT_GUARD_CONTEXT_KEY)
+ ?? $this->app->make('config')->string('auth.defaults.guard');
}
/**
diff --git a/src/auth/src/Passwords/PasswordBrokerManager.php b/src/auth/src/Passwords/PasswordBrokerManager.php
index 2d23d304b..1e34ab544 100644
--- a/src/auth/src/Passwords/PasswordBrokerManager.php
+++ b/src/auth/src/Passwords/PasswordBrokerManager.php
@@ -162,8 +162,10 @@ public function resolveBrokerNameForGuard(UnitEnum|string $guard): ?string
*/
public function getDefaultDriver(): string
{
- if (CoroutineContext::has(self::DEFAULT_BROKER_CONTEXT_KEY)) {
- return CoroutineContext::get(self::DEFAULT_BROKER_CONTEXT_KEY);
+ $name = CoroutineContext::get(self::DEFAULT_BROKER_CONTEXT_KEY);
+
+ if ($name !== null) {
+ return $name;
}
$guard = $this->app->make(AuthFactoryContract::class)->getDefaultDriver();
diff --git a/src/container/src/Container.php b/src/container/src/Container.php
index 332240ddf..d59d95873 100755
--- a/src/container/src/Container.php
+++ b/src/container/src/Container.php
@@ -732,13 +732,15 @@ public function extend(string $abstract, Closure $closure): void
$this->autoSingletons[$abstract] = $closure($this->autoSingletons[$abstract], $this);
$this->rebound($abstract);
- } elseif ($this->isScoped($abstract) && CoroutineContext::has(self::SCOPED_CONTEXT_PREFIX . $abstract)) {
+ } elseif ($this->isScoped($abstract)
+ && ($instance = CoroutineContext::get(self::SCOPED_CONTEXT_PREFIX . $abstract)) !== null
+ ) {
// Apply the extender to the current cached scoped instance immediately.
// Also queue it in $this->extenders for future resolutions — unlike singletons
// (which persist for the worker lifetime), scoped instances are destroyed each
// request by forgetScopedInstances() and must re-apply extenders on rebuild.
$contextKey = self::SCOPED_CONTEXT_PREFIX . $abstract;
- CoroutineContext::set($contextKey, $closure(CoroutineContext::get($contextKey), $this));
+ CoroutineContext::set($contextKey, $closure($instance, $this));
$this->extenders[$abstract][] = $closure;
@@ -1191,8 +1193,10 @@ protected function resolve(
&& ! $needsContextualBuild
) {
$contextKey = self::SCOPED_CONTEXT_PREFIX . $abstract;
- if (CoroutineContext::has($contextKey)) {
- return CoroutineContext::get($contextKey);
+ $instance = CoroutineContext::get($contextKey);
+
+ if ($instance !== null) {
+ return $instance;
}
}
@@ -1338,7 +1342,7 @@ protected function resolve(
if ($publishedCache === 'scoped') {
$contextKey = self::SCOPED_CONTEXT_PREFIX . $abstract;
- if (CoroutineContext::has($contextKey)
+ if ($publishedValue !== null
&& CoroutineContext::get($contextKey) === $publishedValue
) {
CoroutineContext::forget($contextKey);
diff --git a/src/inertia/src/InertiaState.php b/src/inertia/src/InertiaState.php
index c526c438e..bdfbe7d80 100644
--- a/src/inertia/src/InertiaState.php
+++ b/src/inertia/src/InertiaState.php
@@ -107,10 +107,10 @@ class InertiaState implements ReplicableContext
*/
public static function current(): self
{
- if (CoroutineContext::has(self::CONTEXT_KEY)) {
- /** @var self $state */
- $state = CoroutineContext::get(self::CONTEXT_KEY);
+ /** @var null|self $state */
+ $state = CoroutineContext::get(self::CONTEXT_KEY);
+ if ($state !== null) {
return $state;
}
diff --git a/src/support/src/Traits/StaticInstance.php b/src/support/src/Traits/StaticInstance.php
index 7223dfbdd..b4d954c62 100644
--- a/src/support/src/Traits/StaticInstance.php
+++ b/src/support/src/Traits/StaticInstance.php
@@ -14,11 +14,7 @@ trait StaticInstance
public static function instance(array $params = [], bool $refresh = false, string $suffix = ''): static
{
$key = static::class . $suffix;
- $instance = null;
-
- if (CoroutineContext::has($key)) {
- $instance = CoroutineContext::get($key);
- }
+ $instance = CoroutineContext::get($key);
if ($refresh || ! $instance instanceof static) {
$instance = new static(...$params);
diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php
index 93db3f772..9f8282fc7 100644
--- a/src/view/src/Compilers/BladeCompiler.php
+++ b/src/view/src/Compilers/BladeCompiler.php
@@ -236,11 +236,7 @@ protected function getOpenAndClosingPhpTokens(string $contents): Collection
*/
public function getPath(): string
{
- if (CoroutineContext::has(static::PATH_CONTEXT_KEY)) {
- return CoroutineContext::get(static::PATH_CONTEXT_KEY, '');
- }
-
- return $this->path;
+ return CoroutineContext::get(static::PATH_CONTEXT_KEY) ?? $this->path;
}
/**
@@ -1053,7 +1049,6 @@ public function precompiler(callable $precompiler): void
*/
public function usingEchoFormat(string $format, callable $callback): string
{
- $hadOverride = CoroutineContext::has(static::ECHO_FORMAT_CONTEXT_KEY);
$previous = CoroutineContext::get(static::ECHO_FORMAT_CONTEXT_KEY);
CoroutineContext::set(static::ECHO_FORMAT_CONTEXT_KEY, $format);
@@ -1061,7 +1056,7 @@ public function usingEchoFormat(string $format, callable $callback): string
try {
return call_user_func($callback);
} finally {
- if ($hadOverride) {
+ if ($previous !== null) {
CoroutineContext::set(static::ECHO_FORMAT_CONTEXT_KEY, $previous);
} else {
CoroutineContext::forget(static::ECHO_FORMAT_CONTEXT_KEY);
From 38c1054c1cba6b035e73c232654a38c22d190e9e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:42:03 +0000
Subject: [PATCH 07/22] Stream HTTP responses incrementally and cancel
disconnected producers
Use a pull-driven cURL transport for streamed requests inside hooked
coroutines. Each active response owns its transfer and applies backpressure
while the caller consumes buffered bytes. Named connections retain at most
three idle transports, matched by proxy ownership and replaced in least
recently used order. Keep custom clients and handlers, PHP-stream fallback,
destination policies, middleware and supported Guzzle 7/8 behavior intact.
Separate the response-header deadline from idle read timeouts, preserve
received responses on timeout and callback failure, and retain native
transfer statistics. Settle transfers on completion, close, cancellation
and abandonment without keeping request callbacks in worker-owned caches.
Add opt-in cancellation for iterable response production when a client
connection closes. Track every active producer, including pipelined HTTP/1
responses, and compose cancellation with application close callbacks only
in supported server modes. Preserve cancellation through Guzzle and routing
exception conversion. Document the existing quiet HTTP/2 stream-reset limit
and expose fluent event-stream typing without narrowing public signatures.
Validation: full composer fix passed, including 40,237 framework tests,
632 Testbench tests, six dogfood tests, PHPStan and formatting. Focused
minimum-version Guzzle 7 coverage passed 75 cases; the original Guzzle 8
dependencies were restored. Controlled performance benchmarks remain a
separate prerequisite before opening the framework PR.
---
src/contracts/src/Routing/ResponseFactory.php | 3 +
src/docs/http-client.md | 10 +-
src/docs/responses.md | 18 +
src/grpc/src/Server/Server.php | 4 +-
src/http-server/composer.json | 1 +
src/http-server/src/ResponseBridge.php | 20 +-
src/http-server/src/Server.php | 1 +
src/http/composer.json | 2 +
src/http/src/Client/CurlStreamingBody.php | 531 ++++++++++++++++++
.../src/Client/CurlStreamingConnection.php | 30 +
src/http/src/Client/CurlStreamingHandler.php | 210 +++++++
src/http/src/Client/Factory.php | 6 +-
src/http/src/Client/PendingRequest.php | 16 +-
src/http/src/IterableStreamedResponse.php | 20 +
src/routing/src/ResponseFactory.php | 12 +-
src/server/src/ResponseCancellation.php | 79 +++
src/server/src/Server.php | 26 +-
src/support/src/Facades/Response.php | 2 +-
.../src/PHPUnit/AfterEachTestSubscriber.php | 1 +
src/websocket-server/src/Server.php | 2 +-
.../Http/Client/CurlStreamingHandlerTest.php | 383 +++++++++++++
tests/Http/Fixtures/streaming-server.php | 83 ++-
.../Http/HttpClientDestinationPolicyTest.php | 42 +-
tests/Http/HttpClientStreamingTest.php | 367 +++++++++++-
tests/Http/PackageMetadataTest.php | 4 +
.../HttpServer/Fixtures/disconnect-server.php | 102 ++++
tests/HttpServer/PackageMetadataTest.php | 2 +-
tests/HttpServer/ResponseBridgeTest.php | 130 +++++
tests/HttpServer/ResponseCancellationTest.php | 225 ++++++++
tests/Routing/ResponseFactoryTest.php | 38 ++
tests/Server/ServerTest.php | 73 ++-
types/Routing/ResponseFactory.php | 22 +
32 files changed, 2421 insertions(+), 44 deletions(-)
create mode 100644 src/http/src/Client/CurlStreamingBody.php
create mode 100644 src/http/src/Client/CurlStreamingConnection.php
create mode 100644 src/http/src/Client/CurlStreamingHandler.php
create mode 100644 src/server/src/ResponseCancellation.php
create mode 100644 tests/Http/Client/CurlStreamingHandlerTest.php
create mode 100644 tests/HttpServer/Fixtures/disconnect-server.php
create mode 100644 tests/HttpServer/ResponseCancellationTest.php
create mode 100644 types/Routing/ResponseFactory.php
diff --git a/src/contracts/src/Routing/ResponseFactory.php b/src/contracts/src/Routing/ResponseFactory.php
index d86f87a9d..78253f4f7 100644
--- a/src/contracts/src/Routing/ResponseFactory.php
+++ b/src/contracts/src/Routing/ResponseFactory.php
@@ -6,6 +6,7 @@
use BackedEnum;
use Closure;
+use Hypervel\Http\IterableStreamedResponse;
use Hypervel\Http\JsonResponse;
use Hypervel\Http\RedirectResponse;
use Hypervel\Http\Response;
@@ -44,6 +45,8 @@ public function jsonp(string $callback, mixed $data = [], int $status = 200, arr
/**
* Create a new event stream response.
+ *
+ * @return IterableStreamedResponse
*/
public function eventStream(Closure $callback, array $headers = [], StreamedEvent|string|null $endStreamWith = ''): StreamedResponse;
diff --git a/src/docs/http-client.md b/src/docs/http-client.md
index fc5df5446..906a407e8 100644
--- a/src/docs/http-client.md
+++ b/src/docs/http-client.md
@@ -177,11 +177,13 @@ The `jsonLines` method skips blank lines and throws a `JsonException` if a recor
For plain text or a custom JSON decoder, you may use the `lines` method instead. It removes LF and CRLF line endings, preserves empty lines, and includes the final line even when it has no newline. For binary data and other formats, you may read the underlying PSR-7 response body directly.
-Both methods continue reading from the body's current position. They do not rewind it. If you call `body` or `json` first, you must rewind the stream before reading its lines. Memory usage grows with the longest line, not the total response size.
+Both methods continue reading from the body's current position. They do not rewind it. Streaming bodies cannot be rewound, so choose between processing their lines and reading the entire body with `body` or `json`. Memory usage grows with the longest line, not the total response size.
-The default streaming handler requires PHP's `allow_url_fopen` setting. If this setting is disabled, real streaming requests throw a `RuntimeException`; faked requests are unaffected. Custom handlers and clients are responsible for providing their own streaming support.
+Inside a Swoole coroutine with native cURL hooks enabled, the HTTP client receives chunks as they arrive and pauses network reads while your application processes them. [Named connections](#connections) reuse idle connections between requests while keeping request headers, credentials, and cookies separate. Each active stream owns its transport; concurrent streams do not multiplex onto the same connection.
-Unlike buffered requests, the default streaming handler does not use shared cURL connections or multiplexing. Streaming requests fail if their connection options require either feature.
+For streaming requests, `timeout` bounds the wait for response headers. After headers arrive, a long-running stream may continue beyond that timeout. The `read_timeout` option limits idle gaps during headers and body reads, defaults to 60 seconds, and accepts `0` to wait without an idle limit. `connect_timeout` continues to limit connection establishment. Closing a response or canceling its consuming coroutine releases its active transfer.
+
+Outside a hooked coroutine, or when you provide `stream_context` or a custom `stream_factory`, the client uses Guzzle's PHP-stream handler. This fallback requires `allow_url_fopen`, cannot use cURL destination pins or required transport sharing, and retains Guzzle's timeout behavior. In Guzzle 7, an omitted `read_timeout` uses the stream context or PHP socket timeout. Custom handlers and clients remain responsible for their own streaming support; faked responses do not need a transport.
### Request Data
@@ -1063,7 +1065,7 @@ If the proxy cannot be resolved or refuses the connection, a `Hypervel\Http\Clie
### Limitations
-Pinning relies on Guzzle's cURL handler and libcurl 7.75 or newer. For this reason, restricted requests may not use the `stream` option, set their own `proxy` or raw `curl` options, or use a custom handler from the `setHandler` method; such requests throw a `DisallowedDestinationException`. To write a large response to a file, use the `sink` method instead of streaming it.
+Pinning relies on Guzzle's cURL transport and libcurl 7.75 or newer. Streaming is supported inside a Swoole coroutine with native cURL hooks enabled. Restricted requests may not use the PHP-stream fallback, set their own `proxy` or raw `curl` options, or use a custom handler from the `setHandler` method; such requests throw a `DisallowedDestinationException`. To write a large response directly to a file, use the `sink` method.
If the installed cURL transport lacks a required capability, a `Hypervel\Http\Client\Destinations\DestinationPolicyException` is thrown. These failures are never retried, and your retry callback is not called, because another attempt cannot change the installed transport.
diff --git a/src/docs/responses.md b/src/docs/responses.md
index f526a346a..15f3356b2 100644
--- a/src/docs/responses.md
+++ b/src/docs/responses.md
@@ -16,6 +16,7 @@
- [File Downloads](#file-downloads)
- [File Responses](#file-responses)
- [Streamed Responses](#streamed-responses)
+ - [Canceling Disconnected Streams](#canceling-disconnected-streams)
- [Consuming Streamed Responses](#consuming-streamed-responses)
- [Streamed JSON Responses](#streamed-json-responses)
- [Event Streams (SSE)](#event-streams)
@@ -463,6 +464,23 @@ Route::post('/chat', function () {
});
```
+
+### Canceling Disconnected Streams
+
+An iterable response may stop producing data when its client connection closes. This is useful when producing the next chunk waits on an external service:
+
+```php
+use Hypervel\Http\IterableStreamedResponse;
+
+return (new IterableStreamedResponse($chunks))->cancelOnDisconnect();
+```
+
+The `stream` method returns an `IterableStreamedResponse` for generator callbacks, and `eventStream` uses this response type as well. Cancellation is opt-in and applies while the response is being produced. Other requests and work after response production are unaffected. Use `finally` to release resources owned by your generator, and allow `Swoole\Coroutine\CanceledException` to propagate.
+
+Connection-close cancellation works with coroutines enabled in Swoole base mode and in process mode with connection-stable dispatch modes (`2`, `4`, or `8`). The default process-mode settings support it. Your application's close callback continues to run.
+
+Current Swoole releases do not notify PHP when a client cancels one HTTP/2 stream while keeping its connection open. During a quiet upstream wait, that cancellation is detected only when a write fails or the operation finishes or times out. Closing the entire HTTP/2 connection cancels its opted-in producers. Configure appropriate upstream timeouts for quiet streams.
+
### Consuming Streamed Responses
diff --git a/src/grpc/src/Server/Server.php b/src/grpc/src/Server/Server.php
index 2eea497b2..1f2385867 100644
--- a/src/grpc/src/Server/Server.php
+++ b/src/grpc/src/Server/Server.php
@@ -135,7 +135,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
}
$emissionStarted = true;
- ResponseBridge::send($response, $swooleResponse, protocol: 'HTTP/2', request: $request ?? null);
+ ResponseBridge::send($response, $swooleResponse, protocol: 'HTTP/2', request: $request ?? null, streamId: $swooleRequest->streamId ?? 0);
} catch (CanceledException $exception) {
$cancelled = true;
@@ -158,7 +158,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
try {
$response = $this->responses->error($this->exceptions->map($throwable));
$emissionStarted = true;
- ResponseBridge::send($response, $swooleResponse, protocol: 'HTTP/2', request: $request ?? null);
+ ResponseBridge::send($response, $swooleResponse, protocol: 'HTTP/2', request: $request ?? null, streamId: $swooleRequest->streamId ?? 0);
} catch (CanceledException $exception) {
$cancelled = true;
diff --git a/src/http-server/composer.json b/src/http-server/composer.json
index 085d40467..3d1ed685d 100644
--- a/src/http-server/composer.json
+++ b/src/http-server/composer.json
@@ -37,6 +37,7 @@
"hypervel/coordinator": "^0.4",
"hypervel/coroutine": "^0.4",
"hypervel/http": "^0.4",
+ "hypervel/server": "^0.4",
"symfony/http-foundation": "^8.1"
},
"config": {
diff --git a/src/http-server/src/ResponseBridge.php b/src/http-server/src/ResponseBridge.php
index f2a41a6d2..df1109f61 100644
--- a/src/http-server/src/ResponseBridge.php
+++ b/src/http-server/src/ResponseBridge.php
@@ -6,9 +6,11 @@
use Hypervel\Contracts\Http\HasTrailers;
use Hypervel\Http\IterableStreamedResponse;
+use Hypervel\Server\ResponseCancellation;
use ReflectionProperty;
use RuntimeException;
use SplTempFileObject;
+use Swoole\Coroutine\CanceledException;
use Swoole\Http\Response as SwooleResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -44,6 +46,7 @@ public static function send(
bool $withBody = true,
string $protocol = 'HTTP/1.1',
?Request $request = null,
+ int $streamId = 0,
): void {
if ($response instanceof HasTrailers && $response instanceof BinaryFileResponse) {
throw new RuntimeException('Binary file responses cannot emit trailers.');
@@ -73,7 +76,22 @@ public static function send(
static::announceTrailers($response);
static::sendStatusAndHeaders($response, $swooleResponse);
- static::sendStreamedContent($response, $swooleResponse);
+ $registration = null;
+
+ if ($response instanceof IterableStreamedResponse && $response->shouldCancelOnDisconnect()) {
+ // A disconnect during controller work may precede this registration.
+ if (! $swooleResponse->isWritable()) {
+ throw new CanceledException('The client disconnected before response production.');
+ }
+
+ $registration = ResponseCancellation::register($swooleResponse->fd, $streamId);
+ }
+
+ try {
+ static::sendStreamedContent($response, $swooleResponse);
+ } finally {
+ $registration?->release();
+ }
return;
}
diff --git a/src/http-server/src/Server.php b/src/http-server/src/Server.php
index 45869f25c..5ccd408a7 100644
--- a/src/http-server/src/Server.php
+++ b/src/http-server/src/Server.php
@@ -144,6 +144,7 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
withBody: ! isset($rawMethod) || $rawMethod !== 'HEAD',
protocol: is_string($protocol) ? $protocol : 'HTTP/1.1',
request: $request ?? null,
+ streamId: $swooleRequest->streamId ?? 0,
);
} catch (CanceledException $throwable) {
$cancellation = $throwable;
diff --git a/src/http/composer.json b/src/http/composer.json
index 55eec8131..e6852fd1c 100644
--- a/src/http/composer.json
+++ b/src/http/composer.json
@@ -36,7 +36,9 @@
"hypervel/container": "^0.4",
"hypervel/context": "^0.4",
"hypervel/contracts": "^0.4",
+ "hypervel/coroutine": "^0.4",
"hypervel/database": "^0.4",
+ "hypervel/engine": "^0.4",
"hypervel/macroable": "^0.4",
"hypervel/object-pool": "^0.4",
"hypervel/pagination": "^0.4",
diff --git a/src/http/src/Client/CurlStreamingBody.php b/src/http/src/Client/CurlStreamingBody.php
new file mode 100644
index 000000000..35fe173a6
--- /dev/null
+++ b/src/http/src/Client/CurlStreamingBody.php
@@ -0,0 +1,531 @@
+request = $easy->request;
+ }
+
+ /**
+ * Receive the final headers and expose a caller-owned streaming body.
+ */
+ public function response(): ResponseInterface
+ {
+ $response = null;
+
+ try {
+ if (curl_multi_add_handle($this->connection->handle, $this->easy->handle) !== CURLM_OK) {
+ throw new RuntimeException('Unable to start the streaming request.');
+ }
+
+ $this->attached = true;
+ $idleDeadline = $this->readTimeout > 0 ? $this->startedAt + $this->readTimeout : null;
+ $auth = $this->options['curl'] ?? [];
+ $negotiatesAuth = isset($auth[CURLOPT_HTTPAUTH]) || isset($auth[CURLOPT_PROXYAUTH]);
+
+ while (! $this->completed) {
+ $this->advance();
+ $response = $this->easy?->response;
+
+ if ($response !== null
+ && ($response->getStatusCode() >= 200 || $response->getStatusCode() === 101)
+ && (! $negotiatesAuth || ! $this->buffer->eof())
+ ) {
+ break;
+ }
+
+ if (! $this->completed) {
+ $this->wait($idleDeadline, headers: true);
+ }
+ }
+
+ $response = $this->easy->response ?? $this->completedResponse;
+
+ // A pre-header retry creates and exposes its own streaming response.
+ if ($response->getBody() instanceof self) {
+ return $response;
+ }
+
+ $response = $response->withBody($this);
+ $this->exposed = true;
+
+ if ($this->headerDeadline !== null && hrtime(true) / 1e9 >= $this->headerDeadline) {
+ $this->timedOut = true;
+
+ throw $this->responseException('Timed out while receiving the response headers', $response, timeout: true);
+ }
+
+ $onHeaders = $this->options['on_headers'] ?? null;
+ unset($this->options['on_headers']);
+
+ if ($onHeaders !== null) {
+ try {
+ $onHeaders($response, $this->request);
+ } catch (CanceledException $exception) {
+ throw $exception;
+ } catch (Throwable $exception) {
+ throw $this->responseException('An error was encountered during the on_headers event', $response, $exception);
+ }
+ }
+
+ $this->reportStats($response);
+
+ return $response;
+ } catch (Throwable $exception) {
+ try {
+ $this->reportStats($this->exposed ? $response : null, $exception);
+ } finally {
+ $this->close();
+ }
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * Create a transport failure that retains the received response.
+ */
+ protected function responseException(string $message, ResponseInterface $response, ?Throwable $previous = null, bool $timeout = false): TransferException
+ {
+ if (class_exists(ResponseException::class)) {
+ return $timeout
+ ? new ResponseTimeoutException($message, $this->request, $response, $previous)
+ : new ResponseException($message, $this->request, $response, $previous);
+ }
+
+ return new GuzzleRequestException($message, $this->request, $response, $previous); // @phpstan-ignore argument.type (Guzzle 7 accepts the response before the cause.)
+ }
+
+ /**
+ * Read available bytes without waiting to fill the requested length.
+ */
+ public function read(int $length): string
+ {
+ if ($this->closed) {
+ throw new RuntimeException('Cannot read a closed streaming response.');
+ }
+
+ if ($length < 0) {
+ throw new RuntimeException('Length must be nonnegative.');
+ }
+
+ if ($length === 0) {
+ return '';
+ }
+
+ try {
+ if ($this->buffer->eof() && ! $this->completed) {
+ $deadline = $this->readTimeout > 0 ? hrtime(true) / 1e9 + $this->readTimeout : null;
+
+ // The sink pauses after each chunk; drain it and resume before selecting again.
+ if ($this->position > 0 && curl_pause($this->easy->handle, CURLPAUSE_CONT) !== CURLE_OK) {
+ throw new RuntimeException('Unable to resume the streaming response.');
+ }
+
+ while ($this->buffer->eof() && ! $this->completed) {
+ $this->advance();
+
+ if ($this->buffer->eof() && ! $this->completed) {
+ $this->wait($deadline);
+ }
+ }
+ }
+
+ $chunk = $this->buffer->read($length);
+ $this->position += strlen($chunk);
+
+ return $chunk;
+ } catch (Throwable $exception) {
+ $this->close();
+
+ throw $exception;
+ }
+ }
+
+ /**
+ * Drive native I/O and settle a completed transfer once.
+ */
+ protected function advance(): void
+ {
+ $result = curl_multi_exec($this->connection->handle, $running);
+
+ if (Coroutine::isCanceled()) {
+ throw new CanceledException('The streaming request was canceled.');
+ }
+
+ if ($result !== CURLM_OK) {
+ throw new RuntimeException('Unable to read the streaming response: ' . curl_multi_strerror($result));
+ }
+
+ $message = curl_multi_info_read($this->connection->handle);
+
+ if ($message === false) {
+ return;
+ }
+
+ $easy = $this->easy;
+ $easy->errno = $message['result'];
+ $this->detachTransfer();
+ $this->completed = true;
+ $this->easy = null;
+
+ $retry = function (RequestInterface $request, array $options): PromiseInterface {
+ if ($this->exposed) {
+ return Create::rejectionFor(new RuntimeException('Cannot retry a streaming request after exposing its response.'));
+ }
+
+ $options = array_replace($this->options, $options);
+
+ // Factory retry options contain its temporary statistics collector.
+ foreach (['on_headers', 'on_stats'] as $name) {
+ if (isset($this->options[$name])) {
+ $options[$name] = $this->options[$name];
+ } else {
+ unset($options[$name]);
+ }
+ }
+
+ if ($this->headerDeadline !== null) {
+ $remaining = $this->headerDeadline - hrtime(true) / 1e9;
+
+ if ($remaining <= 0) {
+ return Create::rejectionFor(new ConnectException('The streaming request timed out before response headers.', $request));
+ }
+
+ $options['timeout'] = $remaining;
+ }
+
+ // The replacement transfer owns callbacks and header-time statistics.
+ unset($this->options['on_headers'], $this->options['on_stats']);
+
+ return ($this->handler)($request, $options);
+ };
+
+ $stats = null;
+
+ if (isset($this->options['on_stats'])) {
+ $easy->options['on_stats'] = static function (TransferStats $transfer) use (&$stats): void {
+ $stats = $transfer;
+ };
+ }
+
+ try {
+ $this->completedResponse = CurlFactory::finish($retry, $easy, $this->factory)->wait();
+ } catch (TransferException $exception) {
+ // Completion callbacks run after the native cancellation check above.
+ if ($exception->getPrevious() instanceof CanceledException) {
+ throw $exception->getPrevious();
+ }
+
+ throw $exception;
+ } finally {
+ $this->completedStats = $stats;
+ }
+ }
+
+ /**
+ * Wait for network progress within the current phase's deadline.
+ */
+ protected function wait(?float &$idleDeadline, bool $headers = false): void
+ {
+ $deadline = $idleDeadline;
+
+ if ($headers && $this->headerDeadline !== null) {
+ $deadline = $deadline === null ? $this->headerDeadline : min($deadline, $this->headerDeadline);
+ }
+
+ $remaining = $deadline === null ? 1.0 : $deadline - hrtime(true) / 1e9;
+
+ if ($remaining <= 0) {
+ $this->timedOut = true;
+
+ if ($headers) {
+ throw new ConnectException('The streaming request timed out before response headers.', $this->request);
+ }
+
+ throw new RuntimeException('The streaming response read timed out.');
+ }
+
+ $selected = curl_multi_select($this->connection->handle, min(1.0, $remaining));
+
+ if (Coroutine::isCanceled()) {
+ throw new CanceledException('The streaming request was canceled.');
+ }
+
+ if ($selected === -1) {
+ // libcurl can briefly have no descriptors while scheduling its next action.
+ usleep(1000);
+ } elseif ($selected > 0 && $this->readTimeout > 0) {
+ $idleDeadline = hrtime(true) / 1e9 + $this->readTimeout;
+ }
+ }
+
+ /**
+ * Report streaming statistics once, at the handler-return boundary.
+ */
+ protected function reportStats(?ResponseInterface $response, ?Throwable $error = null): void
+ {
+ $callback = $this->options['on_stats'] ?? null;
+ unset($this->options['on_stats']);
+ $stats = $this->completedStats;
+ $this->completedStats = null;
+
+ if ($callback !== null) {
+ $handlerStats = $stats?->getHandlerStats() ?? [];
+
+ if ($this->easy !== null) {
+ $handlerStats = curl_getinfo($this->easy->handle);
+ $handlerStats['appconnect_time'] = curl_getinfo($this->easy->handle, CURLINFO_APPCONNECT_TIME);
+ }
+
+ $handlerError = $stats?->getHandlerErrorData() ?? 0;
+
+ $callback(new TransferStats(
+ $this->request,
+ $response ?? $stats?->getResponse(),
+ hrtime(true) / 1e9 - $this->startedAt,
+ $handlerError === 0 ? ($error ?? 0) : $handlerError,
+ $handlerStats,
+ ));
+ }
+ }
+
+ /**
+ * Remove the active handle before returning its native connection cache.
+ */
+ protected function detachTransfer(): void
+ {
+ $connection = $this->connection;
+ $this->connection = null;
+
+ if ($connection === null) {
+ return;
+ }
+
+ if ($this->attached) {
+ $this->attached = false;
+
+ if (curl_multi_remove_handle($connection->handle, $this->easy->handle) !== CURLM_OK) {
+ throw new RuntimeException('Unable to detach the streaming request.');
+ }
+ }
+
+ $this->handler->release($connection);
+ }
+
+ /**
+ * Close the stream and settle its transfer without waiting for more data.
+ */
+ public function close(): void
+ {
+ if ($this->closed) {
+ return;
+ }
+
+ $this->closed = true;
+
+ try {
+ $this->detachTransfer();
+ } finally {
+ if ($this->easy !== null) {
+ $easy = $this->easy;
+ $this->easy = null;
+ $this->factory->release($easy);
+ }
+
+ $this->buffer->close();
+ $this->completedResponse = null;
+ $this->completedStats = null;
+ $this->options = [];
+ }
+ }
+
+ /**
+ * Detach the body, which has no PHP stream resource to return.
+ */
+ public function detach(): mixed
+ {
+ $this->close();
+
+ return null;
+ }
+
+ /**
+ * Get the unknown size of the incremental response body.
+ */
+ public function getSize(): ?int
+ {
+ return null;
+ }
+
+ /**
+ * Get the number of bytes consumed by the caller.
+ */
+ public function tell(): int
+ {
+ return $this->position;
+ }
+
+ /**
+ * Determine whether the completed body has been consumed.
+ */
+ public function eof(): bool
+ {
+ return $this->closed || ($this->completed && $this->buffer->eof());
+ }
+
+ /**
+ * Determine whether the stream can seek.
+ */
+ public function isSeekable(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Reject seeking on an incremental response.
+ */
+ public function seek(int $offset, int $whence = SEEK_SET): void
+ {
+ throw new RuntimeException('Cannot seek a streaming response.');
+ }
+
+ /**
+ * Reject rewinding an incremental response.
+ */
+ public function rewind(): void
+ {
+ $this->seek(0);
+ }
+
+ /**
+ * Determine whether the response accepts writes.
+ */
+ public function isWritable(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Reject writes to the response body.
+ */
+ public function write(string $string): int
+ {
+ throw new RuntimeException('Cannot write to a streaming response.');
+ }
+
+ /**
+ * Determine whether the stream is open for reading.
+ */
+ public function isReadable(): bool
+ {
+ return ! $this->closed;
+ }
+
+ /**
+ * Read the remaining response body.
+ */
+ public function getContents(): string
+ {
+ $contents = '';
+
+ while (! $this->eof()) {
+ $contents .= $this->read(8192);
+ }
+
+ return $contents;
+ }
+
+ /**
+ * Get metadata for the forward-only response stream.
+ */
+ public function getMetadata(?string $key = null): mixed
+ {
+ $metadata = ['timed_out' => $this->timedOut, 'seekable' => false, 'eof' => $this->eof()];
+
+ return $key === null ? $metadata : ($metadata[$key] ?? null);
+ }
+
+ /**
+ * Read the remaining response body as a string.
+ */
+ public function __toString(): string
+ {
+ return $this->getContents();
+ }
+
+ /**
+ * Release a response abandoned by its consumer.
+ */
+ public function __destruct()
+ {
+ try {
+ $this->close();
+ } catch (Throwable $exception) {
+ PoolErrorReporter::report($exception);
+ }
+ }
+}
diff --git a/src/http/src/Client/CurlStreamingConnection.php b/src/http/src/Client/CurlStreamingConnection.php
new file mode 100644
index 000000000..32813eb3f
--- /dev/null
+++ b/src/http/src/Client/CurlStreamingConnection.php
@@ -0,0 +1,30 @@
+handle = curl_multi_init();
+
+ if (! curl_multi_setopt($this->handle, CURLMOPT_MAXCONNECTS, 1)) {
+ throw new RuntimeException('Unable to limit the streaming connection cache.');
+ }
+ }
+}
diff --git a/src/http/src/Client/CurlStreamingHandler.php b/src/http/src/Client/CurlStreamingHandler.php
new file mode 100644
index 000000000..705837120
--- /dev/null
+++ b/src/http/src/Client/CurlStreamingHandler.php
@@ -0,0 +1,210 @@
+ */
+ protected array $idleConnections = [];
+
+ /**
+ * Create a streaming handler with optional named-connection retention.
+ */
+ public function __construct(array $options = [], protected int $maxIdleConnections = 0)
+ {
+ $sharing = CurlShareHandleState::fromOption($options['transport_sharing'] ?? null);
+ $this->factory = new CurlFactory(
+ $maxIdleConnections,
+ $sharing->mode ?? TransportSharing::NONE,
+ $sharing,
+ );
+ }
+
+ /**
+ * Add coroutine streaming while retaining the caller's fallback handler.
+ */
+ public static function wrap(callable $fallback, array $options = [], int $maxIdleConnections = 0): Closure
+ {
+ $streaming = null;
+
+ return static function (RequestInterface $request, array $requestOptions) use ($fallback, $options, $maxIdleConnections, &$streaming): PromiseInterface {
+ if (empty($requestOptions['stream']) || ! self::supports($requestOptions)) {
+ return $fallback($request, $requestOptions);
+ }
+
+ $streaming ??= new self($options, $maxIdleConnections);
+
+ return $streaming($request, $requestOptions);
+ };
+ }
+
+ /**
+ * Determine whether the current execution can use native streaming.
+ */
+ public static function supports(array $options): bool
+ {
+ return Coroutine::inCoroutine()
+ && (Runtime::getHookFlags() & SWOOLE_HOOK_NATIVE_CURL) !== 0
+ && function_exists('curl_multi_exec')
+ && ! array_key_exists('stream_context', $options)
+ && (! isset($options['stream_factory']) || $options['stream_factory'] instanceof HttpFactory);
+ }
+
+ /**
+ * Start a transfer and return its response without draining its body.
+ *
+ * @return PromiseInterface
+ */
+ public function __invoke(RequestInterface $request, array $options): PromiseInterface
+ {
+ // Guzzle has consumed the stack; retaining it would keep its response alive.
+ unset($options['handler']);
+
+ try {
+ HostValidator::assertRequestHost($request);
+
+ if (isset($options['delay'])) {
+ Sleep::usleep((int) ($options['delay'] * 1000));
+ }
+
+ $timeout = $this->timeout($options['timeout'] ?? 0, 'timeout');
+ $readTimeout = $this->timeout($options['read_timeout'] ?? CurlStreamingBody::DEFAULT_READ_TIMEOUT, 'read_timeout');
+ $startedAt = hrtime(true) / 1e9;
+ $deadline = $timeout > 0 ? $startedAt + $timeout : null;
+
+ foreach (['on_headers', 'on_stats'] as $name) {
+ if (isset($options[$name]) && ! is_callable($options[$name])) {
+ throw new InvalidArgumentException("{$name} must be callable.");
+ }
+ }
+
+ $buffer = new BufferStream;
+ $handle = null;
+ $sink = FnStream::decorate($buffer, [
+ 'write' => static function (string $chunk) use ($buffer, &$handle): int {
+ $buffer->write($chunk);
+
+ // Accept this chunk, then stop native reads until the consumer drains it.
+ /** @var CurlHandle $handle Assigned before the transfer starts. */
+ if (curl_pause($handle, CURLPAUSE_RECV) !== CURLE_OK) {
+ throw new RuntimeException('Unable to pause the streaming response.');
+ }
+
+ return strlen($chunk);
+ },
+ ]);
+ $transferOptions = $options;
+ unset($transferOptions['on_headers'], $transferOptions['on_stats']);
+ $transferOptions['sink'] = $sink;
+ // Streaming timeout ends at headers; body waits use read_timeout.
+ $transferOptions['timeout'] = 0;
+ $easy = $this->factory->create($request, $transferOptions);
+ $handle = $easy->handle;
+
+ try {
+ $connection = $this->acquire($easy);
+ $body = new CurlStreamingBody(
+ $this,
+ $this->factory,
+ $connection,
+ $easy,
+ $buffer,
+ $options,
+ $startedAt,
+ $deadline,
+ $readTimeout,
+ );
+ } catch (Throwable $exception) {
+ $this->factory->release($easy);
+
+ throw $exception;
+ }
+
+ return Create::promiseFor($body->response());
+ } catch (CanceledException $exception) {
+ throw $exception;
+ } catch (Throwable $exception) {
+ return Create::rejectionFor($exception);
+ }
+ }
+
+ /**
+ * Obtain an exclusive transport without sharing another proxy's tunnel.
+ */
+ protected function acquire(EasyHandle $easy): CurlStreamingConnection
+ {
+ for ($index = count($this->idleConnections) - 1; $index >= 0; --$index) {
+ $connection = $this->idleConnections[$index];
+
+ if ($connection->proxyTunnelSignature === $easy->proxyTunnelSignature) {
+ array_splice($this->idleConnections, $index, 1);
+
+ return $connection;
+ }
+ }
+
+ return new CurlStreamingConnection($easy->proxyTunnelSignature);
+ }
+
+ /**
+ * Retain an idle transport after its request has been detached.
+ */
+ public function release(CurlStreamingConnection $connection): void
+ {
+ if ($this->maxIdleConnections === 0) {
+ return;
+ }
+
+ if (count($this->idleConnections) >= $this->maxIdleConnections) {
+ array_shift($this->idleConnections);
+ }
+
+ $this->idleConnections[] = $connection;
+ }
+
+ /**
+ * Normalize a nonnegative request timeout in seconds.
+ */
+ protected function timeout(mixed $value, string $name): float
+ {
+ if (! is_numeric($value) || ! is_finite((float) $value) || $value < 0) {
+ throw new InvalidArgumentException("{$name} must be a finite nonnegative number.");
+ }
+
+ return (float) $value;
+ }
+}
diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php
index b1bb8d71b..090a7a87c 100644
--- a/src/http/src/Client/Factory.php
+++ b/src/http/src/Client/Factory.php
@@ -675,7 +675,11 @@ public function getConnectionHandler(string $name): callable
*/
protected function createConnectionHandler(array $options): callable
{
- return Utils::chooseHandler($options);
+ return CurlStreamingHandler::wrap(
+ Utils::chooseHandler($options),
+ $options,
+ CurlStreamingHandler::MAX_IDLE_CONNECTIONS,
+ );
}
/**
diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php
index 77cd7c30d..2f4bc0457 100644
--- a/src/http/src/Client/PendingRequest.php
+++ b/src/http/src/Client/PendingRequest.php
@@ -20,6 +20,7 @@
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\TransferStats;
use GuzzleHttp\UriTemplate\UriTemplate;
+use GuzzleHttp\Utils;
use Hypervel\Contracts\Container\Transient;
use Hypervel\Contracts\Support\Arrayable;
use Hypervel\Http\Client\Destinations\CurlCapabilities;
@@ -1051,6 +1052,11 @@ function (&$response) use ($attempt, &$shouldRetry) {
}
);
} catch (TransferException $e) {
+ // Guzzle wraps failures raised by transport callbacks.
+ if ($e->getPrevious() instanceof CanceledException) {
+ throw $e->getPrevious();
+ }
+
if (($response = $this->responseFromException($e)) !== null) {
$this->marshalTransportExceptionWithResponse($e, $response);
} elseif (method_exists($e, 'getRequest')) {
@@ -1170,6 +1176,10 @@ protected function makePromise(string $method, string $url, array $options = [],
return $this->runAfterResponseCallbacks($response);
})
->otherwise(function (Throwable $e) {
+ if ($e instanceof TransferException && $e->getPrevious() instanceof CanceledException) {
+ throw $e->getPrevious();
+ }
+
if ($e instanceof CanceledException) {
throw $e;
}
@@ -1715,12 +1725,14 @@ public function buildHandlerStack(): HandlerStack
$handler = $this->factory->getConnectionHandler($this->connection);
}
+ $handler ??= CurlStreamingHandler::wrap(Utils::chooseHandler());
+
$stack = $this->pushHandlers(HandlerStack::create($handler));
if ($this->handler === null && ! ini_get('allow_url_fopen')) {
// Faked responses return before reaching this transport-only guard.
$stack->push(static fn (callable $handler): Closure => static function (RequestInterface $request, array $options) use ($handler): PromiseInterface {
- if ($options['stream'] ?? false) {
+ if (($options['stream'] ?? false) && ! CurlStreamingHandler::supports($options)) {
throw new RuntimeException('Streaming responses require allow_url_fopen when using the default HTTP handler.');
}
@@ -1964,7 +1976,7 @@ protected function sendToPolicyDestination(
): PromiseInterface {
// The pins only bind the cURL handler, and raw cURL or proxy options could route around them.
$unsupported = match (true) {
- ! empty($options['stream']) => 'cannot stream responses; use [sink] instead',
+ ! empty($options['stream']) && ! CurlStreamingHandler::supports($options) => 'cannot stream responses with the fallback transport; use [sink] instead',
$this->handler !== null => 'cannot use a custom handler',
($options['proxy'] ?? '') !== '' => 'cannot set the [proxy] option; select proxies in the destination policy',
! empty($options['curl']) => 'cannot set raw [curl] options',
diff --git a/src/http/src/IterableStreamedResponse.php b/src/http/src/IterableStreamedResponse.php
index 77500d79e..437aaed96 100644
--- a/src/http/src/IterableStreamedResponse.php
+++ b/src/http/src/IterableStreamedResponse.php
@@ -19,6 +19,8 @@ class IterableStreamedResponse extends StreamedResponse
/** @var null|iterable */
private array|Traversable|null $chunks = null;
+ protected bool $cancelOnDisconnect = false;
+
/**
* Create a new iterable streamed response.
*
@@ -31,6 +33,24 @@ public function __construct(iterable $chunks, int $status = 200, array $headers
$this->setChunks($chunks);
}
+ /**
+ * Cancel response production when the client connection closes.
+ */
+ public function cancelOnDisconnect(bool $cancel = true): static
+ {
+ $this->cancelOnDisconnect = $cancel;
+
+ return $this;
+ }
+
+ /**
+ * Determine whether disconnects should cancel response production.
+ */
+ public function shouldCancelOnDisconnect(): bool
+ {
+ return $this->cancelOnDisconnect;
+ }
+
/**
* Set the chunks associated with the response.
*
diff --git a/src/routing/src/ResponseFactory.php b/src/routing/src/ResponseFactory.php
index bf8a3b350..7fb861f51 100644
--- a/src/routing/src/ResponseFactory.php
+++ b/src/routing/src/ResponseFactory.php
@@ -19,6 +19,7 @@
use Hypervel\Support\Traits\Macroable;
use ReflectionFunction;
use SplFileInfo;
+use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -93,10 +94,13 @@ public function jsonp(string $callback, mixed $data = [], int $status = 200, arr
/**
* Create a new event stream response.
+ *
+ * @return IterableStreamedResponse
*/
public function eventStream(Closure $callback, array $headers = [], StreamedEvent|string|null $endStreamWith = ''): StreamedResponse
{
- return $this->stream(function () use ($callback, $endStreamWith) {
+ /** @var IterableStreamedResponse $response */
+ $response = $this->stream(function () use ($callback, $endStreamWith) {
try {
foreach ($callback() as $message) {
yield $this->formatStreamedEvent($message);
@@ -105,6 +109,8 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven
if (filled($endStreamWith)) {
yield $this->formatStreamedEvent($endStreamWith);
}
+ } catch (CanceledException $e) {
+ throw $e;
} catch (Throwable $e) {
report($e);
}
@@ -113,6 +119,8 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]));
+
+ return $response;
}
/**
@@ -183,6 +191,8 @@ public function streamDownload(callable $callback, ?string $name = null, array $
$withWrappedException = function () use ($callback) {
try {
$callback();
+ } catch (CanceledException $e) {
+ throw $e;
} catch (Throwable $e) {
throw new StreamedResponseException($e);
}
diff --git a/src/server/src/ResponseCancellation.php b/src/server/src/ResponseCancellation.php
new file mode 100644
index 000000000..8108a54bb
--- /dev/null
+++ b/src/server/src/ResponseCancellation.php
@@ -0,0 +1,79 @@
+> */
+ protected static array $responses = [];
+
+ /**
+ * Identify the execution producing one response on a connection.
+ */
+ private function __construct(
+ protected int $connection,
+ protected int $stream,
+ protected int $coroutine,
+ ) {
+ }
+
+ /**
+ * Register the current coroutine for the duration of response production.
+ */
+ public static function register(int $connection, int $stream = 0): ?self
+ {
+ $coroutine = Coroutine::id();
+
+ if ($coroutine < 0) {
+ return null;
+ }
+
+ return self::$responses[$connection][$coroutine] = new self($connection, $stream, $coroutine);
+ }
+
+ /**
+ * Cancel every active producer on a closed connection.
+ */
+ public static function cancel(int $connection): void
+ {
+ $responses = self::$responses[$connection] ?? [];
+ unset(self::$responses[$connection]);
+
+ foreach ($responses as $response) {
+ Coroutine::cancelById($response->coroutine, true);
+ }
+ }
+
+ /**
+ * Remove this registration without disturbing a newer response.
+ */
+ public function release(): void
+ {
+ if ((self::$responses[$this->connection][$this->coroutine] ?? null) !== $this) {
+ return;
+ }
+
+ unset(self::$responses[$this->connection][$this->coroutine]);
+
+ if (self::$responses[$this->connection] === []) {
+ unset(self::$responses[$this->connection]);
+ }
+ }
+
+ /**
+ * Flush all static state.
+ */
+ public static function flushState(): void
+ {
+ self::$responses = [];
+ }
+}
diff --git a/src/server/src/Server.php b/src/server/src/Server.php
index cfee0dece..0798bf413 100644
--- a/src/server/src/Server.php
+++ b/src/server/src/Server.php
@@ -90,10 +90,10 @@ protected function initServers(ServerConfig $config): void
if (! $this->server instanceof SwooleServer) {
$this->server = $this->makeServer($type, $host, $port, $config->getMode(), $sockType);
$callbacks = array_replace($this->defaultCallbacks(), $config->getCallbacks(), $callbacks);
- $this->registerSwooleEvents($this->server, $callbacks, $name);
if ($this->server->set(array_replace($config->getSettings(), $server->getSettings())) === false) {
throw new ServerException("Failed to configure server [{$name}].");
}
+ $this->registerSwooleEvents($this->server, $callbacks, $name);
ServerManager::add($name, [$type, current($this->server->ports)]);
// Trigger BeforeMainServerStart event, this event only triggers once before main server start.
@@ -182,6 +182,18 @@ protected function makeServer(int $type, string $host, int $port, int $mode, int
*/
protected function registerSwooleEvents(SwoolePort|SwooleServer $server, array $events, string $serverName): void
{
+ $mainServer = $server instanceof SwooleServer ? $server : $this->getServer();
+ $settings = $mainServer->setting ?? [];
+ // These dispatch modes keep connection events in the response's worker.
+ $cancelResponses = (isset($events[Event::ON_REQUEST]) || isset($events[Event::ON_HANDSHAKE]))
+ && ($settings['enable_coroutine'] ?? true)
+ && ($mainServer->mode === SWOOLE_BASE
+ || ($mainServer->mode === SWOOLE_PROCESS && in_array((int) ($settings['dispatch_mode'] ?? 2), [2, 4, 8], true)));
+
+ if ($cancelResponses) {
+ $events[Event::ON_CLOSE] ??= null;
+ }
+
foreach ($events as $event => $callback) {
if (! Event::isSwooleEvent($event)) {
continue;
@@ -204,6 +216,18 @@ protected function registerSwooleEvents(SwoolePort|SwooleServer $server, array $
$callback = [$class, $method];
}
+ if ($cancelResponses && $event === Event::ON_CLOSE) {
+ $callback = static function (SwooleServer $server, int $connection, int $reactor) use ($callback): void {
+ try {
+ ResponseCancellation::cancel($connection);
+ } finally {
+ if ($callback !== null) {
+ $callback($server, $connection, $reactor);
+ }
+ }
+ };
+ }
+
if (
($event === Event::ON_REQUEST || $event === Event::ON_HANDSHAKE)
&& is_callable($callback)
diff --git a/src/support/src/Facades/Response.php b/src/support/src/Facades/Response.php
index 27c817000..0d9659cdb 100644
--- a/src/support/src/Facades/Response.php
+++ b/src/support/src/Facades/Response.php
@@ -8,7 +8,7 @@
/**
* @method static \Symfony\Component\HttpFoundation\BinaryFileResponse download(\SplFileInfo|string $file, string|null $name = null, array $headers = [], string $disposition = 'attachment')
- * @method static \Symfony\Component\HttpFoundation\StreamedResponse eventStream(\Closure $callback, array $headers = [], \Hypervel\Http\StreamedEvent|string|null $endStreamWith = '')
+ * @method static \Hypervel\Http\IterableStreamedResponse eventStream(\Closure $callback, array $headers = [], \Hypervel\Http\StreamedEvent|string|null $endStreamWith = '')
* @method static \Symfony\Component\HttpFoundation\BinaryFileResponse file(\SplFileInfo|string $file, array $headers = [])
* @method static void flushMacros()
* @method static void flushState()
diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
index 62a71b10b..33b3a8a00 100644
--- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
+++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
@@ -261,6 +261,7 @@ protected function flushFrameworkState(): void
\Hypervel\Routing\SortedMiddleware::flushCache();
\Hypervel\Routing\UrlGenerator::flushState();
\Hypervel\Server\ServerManager::flushState();
+ \Hypervel\Server\ResponseCancellation::flushState();
\Hypervel\ServerProcess\ProcessCollector::flushState();
\Hypervel\ServerProcess\ProcessManager::flushState();
\Hypervel\Session\Middleware\AuthenticateSession::flushState();
diff --git a/src/websocket-server/src/Server.php b/src/websocket-server/src/Server.php
index 92ac47ee5..fdd1f388d 100644
--- a/src/websocket-server/src/Server.php
+++ b/src/websocket-server/src/Server.php
@@ -199,7 +199,7 @@ public function onHandshake(Request $request, SwooleResponse $response): void
}
try {
- ResponseBridge::send($httpResponse, $response, request: $httpRequest);
+ ResponseBridge::send($httpResponse, $response, request: $httpRequest, streamId: $request->streamId ?? 0);
} catch (CanceledException $exception) {
throw $exception;
} catch (Throwable $throwable) {
diff --git a/tests/Http/Client/CurlStreamingHandlerTest.php b/tests/Http/Client/CurlStreamingHandlerTest.php
new file mode 100644
index 000000000..1de7d375f
--- /dev/null
+++ b/tests/Http/Client/CurlStreamingHandlerTest.php
@@ -0,0 +1,383 @@
+expectException(ConnectionException::class);
+ $this->expectExceptionMessage($message);
+
+ (new Factory)->withOptions(['stream' => true])->withHeaders($headers)->get($url);
+ }
+
+ /**
+ * Provide URI and Host-header spellings that transports interpret differently.
+ */
+ public static function ambiguousHosts(): array
+ {
+ return [
+ ['http://127.0.0.1.:1/', [], 'must not be written as one to four'],
+ ['http://127.0.0.1:1/', ['Host' => "example.test\xc2\xa0"], 'must contain only printable ASCII'],
+ ];
+ }
+
+ public function testRedirectsCookiesAndDecompressionKeepThePublicClientBehavior(): void
+ {
+ $server = LoopbackHttpServer::start([
+ ['status' => 302, 'headers' => ['Location' => '/final', 'Set-Cookie' => 'session=test; Path=/'], 'body' => 'redirect'],
+ ['headers' => ['Content-Encoding' => 'gzip'], 'body' => gzencode("{\"id\":1}\n{\"id\":2}\n")],
+ ]);
+ $response = (new Factory)->withOptions(['stream' => true])->get('http://127.0.0.1:' . $server->port);
+
+ try {
+ $this->assertSame(200, $response->status());
+ $this->assertSame([['id' => 1], ['id' => 2]], iterator_to_array($response->jsonLines()));
+ $this->assertSame('', $response->header('Content-Encoding'));
+ $server->request();
+ $redirected = $server->request();
+ $this->assertStringStartsWith('GET /final HTTP/1.1', $redirected);
+ $this->assertStringContainsString('Cookie: session=test', $redirected);
+ } finally {
+ $response->close();
+ }
+ }
+
+ #[DataProvider('authenticationResponses')]
+ public function testNativeAuthenticationExposesOnlyTheFinalPostResponse(int $initialStatus): void
+ {
+ $server = LoopbackHttpServer::start([
+ ['status' => $initialStatus, 'headers' => ['WWW-Authenticate' => 'Digest realm="test", nonce="fixed", qop="auth", algorithm=MD5'], 'body' => 'intermediate'],
+ ['body' => 'final'],
+ ]);
+ $headers = [];
+ $stats = [];
+ $response = (new Factory)->withOptions([
+ 'stream' => true,
+ 'curl' => [CURLOPT_HTTPAUTH => CURLAUTH_DIGEST, CURLOPT_USERPWD => 'account:secret'],
+ 'on_headers' => static function (ResponseInterface $response) use (&$headers): void {
+ $headers[] = $response->getStatusCode();
+ },
+ 'on_stats' => static function (TransferStats $transfer) use (&$stats): void {
+ $stats[] = $transfer->getResponse()->getStatusCode();
+ },
+ ])->withBody('prompt')->post('http://127.0.0.1:' . $server->port);
+
+ try {
+ $this->assertSame([200], $headers);
+ $this->assertSame([200], $stats);
+ $this->assertSame('final', $response->body());
+ $this->assertSame([200], $stats);
+ $this->assertStringEndsWith("\r\n\r\n", $server->request());
+ $this->assertStringEndsWith("\r\n\r\nprompt", $server->request());
+ } finally {
+ $response->close();
+ }
+ }
+
+ /**
+ * Provide challenge and successful negotiation responses before the real POST.
+ */
+ public static function authenticationResponses(): array
+ {
+ return [[401], [200]];
+ }
+
+ public function testDigestAuthenticationWorksThroughThePublicApi(): void
+ {
+ $server = LoopbackHttpServer::start([
+ ['status' => 401, 'headers' => ['WWW-Authenticate' => 'Digest realm="test", nonce="fixed", qop="auth", algorithm=MD5']],
+ ['body' => 'authenticated'],
+ ]);
+ $response = (new Factory)->withOptions(['stream' => true])->withDigestAuth('account', 'secret')
+ ->withBody('prompt')->post('http://127.0.0.1:' . $server->port);
+
+ try {
+ $this->assertSame(200, $response->status());
+ $this->assertSame('authenticated', $response->body());
+ $server->request();
+ $authenticated = $server->request();
+ $this->assertStringContainsString('Authorization: Digest ', $authenticated);
+ $this->assertStringEndsWith("\r\n\r\nprompt", $authenticated);
+ } finally {
+ $response->close();
+ }
+ }
+
+ #[DataProvider('callbackFailureStatuses')]
+ public function testHeadersCallbackFailureKeepsItsResponseAndCauseAndClosesTheTransfer(int $status): void
+ {
+ $server = LoopbackHttpServer::start([['status' => $status]]);
+ $failure = new RuntimeException('invalid provider headers');
+ $stats = [];
+
+ try {
+ (new Factory)->withOptions([
+ 'stream' => true,
+ 'on_headers' => static fn () => throw $failure,
+ 'on_stats' => static function (TransferStats $transfer) use (&$stats): void {
+ $stats[] = $transfer;
+ },
+ ])->get('http://127.0.0.1:' . $server->port);
+
+ $this->fail('Expected the header callback to fail.');
+ } catch (ConnectionException|RequestException $exception) {
+ if ($status === 200) {
+ $this->assertInstanceOf(ConnectionException::class, $exception);
+ $this->assertSame('An error was encountered during the on_headers event', $exception->getMessage());
+ $this->assertSame($failure, $exception->getPrevious()->getPrevious());
+ } else {
+ $this->assertInstanceOf(RequestException::class, $exception);
+ $this->assertSame($status, $exception->response->status());
+ }
+ }
+
+ $this->assertCount(1, $stats);
+ $this->assertSame($status, $stats[0]->getResponse()->getStatusCode());
+ $this->assertSame($failure, $stats[0]->getHandlerErrorData()->getPrevious());
+ $this->assertFalse($stats[0]->getResponse()->getBody()->isReadable());
+ $this->assertNotNull($server->request());
+ }
+
+ /**
+ * Provide successful and failed responses rejected by a header callback.
+ */
+ public static function callbackFailureStatuses(): array
+ {
+ return [[200], [503]];
+ }
+
+ public function testLateFinalHeadersRetainTheirResponseInTheExceptionAndStatistics(): void
+ {
+ $server = LoopbackHttpServer::start([['status' => 503, 'headers' => ['Retry-After' => '10']]]);
+ $delayed = false;
+ $stats = [];
+
+ try {
+ (new Factory)->timeout(0.5)->withOptions([
+ 'stream' => true,
+ 'progress' => static function (int $total, int $received) use (&$delayed): void {
+ if ($received > 0 && ! $delayed) {
+ $delayed = true;
+ // Headers exist, but native execution returns after their deadline.
+ usleep(600000);
+ }
+ },
+ 'on_stats' => static function (TransferStats $transfer) use (&$stats): void {
+ $stats[] = $transfer;
+ },
+ ])->get('http://127.0.0.1:' . $server->port);
+
+ $this->fail('Expected the late response to time out.');
+ } catch (RequestException $exception) {
+ $this->assertSame(503, $exception->response->status());
+ $this->assertSame('10', $exception->response->header('Retry-After'));
+ }
+
+ $this->assertCount(1, $stats);
+ $this->assertSame(503, $stats[0]->getResponse()->getStatusCode());
+ $this->assertSame('Timed out while receiving the response headers', $stats[0]->getHandlerErrorData()->getMessage());
+ $this->assertFalse($stats[0]->getResponse()->getBody()->isReadable());
+ $this->assertTrue($stats[0]->getResponse()->getBody()->getMetadata('timed_out'));
+ }
+
+ public function testPreHeaderRetryDoesNotRecheckTheDeadlineAfterTheReplacementCallback(): void
+ {
+ $server = LoopbackHttpServer::start([['body' => ''], ['body' => 'retried']]);
+ $nativeFactory = new CurlFactory(0);
+ $first = true;
+ $factory = m::mock(CurlFactoryInterface::class);
+ $factory->shouldReceive('create')->andReturnUsing(static function (RequestInterface $request, array $options) use ($nativeFactory, &$first): EasyHandle {
+ $easy = $nativeFactory->create($request, $options);
+
+ if ($first) {
+ $first = false;
+ // Guzzle retries native success without a parsed response; reproduce that outcome.
+ curl_setopt($easy->handle, CURLOPT_HEADERFUNCTION, static fn (CurlHandle $handle, string $header): int => strlen($header));
+ }
+
+ return $easy;
+ });
+ $factory->shouldReceive('release')->andReturnUsing($nativeFactory->release(...));
+ $handler = new CurlStreamingHandler;
+ (new ReflectionProperty($handler, 'factory'))->setValue($handler, $factory);
+ $callbacks = 0;
+ $response = (new Factory)->setHandler($handler)->timeout(0.5)->withOptions([
+ 'stream' => true,
+ 'on_headers' => static function () use (&$callbacks): void {
+ ++$callbacks;
+ usleep(600000);
+ },
+ ])->get('http://127.0.0.1:' . $server->port);
+
+ try {
+ $this->assertSame('retried', $response->body());
+ $this->assertSame(1, $callbacks);
+ $this->assertNotNull($server->request());
+ $this->assertNotNull($server->request());
+ } finally {
+ $response->close();
+ }
+ }
+
+ #[DataProvider('requestExecutionModes')]
+ public function testWrappedHeaderCancellationIsNotRecoveredOrRetried(bool $async): void
+ {
+ $server = LoopbackHttpServer::start();
+ $failure = new CanceledException('header processing canceled');
+ $events = new Dispatcher;
+ $events->listen(ConnectionFailed::class, fn () => $this->fail('Cancellation dispatched a connection failure.'));
+ $attempts = 0;
+ $request = (new Factory($events))->async($async)
+ ->beforeSending(static function () use (&$attempts): void {
+ ++$attempts;
+ })
+ ->afterResponse(fn () => $this->fail('Cancellation recovered an HTTP response.'))
+ ->retry(3, when: fn () => $this->fail('Cancellation reached the retry policy.'))
+ ->withOptions(['on_headers' => static fn () => throw $failure]);
+
+ try {
+ $result = $request->get('http://127.0.0.1:' . $server->port);
+
+ if ($async) {
+ $result->wait();
+ }
+
+ $this->fail('Expected the callback cancellation.');
+ } catch (CanceledException $exception) {
+ $this->assertSame($failure, $exception);
+ }
+
+ $this->assertSame(1, $attempts);
+ $this->assertNotNull($server->request());
+ }
+
+ /**
+ * Provide synchronous and promise-based request execution.
+ */
+ public static function requestExecutionModes(): array
+ {
+ return [[false], [true]];
+ }
+
+ public function testCancellationDuringTrailerProcessingClosesTheBodyAndPreservesTheException(): void
+ {
+ $server = LoopbackHttpServer::start();
+ $ready = new Channel(1);
+ $waiting = new Channel(1);
+ $cancellation = null;
+ $response = (new Factory)->withOptions([
+ 'stream' => true,
+ 'on_trailers' => static function () use ($ready, $waiting, &$cancellation): void {
+ $ready->push(Coroutine::id());
+
+ try {
+ $waiting->pop(2);
+ } catch (CanceledException $exception) {
+ $cancellation = $exception;
+
+ throw $exception;
+ }
+ },
+ ])->get('http://127.0.0.1:' . $server->port);
+
+ try {
+ $results = parallel([
+ 'reader' => static function () use ($response): ?CanceledException {
+ try {
+ $response->body();
+
+ return null;
+ } catch (CanceledException $exception) {
+ return $exception;
+ }
+ },
+ 'cancel' => function () use ($ready): void {
+ $coroutine = $ready->pop(1);
+ $this->assertIsInt($coroutine);
+ usleep(1000);
+ $this->assertTrue(Coroutine::cancelById($coroutine, true));
+ },
+ ]);
+
+ $this->assertInstanceOf(CanceledException::class, $results['reader']);
+ $this->assertSame($cancellation, $results['reader']);
+ $this->assertFalse($response->toPsrResponse()->getBody()->isReadable());
+ } finally {
+ $response->close();
+ $ready->close();
+ $waiting->close();
+ }
+ }
+
+ public function testAbandonedResponseBodyIsReleasedWithoutCyclicGarbageCollection(): void
+ {
+ $server = LoopbackHttpServer::start();
+ $response = (new Factory)->withOptions(['stream' => true])->get('http://127.0.0.1:' . $server->port);
+ $body = WeakReference::create($response->toPsrResponse()->getBody());
+
+ unset($response);
+
+ $this->assertNull($body->get());
+ $this->assertNotNull($server->request());
+ }
+
+ public function testSlowConsumersPauseDownloadsAndMayContinueBeyondTheHeaderTimeout(): void
+ {
+ $payload = str_repeat('streamed content ', 131072);
+ $server = LoopbackHttpServer::start([['body' => $payload]]);
+ $downloaded = 0;
+ $response = (new Factory)->timeout(0.5)->withOptions([
+ 'stream' => true,
+ 'read_timeout' => 0,
+ 'progress' => static function (int $total, int $received) use (&$downloaded): void {
+ $downloaded = $received;
+ },
+ ])->get('http://127.0.0.1:' . $server->port);
+
+ try {
+ $body = $response->toPsrResponse()->getBody();
+ $first = $body->read(1);
+ $pausedAt = $downloaded;
+ $this->assertGreaterThan(0, $pausedAt);
+ $this->assertLessThan(strlen($payload), $pausedAt);
+
+ // Allow the origin to progress while the caller holds its first byte.
+ usleep(600000);
+ $this->assertSame($pausedAt, $downloaded);
+ $this->assertSame($payload, $first . $body->getContents());
+ } finally {
+ $response->close();
+ }
+ }
+}
diff --git a/tests/Http/Fixtures/streaming-server.php b/tests/Http/Fixtures/streaming-server.php
index 0a9b77333..303496b6f 100644
--- a/tests/Http/Fixtures/streaming-server.php
+++ b/tests/Http/Fixtures/streaming-server.php
@@ -10,6 +10,7 @@
echo stream_socket_get_name($server, false), "\n";
fflush(STDOUT);
$connection = null;
+$start = null;
$release = null;
try {
@@ -20,21 +21,95 @@
stream_set_timeout($connection, 5);
while (($line = fgets($connection)) !== "\r\n" && $line !== false);
+ fwrite(STDOUT, "REQUEST\n");
- $first = $argv[1] === 'buffered' ? "{\"id\":1}\n" : '';
+ $scripted = match ($argv[1]) {
+ 'informational' => "HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nfinal",
+ 'trailers' => "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nTrailer: X-Checksum\r\n\r\n5\r\nhello\r\n0\r\nX-Checksum: abc\r\n\r\n",
+ default => null,
+ };
+
+ if ($scripted !== null) {
+ fwrite($connection, $scripted);
+
+ return;
+ }
+
+ if ($argv[1] === 'silent-headers') {
+ $start = stream_socket_accept($server, 5);
+ if ($start === false) {
+ throw new RuntimeException('The streaming test did not release the headers.');
+ }
+
+ stream_set_timeout($start, 5);
+ @fwrite($start, "ready\n");
+ fgets($start);
+
+ // Timeout and cancellation cases close the client before releasing us.
+ @fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
+
+ return;
+ }
+
+ if ($argv[1] === 'trickle-headers') {
+ fwrite($connection, "HTTP/1.1 200 OK\r\nX-Partial: ");
+
+ for ($index = 0; $index < 8; ++$index) {
+ usleep(100000);
+
+ // A header deadline may close the client before the line is complete.
+ if (@fwrite($connection, 'a') === false) {
+ return;
+ }
+ }
+
+ @fwrite($connection, "\r\nContent-Length: 0\r\n\r\n");
+
+ return;
+ }
+
+ $first = match ($argv[1]) {
+ 'buffered' => "{\"id\":1}\n",
+ 'truncated' => 'partial',
+ default => '',
+ };
$last = "{\"id\":2}\n";
- $length = strlen($first . $last);
- fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {$length}\r\nConnection: close\r\n\r\n" . $first);
+ $length = $argv[1] === 'truncated' ? 100 : strlen($first . $last);
+ $chunked = $argv[1] === 'chunked';
+ $framing = $chunked ? "Transfer-Encoding: chunked\r\n" : "Content-Length: {$length}\r\n";
+ fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\n{$framing}Connection: close\r\n\r\n" . $first);
fflush($connection);
+ if ($chunked) {
+ // Send body data only after the caller has received the response headers.
+ $start = stream_socket_accept($server, 5);
+ if ($start === false) {
+ throw new RuntimeException('The streaming test did not start the body.');
+ }
+
+ fwrite($connection, "9\r\n{\"id\":1}\n\r\n");
+ fflush($connection);
+ }
+
// A second connection releases the final record independently of the reader.
$release = stream_socket_accept($server, 5);
if ($release === false) {
throw new RuntimeException('The streaming test did not release the server.');
}
- fwrite($connection, $last);
+ stream_set_timeout($release, 5);
+ @fwrite($release, "ready\n");
+ fgets($release);
+
+ if ($argv[1] === 'truncated') {
+ return;
+ }
+
+ fwrite($connection, $chunked ? "9\r\n{$last}\r\n0\r\n\r\n" : $last);
} finally {
+ if (is_resource($start)) {
+ fclose($start);
+ }
if (is_resource($release)) {
fclose($release);
}
diff --git a/tests/Http/HttpClientDestinationPolicyTest.php b/tests/Http/HttpClientDestinationPolicyTest.php
index cd91d2d2d..b7b685e18 100644
--- a/tests/Http/HttpClientDestinationPolicyTest.php
+++ b/tests/Http/HttpClientDestinationPolicyTest.php
@@ -37,16 +37,19 @@ class HttpClientDestinationPolicyTest extends TestCase
*/
private array $connectionFailures = [];
- public function testPinsTheRequestToTheVettedAddresses(): void
+ #[DataProvider('streamModes')]
+ public function testPinsTheRequestToTheVettedAddresses(bool $stream): void
{
$server = LoopbackHttpServer::start();
$policy = $this->loopbackPolicy('destination.invalid');
$response = $this->factory()
->withDestinationPolicy($policy)
+ ->withOptions(['stream' => $stream])
->get("http://destination.invalid:{$server->port}/probe");
$this->assertSame('OK', $response->body());
+ $this->assertSame('127.0.0.1', $response->handlerStats()['primary_ip']);
$this->assertStringContainsString("Host: destination.invalid:{$server->port}\r\n", (string) $server->request());
$this->assertSame(["http://destination.invalid:{$server->port}/probe"], $policy->urls);
}
@@ -74,7 +77,8 @@ public function testPinsAHostWrittenWithATrailingDot(): void
$this->assertStringContainsString("Host: destination.invalid.:{$server->port}\r\n", (string) $server->request());
}
- public function testResolvesEveryRedirectAgain(): void
+ #[DataProvider('streamModes')]
+ public function testResolvesEveryRedirectAgain(bool $stream): void
{
$second = LoopbackHttpServer::start();
$first = LoopbackHttpServer::start([
@@ -84,6 +88,7 @@ public function testResolvesEveryRedirectAgain(): void
$response = $this->factory()
->withDestinationPolicy($policy)
+ ->withOptions(['stream' => $stream])
->get("http://first.invalid:{$first->port}/start");
$this->assertSame('OK', $response->body());
@@ -93,7 +98,8 @@ public function testResolvesEveryRedirectAgain(): void
], $policy->urls);
}
- public function testPinsAnApprovedProxyWithoutResolvingTheTarget(): void
+ #[DataProvider('streamModes')]
+ public function testPinsAnApprovedProxyWithoutResolvingTheTarget(bool $stream): void
{
$proxy = LoopbackHttpServer::start();
$policy = new FakeDestinationPolicy(
@@ -104,6 +110,7 @@ public function testPinsAnApprovedProxyWithoutResolvingTheTarget(): void
$response = $this->factory()
->withDestinationPolicy($policy)
+ ->withOptions(['stream' => $stream])
->get('http://target.invalid/probe');
$this->assertSame('OK', $response->body());
@@ -111,6 +118,14 @@ public function testPinsAnApprovedProxyWithoutResolvingTheTarget(): void
$this->assertSame(['proxy.invalid'], $policy->resolvedHosts);
}
+ /**
+ * Provide buffered and incremental response transports.
+ */
+ public static function streamModes(): array
+ {
+ return [[false], [true]];
+ }
+
/**
* @param Closure(PendingRequest): PendingRequest $configure
*/
@@ -136,9 +151,9 @@ public function testRejectsRequestsThePinsCannotCover(Closure $configure, string
*/
public static function unpinnableRequests(): iterable
{
- yield 'streamed response' => [
- static fn (PendingRequest $request): PendingRequest => $request->withOptions(['stream' => true]),
- 'Destination-restricted requests cannot stream responses; use [sink] instead.',
+ yield 'fallback streamed response' => [
+ static fn (PendingRequest $request): PendingRequest => $request->withOptions(['stream' => true, 'stream_context' => []]),
+ 'Destination-restricted requests cannot stream responses with the fallback transport; use [sink] instead.',
];
yield 'custom handler' => [
static fn (PendingRequest $request): PendingRequest => $request->setHandler(
@@ -241,7 +256,7 @@ public function testAnUnresolvableProxyIsARetriedProxyConnectionFailure(bool $as
}
#[DataProvider('refusingProxies')]
- public function testAProxyRefusingConnectionsIsARetriedProxyConnectionFailure(bool $async, bool $ipv6): void
+ public function testAProxyRefusingConnectionsIsARetriedProxyConnectionFailure(bool $async, bool $ipv6, bool $stream): void
{
[$refusing, $port] = $this->refusingPort($ipv6);
$errors = [];
@@ -252,6 +267,7 @@ public function testAProxyRefusingConnectionsIsARetriedProxyConnectionFailure(bo
);
$request = $this->factory()
->withDestinationPolicy($policy)
+ ->withOptions(['stream' => $stream])
->withOptions(['on_stats' => static function (TransferStats $stats) use (&$errors): void {
$errors[] = $stats->getHandlerErrorData();
}])
@@ -276,10 +292,14 @@ public function testAProxyRefusingConnectionsIsARetriedProxyConnectionFailure(bo
*/
public static function refusingProxies(): iterable
{
- yield 'synchronous hostname' => [false, false];
- yield 'asynchronous hostname' => [true, false];
- yield 'synchronous IPv6 literal' => [false, true];
- yield 'asynchronous IPv6 literal' => [true, true];
+ yield 'synchronous hostname' => [false, false, false];
+ yield 'asynchronous hostname' => [true, false, false];
+ yield 'synchronous IPv6 literal' => [false, true, false];
+ yield 'asynchronous IPv6 literal' => [true, true, false];
+ yield 'streamed synchronous hostname' => [false, false, true];
+ yield 'streamed asynchronous hostname' => [true, false, true];
+ yield 'streamed synchronous IPv6 literal' => [false, true, true];
+ yield 'streamed asynchronous IPv6 literal' => [true, true, true];
}
public function testARefusedDirectConnectionStaysAPlainConnectionException(): void
diff --git a/tests/Http/HttpClientStreamingTest.php b/tests/Http/HttpClientStreamingTest.php
index 848259a38..581e9c837 100644
--- a/tests/Http/HttpClientStreamingTest.php
+++ b/tests/Http/HttpClientStreamingTest.php
@@ -5,10 +5,17 @@
namespace Hypervel\Tests\Http;
use Closure;
+use GuzzleHttp\Exception\TransferException;
+use GuzzleHttp\Handler\StreamHandler;
+use Hypervel\Engine\Coroutine as EngineCoroutine;
+use Hypervel\Http\Client\ConnectionException;
+use Hypervel\Http\Client\CurlStreamingHandler;
use Hypervel\Http\Client\Factory;
use Hypervel\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
+use Psr\Http\Message\ResponseInterface;
use RuntimeException;
+use Swoole\Coroutine\CanceledException;
use Swoole\Coroutine\Channel;
use Symfony\Component\Process\Process;
use Throwable;
@@ -78,19 +85,26 @@ public function testStreamingReadsAllowOtherCoroutinesToProgress(): void
});
}
- public function testBufferedFirstRecordArrivesBeforeTheNextServerWrite(): void
+ #[DataProvider('streamingTransports')]
+ public function testBufferedFirstRecordArrivesBeforeTheNextServerWrite(bool $native, string $mode): void
{
- if (SWOOLE_VERSION_ID <= 60203) {
+ if (! $native && SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Swoole 6.2.3 and earlier lack the buffered-read fix: https://github.com/swoole/swoole-src/pull/6235.');
}
- $this->withStreamingServer('buffered', function (string $address): void {
+ $this->withStreamingServer($mode, function (string $address) use ($native, $mode): void {
$received = new Channel(1);
try {
$results = parallel([
- 'reader' => function () use ($address, $received): array {
- $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 3])->get('http://' . $address);
+ 'reader' => function () use ($address, $received, $native, $mode): array {
+ $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 3])
+ ->setHandler($native ? new CurlStreamingHandler : new StreamHandler)
+ ->get('http://' . $address);
try {
+ if ($mode === 'chunked') {
+ $this->releaseServer($address);
+ }
+
$lines = $response->jsonLines();
$first = $lines->current();
$received->push($first);
@@ -119,18 +133,332 @@ public function testBufferedFirstRecordArrivesBeforeTheNextServerWrite(): void
});
}
- public function testIdleStreamingReadTimeoutRaisesTheStreamReadError(): void
+ /**
+ * Provide the native streaming route and its PHP-stream fallback.
+ */
+ public static function streamingTransports(): array
+ {
+ return [
+ 'native cURL with buffered data' => [true, 'buffered'],
+ 'native cURL with later chunked data' => [true, 'chunked'],
+ 'PHP stream fallback' => [false, 'buffered'],
+ ];
+ }
+
+ public function testInformationalHeadersAreNotExposedAsTheFinalResponse(): void
+ {
+ $this->withStreamingServer('informational', function (string $address): void {
+ $statuses = [];
+ $response = (new Factory)->withOptions([
+ 'stream' => true,
+ 'on_headers' => static function (ResponseInterface $response) use (&$statuses): void {
+ $statuses[] = $response->getStatusCode();
+ },
+ ])->get('http://' . $address);
+
+ try {
+ $this->assertSame([200], $statuses);
+ $this->assertSame('final', $response->body());
+ } finally {
+ $response->close();
+ }
+ });
+ }
+
+ public function testTruncatedBodiesFailInsteadOfReportingSuccessfulEndOfStream(): void
+ {
+ $this->withStreamingServer('truncated', function (string $address): void {
+ $response = (new Factory)->withOptions(['stream' => true])->get('http://' . $address);
+
+ try {
+ $this->assertSame('partial', $response->toPsrResponse()->getBody()->read(8192));
+ $this->releaseServer($address);
+ $response->body();
+
+ $this->fail('Expected a truncated-response error.');
+ } catch (TransferException $exception) {
+ $this->assertStringContainsString('cURL error 18', $exception->getMessage());
+ $this->assertFalse($response->toPsrResponse()->getBody()->isReadable());
+ } finally {
+ $response->close();
+ }
+ });
+ }
+
+ public function testTrailersAreDeliveredOnceAfterConsumingTheBody(): void
+ {
+ $this->withStreamingServer('trailers', function (string $address): void {
+ $trailers = [];
+ $response = (new Factory)->withOptions([
+ 'stream' => true,
+ 'on_trailers' => static function (array $headers) use (&$trailers): void {
+ $trailers[] = $headers;
+ },
+ ])->get('http://' . $address);
+
+ try {
+ $this->assertSame([], $trailers);
+ $this->assertSame('hello', $response->body());
+ $this->assertSame([['x-checksum' => ['abc']]], $trailers);
+ $this->assertSame('', $response->body());
+ $this->assertCount(1, $trailers);
+ } finally {
+ $response->close();
+ }
+ });
+ }
+
+ public function testCancelingASilentBodyReadFinishesBeforeTheProviderContinues(): void
+ {
+ $this->withStreamingServer('delayed', function (string $address): void {
+ $ready = new Channel(1);
+ $finished = new Channel(1);
+
+ try {
+ $results = parallel([
+ 'reader' => function () use ($address, $ready, $finished): ?CanceledException {
+ $response = (new Factory)->withOptions(['stream' => true])->get('http://' . $address);
+
+ try {
+ $ready->push(EngineCoroutine::id());
+ $response->lines()->current();
+
+ return null;
+ } catch (CanceledException $exception) {
+ return $exception;
+ } finally {
+ $response->close();
+ $finished->push(true);
+ }
+ },
+ 'cancel' => function () use ($address, $ready, $finished): bool {
+ try {
+ $coroutine = $ready->pop(1);
+ $this->assertIsInt($coroutine);
+ // Channel delivery resumes us before the reader enters its I/O wait.
+ usleep(1000);
+ $this->assertTrue(EngineCoroutine::cancelById($coroutine));
+
+ return $finished->pop(1) === true;
+ } finally {
+ $this->releaseServer($address);
+ }
+ },
+ ]);
+
+ $this->assertInstanceOf(CanceledException::class, $results['reader']);
+ $this->assertTrue($results['cancel'], 'The reader waited for provider data after cancellation.');
+ } finally {
+ $ready->close();
+ $finished->close();
+ }
+ });
+ }
+
+ public function testAResponseCanBeConsumedAfterItsCreatingCoroutineEnds(): void
{
- if (SWOOLE_VERSION_ID <= 60203) {
+ $this->withStreamingServer('buffered', function (string $address): void {
+ [$response] = parallel([
+ static fn () => (new Factory)->withOptions(['stream' => true])->get('http://' . $address),
+ ]);
+
+ try {
+ $lines = $response->jsonLines();
+ $this->assertSame(['id' => 1], $lines->current());
+ $this->releaseServer($address);
+ $lines->next();
+ $this->assertSame(['id' => 2], $lines->current());
+ $lines->next();
+ $this->assertFalse($lines->valid());
+ } finally {
+ $response->close();
+ }
+ });
+ }
+
+ #[DataProvider('connectionRoutes')]
+ public function testNamedStreamsReuseConnectionsWithoutSharingCredentialsOrCookies(bool $proxy): void
+ {
+ $process = new Process([PHP_BINARY, dirname(__DIR__) . '/HttpServer/Fixtures/disconnect-server.php', '', 'process']);
+ $process->setTimeout(10);
+ $process->start();
+ $processId = $process->getPid();
+
+ try {
+ $deadline = microtime(true) + 3;
+
+ while (! str_contains($process->getOutput(), 'READY ') && $process->isRunning() && microtime(true) < $deadline) {
+ usleep(10000);
+ }
+
+ $this->assertSame(1, preg_match('/READY (\d+)/', $process->getOutput(), $matches), $process->getErrorOutput());
+ $endpoint = 'http://127.0.0.1:' . $matches[1];
+ $url = ($proxy ? 'http://provider.invalid' : $endpoint) . '/identity';
+ $failure = null;
+
+ run(function () use ($url, $endpoint, $proxy, &$failure): void {
+ try {
+ $factory = (new Factory)->registerConnection('provider');
+ $connections = [];
+
+ foreach (['first-account', 'second-account', 'first-account', null, 'second-account', 'third-account', 'third-account', 'first-account'] as $token) {
+ $request = $factory->connection('provider')->withOptions(['stream' => true]);
+
+ if ($proxy) {
+ $request->withOptions([
+ 'proxy' => $endpoint,
+ 'curl' => [
+ CURLOPT_HTTPPROXYTUNNEL => true,
+ CURLOPT_PROXYHEADER => $token === null ? [] : ['Proxy-Authorization: Bearer ' . $token],
+ ],
+ ]);
+ }
+
+ if ($token !== null) {
+ $request->withToken($token);
+ }
+
+ $response = $request->get($url);
+
+ try {
+ $identity = $response->json();
+ $connections[] = $identity['connection'];
+ $this->assertSame($token === null ? null : 'Bearer ' . $token, $identity['authorization']);
+ $this->assertSame($proxy && $token !== null ? 'Bearer ' . $token : null, $identity['proxy_authorization']);
+ $this->assertNull($identity['cookie']);
+ } finally {
+ $response->close();
+ }
+ }
+
+ $this->assertCount($proxy ? 5 : 1, array_unique($connections));
+ $this->assertSame($connections[0], $connections[2]);
+ $this->assertSame($connections[1], $connections[4]);
+ $this->assertSame($connections[5], $connections[6]);
+
+ if ($proxy) {
+ $this->assertNotSame($connections[0], $connections[7], 'The least recently used identity was not evicted.');
+ }
+ } catch (Throwable $exception) {
+ $failure = $exception;
+ }
+ }, SWOOLE_HOOK_ALL);
+
+ if ($failure !== null) {
+ throw $failure;
+ }
+ } finally {
+ posix_kill(-$processId, SIGKILL);
+ $process->stop(0);
+ }
+ }
+
+ /**
+ * Provide direct requests and connection-authenticated proxy tunnels.
+ */
+ public static function connectionRoutes(): array
+ {
+ return [[false], [true]];
+ }
+
+ public function testCancelingAHeaderWaitFinishesBeforeTheProviderResponds(): void
+ {
+ $this->withStreamingServer('silent-headers', function (string $address): void {
+ $started = new Channel(1);
+ $finished = new Channel(1);
+
+ try {
+ $results = parallel([
+ 'reader' => function () use ($address, $started, $finished): ?CanceledException {
+ $started->push(EngineCoroutine::id());
+
+ try {
+ (new Factory)->withOptions(['stream' => true])->get('http://' . $address);
+
+ return null;
+ } catch (CanceledException $exception) {
+ return $exception;
+ } finally {
+ $finished->push(true);
+ }
+ },
+ 'cancel' => function () use ($address, $started, $finished): void {
+ $coroutine = $started->pop(1);
+ $control = stream_socket_client('tcp://' . $address, $error, $message, 2);
+ $this->assertIsResource($control, $message);
+
+ try {
+ stream_set_timeout($control, 2);
+ $this->assertSame("ready\n", fgets($control));
+ $this->assertTrue(EngineCoroutine::cancelById($coroutine));
+ $this->assertTrue($finished->pop(1), 'The header wait continued until the provider responded.');
+ } finally {
+ fclose($control);
+ }
+ },
+ ]);
+
+ $this->assertInstanceOf(CanceledException::class, $results['reader']);
+ } finally {
+ $started->close();
+ $finished->close();
+ }
+ });
+ }
+
+ public function testSilentHeadersUseTheIdleTimeoutWhenTheTotalTimeoutIsDisabled(): void
+ {
+ $this->withStreamingServer('silent-headers', function (string $address): void {
+ try {
+ $this->expectException(ConnectionException::class);
+ $this->expectExceptionMessage('The streaming request timed out before response headers.');
+
+ (new Factory)->timeout(0)->withOptions(['stream' => true, 'read_timeout' => 0.2])->get('http://' . $address);
+ } finally {
+ $this->releaseServer($address);
+ }
+ });
+ }
+
+ public function testHeaderProgressResetsTheIdleTimeoutBeforeAHeaderLineIsComplete(): void
+ {
+ $this->withStreamingServer('trickle-headers', function (string $address): void {
+ $response = (new Factory)->timeout(0)->withOptions(['stream' => true, 'read_timeout' => 0.4])->get('http://' . $address);
+
+ try {
+ $this->assertSame(200, $response->status());
+ $this->assertSame('aaaaaaaa', $response->header('X-Partial'));
+ } finally {
+ $response->close();
+ }
+ });
+ }
+
+ public function testHeaderProgressDoesNotResetTheTotalHeaderDeadline(): void
+ {
+ $this->withStreamingServer('trickle-headers', function (string $address): void {
+ $this->expectException(ConnectionException::class);
+ $this->expectExceptionMessage('The streaming request timed out before response headers.');
+
+ (new Factory)->timeout(0.4)->withOptions(['stream' => true, 'read_timeout' => 0.4])->get('http://' . $address);
+ });
+ }
+
+ #[DataProvider('idleTimeoutTransports')]
+ public function testIdleStreamingReadTimeoutRaisesTheStreamReadError(bool $native): void
+ {
+ if (! $native && SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Swoole 6.2.3 and earlier lack the read-timeout fix: https://github.com/swoole/swoole-src/pull/6236.');
}
- $this->withStreamingServer('delayed', function (string $address): void {
+ $this->withStreamingServer('delayed', function (string $address) use ($native): void {
$finished = new Channel(1);
try {
$results = parallel([
- 'reader' => function () use ($address, $finished): ?RuntimeException {
- $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 1])->get('http://' . $address);
+ 'reader' => function () use ($address, $finished, $native): ?RuntimeException {
+ $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 1])
+ ->setHandler($native ? new CurlStreamingHandler : new StreamHandler)
+ ->get('http://' . $address);
try {
$response->lines()->current();
@@ -149,13 +477,21 @@ public function testIdleStreamingReadTimeoutRaisesTheStreamReadError(): void
]);
$this->assertInstanceOf(RuntimeException::class, $results['reader']);
- $this->assertSame('Unable to read from stream', $results['reader']->getMessage());
+ $this->assertSame($native ? 'The streaming response read timed out.' : 'Unable to read from stream', $results['reader']->getMessage());
} finally {
$finished->close();
}
});
}
+ /**
+ * Provide both transports for their respective read-timeout contracts.
+ */
+ public static function idleTimeoutTransports(): array
+ {
+ return [[true], [false]];
+ }
+
/**
* Run a hooked client against an independently controlled loopback server.
*/
@@ -166,8 +502,13 @@ protected function withStreamingServer(string $mode, Closure $callback): void
$process->start();
try {
- $ready = $process->waitUntil(fn () => str_contains($process->getOutput(), "\n"));
- $this->assertTrue($ready, $process->getErrorOutput());
+ $deadline = microtime(true) + 5;
+
+ while (! str_contains($process->getOutput(), "\n") && $process->isRunning() && microtime(true) < $deadline) {
+ usleep(10000);
+ }
+
+ $this->assertStringContainsString("\n", $process->getOutput(), $process->getErrorOutput());
$address = trim($process->getOutput());
$failure = null;
diff --git a/tests/Http/PackageMetadataTest.php b/tests/Http/PackageMetadataTest.php
index 58ce9f7a2..2e9e73979 100644
--- a/tests/Http/PackageMetadataTest.php
+++ b/tests/Http/PackageMetadataTest.php
@@ -35,6 +35,10 @@ public function testRuntimeAndOptionalDependenciesAreDeclared(): void
$this->assertSame($rootComposer['require'][$dependency], $composer['require'][$dependency]);
}
+ foreach (['hypervel/coroutine', 'hypervel/engine'] as $dependency) {
+ $this->assertArrayHasKey($dependency, $composer['require']);
+ }
+
$this->assertSame('*', $composer['require']['ext-filter']);
$this->assertSame(
'Required to use Hypervel\Http\Testing\FileFactory::image().',
diff --git a/tests/HttpServer/Fixtures/disconnect-server.php b/tests/HttpServer/Fixtures/disconnect-server.php
new file mode 100644
index 000000000..429fad04b
--- /dev/null
+++ b/tests/HttpServer/Fixtures/disconnect-server.php
@@ -0,0 +1,102 @@
+init(new ServerConfig([
+ 'mode' => $argv[2] === 'base' ? SWOOLE_BASE : SWOOLE_PROCESS,
+ 'settings' => [
+ 'worker_num' => 1,
+ 'reactor_num' => 1,
+ 'enable_coroutine' => true,
+ 'open_http2_protocol' => true,
+ 'hook_flags' => SWOOLE_HOOK_ALL,
+ 'log_level' => SWOOLE_LOG_WARNING,
+ ],
+ 'servers' => [
+ [
+ 'name' => 'http',
+ 'host' => '127.0.0.1',
+ 'port' => 0,
+ 'callbacks' => [
+ Event::ON_WORKER_START => static function (NativeServer $server): void {
+ fwrite(STDOUT, 'READY ' . $server->ports[0]->port . "\n");
+ },
+ Event::ON_REQUEST => static function (Request $request, Response $native) use ($provider, &$proxyAuthorizations): void {
+ if ($request->server['request_method'] === 'CONNECT') {
+ $proxyAuthorizations[$request->fd] = $request->header['proxy-authorization'] ?? null;
+ $native->end();
+
+ return;
+ }
+
+ if ($request->server['request_uri'] === '/health') {
+ $native->end('healthy');
+
+ return;
+ }
+
+ if ($request->server['request_uri'] === '/identity') {
+ $native->cookie('session', 'previous-request');
+ $native->end(json_encode([
+ 'connection' => $request->fd,
+ 'authorization' => $request->header['authorization'] ?? null,
+ 'proxy_authorization' => $proxyAuthorizations[$request->fd] ?? null,
+ 'cookie' => $request->header['cookie'] ?? null,
+ ], JSON_THROW_ON_ERROR));
+
+ return;
+ }
+
+ $response = (new IterableStreamedResponse((static function () use ($provider): iterable {
+ $upstream = null;
+
+ try {
+ $upstream = (new Factory)->withOptions(['stream' => true])->get('http://' . $provider);
+ fwrite(STDOUT, "UPSTREAM_HEADERS\n");
+
+ yield from $upstream->lines();
+ } finally {
+ $upstream?->close();
+ fwrite(STDOUT, "STOPPED\n");
+ }
+ })()))->cancelOnDisconnect();
+
+ ResponseBridge::send($response, $native, streamId: $request->streamId ?? 0);
+ },
+ Event::ON_CLOSE => static function (NativeServer $server, int $connection) use (&$proxyAuthorizations): void {
+ unset($proxyAuthorizations[$connection]);
+ fwrite(STDOUT, "APPLICATION_CLOSE\n");
+ },
+ ],
+ ],
+ ],
+]));
+$server->start();
diff --git a/tests/HttpServer/PackageMetadataTest.php b/tests/HttpServer/PackageMetadataTest.php
index 827dfde6b..7fae22ce9 100644
--- a/tests/HttpServer/PackageMetadataTest.php
+++ b/tests/HttpServer/PackageMetadataTest.php
@@ -30,6 +30,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void
'hypervel/coordinator',
'hypervel/coroutine',
'hypervel/http',
+ 'hypervel/server',
'symfony/http-foundation',
] as $dependency) {
$this->assertArrayHasKey($dependency, $composer['require']);
@@ -38,6 +39,5 @@ public function testDirectRuntimeDependenciesAreDeclared(): void
}
$this->assertArrayNotHasKey('hypervel/engine', $composer['require']);
- $this->assertArrayNotHasKey('hypervel/server', $composer['require']);
}
}
diff --git a/tests/HttpServer/ResponseBridgeTest.php b/tests/HttpServer/ResponseBridgeTest.php
index 06933ebda..9a1913535 100644
--- a/tests/HttpServer/ResponseBridgeTest.php
+++ b/tests/HttpServer/ResponseBridgeTest.php
@@ -11,6 +11,7 @@
use Hypervel\Http\Request;
use Hypervel\Http\Response as HypervelResponse;
use Hypervel\HttpServer\ResponseBridge;
+use Hypervel\Server\ResponseCancellation;
use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
use Mockery as m;
@@ -18,12 +19,16 @@
use ReflectionProperty;
use RuntimeException;
use SplTempFileObject;
+use Swoole\Coroutine\CanceledException;
+use Swoole\Coroutine\Channel;
use Swoole\Http\Response as SwooleResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
+use function Hypervel\Coroutine\parallel;
+
class ResponseBridgeTest extends TestCase
{
protected string $tempDirectory;
@@ -1232,6 +1237,131 @@ public function testStatusFailureThrowsBeforeHeaders(): void
ResponseBridge::send(new Response('body'), $swooleResponse);
}
+ #[DataProvider('disconnectCancellationOptions')]
+ public function testDisconnectCancelsAllActiveStreamsOnOnlyThatConnection(bool $cancel, array $streams): void
+ {
+ $ready = new Channel(3);
+ $continue = new Channel(3);
+ $closed = [];
+ $produce = function (int $connection, int $stream) use ($ready, $continue, $cancel, &$closed): bool {
+ $response = new IterableStreamedResponse((function () use ($connection, $stream, $ready, $continue, &$closed): iterable {
+ try {
+ $ready->push(true);
+ $this->assertTrue($continue->pop(2));
+ yield 'body';
+ } finally {
+ $closed[] = [$connection, $stream];
+ }
+ })());
+ $native = $this->mockSwooleResponse();
+ $native->fd = $connection;
+
+ if ($cancel) {
+ $response->cancelOnDisconnect();
+ $native->expects('isWritable')->andReturnTrue();
+ }
+
+ try {
+ ResponseBridge::send($response, $native, streamId: $stream);
+
+ return false;
+ } catch (CanceledException) {
+ return true;
+ }
+ };
+
+ try {
+ $results = parallel([
+ 'first' => fn (): bool => $produce(10, $streams[0]),
+ 'second' => fn (): bool => $produce(10, $streams[1]),
+ 'other' => fn (): bool => $produce(20, 1),
+ 'close' => function () use ($ready, $continue, $cancel): void {
+ for ($index = 0; $index < 3; ++$index) {
+ $this->assertTrue($ready->pop(1));
+ }
+
+ usleep(1000);
+ ResponseCancellation::cancel(10);
+
+ for ($index = 0; $index < ($cancel ? 1 : 3); ++$index) {
+ $continue->push(true);
+ }
+ },
+ ]);
+
+ $this->assertSame($cancel, $results['first']);
+ $this->assertSame($cancel, $results['second']);
+ $this->assertFalse($results['other']);
+ $this->assertEqualsCanonicalizing([[10, $streams[0]], [10, $streams[1]], [20, 1]], $closed);
+ } finally {
+ $ready->close();
+ $continue->close();
+ }
+ }
+
+ /**
+ * Provide default production and explicit disconnect cancellation.
+ */
+ public static function disconnectCancellationOptions(): array
+ {
+ return [
+ 'default opt-out' => [false, [1, 3]],
+ 'HTTP/2 streams' => [true, [1, 3]],
+ 'pipelined HTTP/1 responses' => [true, [0, 0]],
+ ];
+ }
+
+ public function testDisconnectAfterProductionDoesNotCancelLaterRequestWork(): void
+ {
+ $sent = new Channel(1);
+ $continue = new Channel(1);
+
+ try {
+ $results = parallel([
+ 'producer' => function () use ($sent, $continue): mixed {
+ $response = (new IterableStreamedResponse(['body']))->cancelOnDisconnect();
+ $native = $this->mockSwooleResponse();
+ $native->fd = 10;
+ $native->expects('isWritable')->andReturnTrue();
+ ResponseBridge::send($response, $native);
+ $sent->push(true);
+
+ return $continue->pop(2);
+ },
+ 'close' => function () use ($sent, $continue): void {
+ $this->assertTrue($sent->pop(1));
+ usleep(1000);
+ ResponseCancellation::cancel(10);
+ $continue->push(true);
+ },
+ ]);
+
+ $this->assertTrue($results['producer']);
+ } finally {
+ $sent->close();
+ $continue->close();
+ }
+ }
+
+ public function testAlreadyDisconnectedClientDoesNotStartAnOptedInProducer(): void
+ {
+ $started = false;
+ $response = (new IterableStreamedResponse((static function () use (&$started): iterable {
+ $started = true;
+ yield 'body';
+ })()))->cancelOnDisconnect();
+ $native = $this->mockSwooleResponse();
+ $native->expects('isWritable')->andReturnFalse();
+ $native->shouldNotReceive('write');
+
+ try {
+ ResponseBridge::send($response, $native);
+ $this->fail('Expected cancellation before producing the response.');
+ } catch (CanceledException) {
+ $this->assertFalse($started);
+ }
+ }
+
public function testHeaderFailureThrowsBeforeBody(): void
{
$swooleResponse = $this->mockSwooleResponse();
diff --git a/tests/HttpServer/ResponseCancellationTest.php b/tests/HttpServer/ResponseCancellationTest.php
new file mode 100644
index 000000000..db8f7ce23
--- /dev/null
+++ b/tests/HttpServer/ResponseCancellationTest.php
@@ -0,0 +1,225 @@
+setTimeout(10);
+ $server = null;
+ $serverProcessId = null;
+ $client = null;
+ $control = null;
+
+ try {
+ $provider->start();
+ $this->waitForOutput($provider, "\n");
+ $providerAddress = trim($provider->getOutput());
+ $server = new Process([PHP_BINARY, __DIR__ . '/Fixtures/disconnect-server.php', $providerAddress, $mode]);
+ $server->setTimeout(10);
+ $server->start();
+ $serverProcessId = $server->getPid();
+ $this->waitForOutput($server, 'READY ');
+ preg_match('/READY (\d+)/', $server->getOutput(), $matches);
+ $address = '127.0.0.1:' . $matches[1];
+
+ $client = stream_socket_client('tcp://' . $address, $error, $message, 2);
+ $this->assertIsResource($client, $message);
+ fwrite($client, "GET /stream HTTP/1.1\r\nHost: localhost\r\n\r\n");
+ $this->waitForOutput($provider, 'REQUEST');
+
+ $control = stream_socket_client('tcp://' . $providerAddress, $error, $message, 2);
+ $this->assertIsResource($control, $message);
+ stream_set_timeout($control, 2);
+ $this->assertSame("ready\n", fgets($control));
+
+ if ($phase === 'delayed') {
+ $this->waitForOutput($server, 'UPSTREAM_HEADERS');
+ }
+
+ fclose($client);
+ $client = null;
+ $this->waitForOutput($server, 'STOPPED');
+ $this->waitForOutput($server, 'APPLICATION_CLOSE');
+ $this->assertTrue($provider->isRunning(), 'Provider completion, rather than the disconnect, unblocked the producer.');
+
+ $health = stream_socket_client('tcp://' . $address, $error, $message, 2);
+ $this->assertIsResource($health, $message);
+
+ try {
+ stream_set_timeout($health, 2);
+ fwrite($health, "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
+ $this->assertStringContainsString('healthy', stream_get_contents($health));
+ } finally {
+ fclose($health);
+ }
+
+ $this->assertSame('', $server->getErrorOutput());
+ } finally {
+ if (is_resource($client)) {
+ fclose($client);
+ }
+
+ if (is_resource($control)) {
+ fclose($control);
+ }
+
+ if ($serverProcessId !== null) {
+ // The fixture owns its process group, including every Swoole child.
+ posix_kill(-$serverProcessId, SIGKILL);
+ }
+
+ $server?->stop(0);
+ $provider->stop(0);
+ }
+ }
+
+ /**
+ * Provide silent header and body waits under both supported server modes.
+ */
+ public static function silentProviderPhases(): array
+ {
+ return [
+ 'headers in process mode' => ['silent-headers', 'process'],
+ 'body in process mode' => ['delayed', 'process'],
+ 'headers in base mode' => ['silent-headers', 'base'],
+ 'body in base mode' => ['delayed', 'base'],
+ ];
+ }
+
+ public function testHttp2StreamResetLeavesSilentProductionUntilTheNextWriteAndPreservesSiblingStreams(): void
+ {
+ $provider = new Process([PHP_BINARY, dirname(__DIR__) . '/Http/Fixtures/streaming-server.php', 'delayed']);
+ $provider->setTimeout(10);
+ $server = null;
+ $serverProcessId = null;
+ $client = null;
+ $control = null;
+
+ try {
+ $provider->start();
+ $this->waitForOutput($provider, "\n");
+ $providerAddress = trim($provider->getOutput());
+ $server = new Process([PHP_BINARY, __DIR__ . '/Fixtures/disconnect-server.php', $providerAddress, 'process']);
+ $server->setTimeout(10);
+ $server->start();
+ $serverProcessId = $server->getPid();
+ $this->waitForOutput($server, 'READY ');
+ preg_match('/READY (\d+)/', $server->getOutput(), $matches);
+ $client = stream_socket_client('tcp://127.0.0.1:' . $matches[1], $error, $message, 2);
+ $this->assertIsResource($client, $message);
+ stream_set_timeout($client, 2);
+
+ // Static HPACK indexes encode GET and http; the path and authority are literals.
+ fwrite($client, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
+ . $this->http2Frame(4, 0, 0, '')
+ . $this->http2Frame(1, 5, 1, "\x82\x86\x04\x07/stream\x01\x09localhost"));
+ $this->waitForOutput($provider, 'REQUEST');
+ $this->waitForOutput($server, 'UPSTREAM_HEADERS');
+ $control = stream_socket_client('tcp://' . $providerAddress, $error, $message, 2);
+ $this->assertIsResource($control, $message);
+ stream_set_timeout($control, 2);
+ $this->assertSame("ready\n", fgets($control));
+
+ // RST_STREAM(CANCEL) affects stream 1; stream 3 proves the connection remains usable.
+ fwrite($client, $this->http2Frame(3, 0, 1, pack('N', 8))
+ . $this->http2Frame(1, 5, 3, "\x82\x86\x04\x07/health\x01\x09localhost"));
+ $this->assertSame('healthy', $this->http2Body($client, 3));
+ $this->assertStringNotContainsString('STOPPED', $server->getOutput());
+
+ fclose($control);
+ $control = null;
+ $this->waitForOutput($server, 'STOPPED');
+
+ fwrite($client, $this->http2Frame(1, 5, 5, "\x82\x86\x04\x07/health\x01\x09localhost"));
+ $this->assertSame('healthy', $this->http2Body($client, 5));
+ } finally {
+ if (is_resource($client)) {
+ fclose($client);
+ }
+
+ if (is_resource($control)) {
+ fclose($control);
+ }
+
+ if ($serverProcessId !== null) {
+ posix_kill(-$serverProcessId, SIGKILL);
+ }
+
+ $server?->stop(0);
+ $provider->stop(0);
+ }
+ }
+
+ /**
+ * Encode one frame for the reset behavior unavailable through Swoole's HTTP/2 client.
+ */
+ private function http2Frame(int $type, int $flags, int $stream, string $payload): string
+ {
+ return substr(pack('N', strlen($payload)), 1) . pack('CCN', $type, $flags, $stream) . $payload;
+ }
+
+ /**
+ * Read one response body, acknowledging the server's initial settings.
+ *
+ * @param resource $client
+ */
+ private function http2Body(mixed $client, int $stream): string
+ {
+ $body = '';
+
+ do {
+ $header = stream_get_contents($client, 9);
+ $this->assertSame(9, strlen($header), 'The HTTP/2 fixture stopped sending frames.');
+ $length = unpack('N', "\0" . substr($header, 0, 3))[1];
+ $type = ord($header[3]);
+ $flags = ord($header[4]);
+ $identity = unpack('N', substr($header, 5))[1];
+ $payload = $length === 0 ? '' : stream_get_contents($client, $length);
+ $this->assertSame($length, strlen($payload));
+
+ if ($type === 4 && ($flags & 1) === 0) {
+ fwrite($client, $this->http2Frame(4, 1, 0, ''));
+ }
+
+ if ($identity === $stream && $type === 0) {
+ $body .= $payload;
+ }
+ } while ($identity !== $stream || ($flags & 1) === 0 || ! in_array($type, [0, 1], true));
+
+ return $body;
+ }
+
+ /**
+ * Wait for a fixture boundary while retaining its diagnostics on failure.
+ */
+ private function waitForOutput(Process $process, string $line): void
+ {
+ $deadline = microtime(true) + 3;
+
+ do {
+ if (str_contains($process->getOutput(), $line)) {
+ return;
+ }
+
+ if (! $process->isRunning()) {
+ break;
+ }
+
+ usleep(10000);
+ } while (microtime(true) < $deadline);
+
+ $this->fail("The fixture did not reach [{$line}].\n" . $process->getOutput() . $process->getErrorOutput());
+ }
+}
diff --git a/tests/Routing/ResponseFactoryTest.php b/tests/Routing/ResponseFactoryTest.php
index d33c6703f..a47e95efe 100644
--- a/tests/Routing/ResponseFactoryTest.php
+++ b/tests/Routing/ResponseFactoryTest.php
@@ -5,6 +5,8 @@
namespace Hypervel\Tests\Routing;
use Generator;
+use Hypervel\Container\Container;
+use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Contracts\View\Factory as ViewFactory;
use Hypervel\Http\IterableStreamedResponse;
use Hypervel\Http\StreamedEvent;
@@ -12,6 +14,8 @@
use Hypervel\Routing\ResponseFactory;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\DataProvider;
+use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ResponseFactoryTest extends TestCase
@@ -143,6 +147,40 @@ static function () {
], $chunks);
}
+ #[DataProvider('streamingFactoryMethods')]
+ public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrWrapping(string $method): void
+ {
+ $failure = new CanceledException('response canceled');
+ $handler = m::mock(ExceptionHandler::class);
+ $handler->shouldNotReceive('report');
+ Container::getInstance()->instance(ExceptionHandler::class, $handler);
+ $callback = $method === 'eventStream'
+ ? static function () use ($failure): iterable {
+ yield 'first';
+ throw $failure;
+ }
+ : static fn () => throw $failure;
+ $response = $this->factory()->{$method}($callback);
+ ob_start();
+
+ try {
+ $response->sendContent();
+ $this->fail('Expected response production to propagate cancellation.');
+ } catch (CanceledException $exception) {
+ $this->assertSame($failure, $exception);
+ } finally {
+ ob_end_clean();
+ }
+ }
+
+ /**
+ * Provide response factories that handle producer exceptions.
+ */
+ public static function streamingFactoryMethods(): array
+ {
+ return [['eventStream'], ['streamDownload']];
+ }
+
private function factory(): ResponseFactory
{
return new ResponseFactory(
diff --git a/tests/Server/ServerTest.php b/tests/Server/ServerTest.php
index db2b4390e..bea4768b6 100644
--- a/tests/Server/ServerTest.php
+++ b/tests/Server/ServerTest.php
@@ -22,6 +22,7 @@
use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
@@ -249,16 +250,86 @@ public function testPortEventRegistrationFailureStopsConfiguration(): void
{
$nativePort = m::mock(SwoolePort::class);
$nativePort->expects('on')->with(Event::ON_REQUEST, m::type('callable'))->andReturnFalse();
+ $server = $this->server(m::mock(Container::class));
+ $server->useNativeServer(m::mock(SwooleServer::class));
$this->expectException(ServerException::class);
$this->expectExceptionMessageIs('Failed to register event [request] on server [test].');
- $this->server(m::mock(Container::class))->registerEvents($nativePort, [
+ $server->registerEvents($nativePort, [
Event::ON_REQUEST => static function (SwooleRequest $request, SwooleResponse $response): void {
},
]);
}
+ #[DataProvider('responseCancellationModes')]
+ public function testDisconnectHandlingPreservesApplicationCallbacksAndRespectsServerModes(int $mode, array $settings, bool $supported): void
+ {
+ $registered = [];
+ $received = [];
+ $applicationCallback = static function (SwooleServer $server, int $connection, int $reactor) use (&$received): void {
+ $received = [$server, $connection, $reactor];
+ };
+ $nativeServer = m::mock(SwooleServer::class);
+ $nativeServer->mode = $mode;
+ $nativeServer->setting = $settings;
+ $nativeServer->expects('on')->twice()->andReturnUsing(
+ static function (string $event, callable $callback) use (&$registered): bool {
+ $registered[$event] = $callback;
+
+ return true;
+ },
+ );
+
+ $this->server(m::mock(Container::class))->registerEvents($nativeServer, [
+ Event::ON_REQUEST => static function (): void {
+ },
+ Event::ON_CLOSE => $applicationCallback,
+ ]);
+
+ $this->assertSame($supported, $registered[Event::ON_CLOSE] !== $applicationCallback);
+ $registered[Event::ON_CLOSE]($nativeServer, 123, 2);
+ $this->assertSame([$nativeServer, 123, 2], $received);
+ }
+
+ /**
+ * Provide modes with and without connection ownership in the serving worker.
+ */
+ public static function responseCancellationModes(): array
+ {
+ return [
+ 'base mode' => [SWOOLE_BASE, [], true],
+ 'default process mode' => [SWOOLE_PROCESS, [], true],
+ 'IP dispatch' => [SWOOLE_PROCESS, ['dispatch_mode' => 4], true],
+ 'connection balancing' => [SWOOLE_PROCESS, ['dispatch_mode' => 8], true],
+ 'round robin' => [SWOOLE_PROCESS, ['dispatch_mode' => 1], false],
+ 'queue dispatch' => [SWOOLE_PROCESS, ['dispatch_mode' => 3], false],
+ 'coroutines disabled' => [SWOOLE_PROCESS, ['enable_coroutine' => false], false],
+ ];
+ }
+
+ public function testResponseDisconnectCallbackIsInstalledWithoutAnApplicationCallback(): void
+ {
+ $registered = [];
+ $nativeServer = m::mock(SwooleServer::class);
+ $nativeServer->mode = SWOOLE_PROCESS;
+ $nativeServer->expects('on')->twice()->andReturnUsing(
+ static function (string $event, callable $callback) use (&$registered): bool {
+ $registered[$event] = $callback;
+
+ return true;
+ },
+ );
+
+ $this->server(m::mock(Container::class))->registerEvents($nativeServer, [
+ Event::ON_REQUEST => static function (): void {
+ },
+ ]);
+
+ $this->assertArrayHasKey(Event::ON_CLOSE, $registered);
+ $registered[Event::ON_CLOSE]($nativeServer, 123, 2);
+ }
+
public function testMainServerSettingsFailureStopsConfiguration(): void
{
$nativeServer = m::mock(SwooleServer::class);
diff --git a/types/Routing/ResponseFactory.php b/types/Routing/ResponseFactory.php
new file mode 100644
index 000000000..9ff5c38a7
--- /dev/null
+++ b/types/Routing/ResponseFactory.php
@@ -0,0 +1,22 @@
+eventStream($events)->cancelOnDisconnect());
+ assertType(IterableStreamedResponse::class, $contract->eventStream($events)->cancelOnDisconnect());
+ assertType(IterableStreamedResponse::class, response()->eventStream($events)->cancelOnDisconnect());
+ assertType(IterableStreamedResponse::class, Response::eventStream($events)->cancelOnDisconnect());
+}
From 4d0f4c471266644d4b240d1592a83154e42e71d7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:42:14 +0000
Subject: [PATCH 08/22] Record the finalized HTTP transport ownership and
cancellation design
Specify pull-driven streaming, bounded exact-signature idle reuse, timeout
phases, Guzzle compatibility and transfer cleanup in the active port plan.
Keep disconnect registration scoped to response production and distinguish
whole-connection cancellation from the missing native HTTP/2 reset event.
Record the verified stable-runtime limitation accurately and retain the
pre-PR performance acceptance requirements. The framework changes remain
independent of the later AI package implementation.
---
docs/plans/2026-10-07-ai-sdk-port.md | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/docs/plans/2026-10-07-ai-sdk-port.md b/docs/plans/2026-10-07-ai-sdk-port.md
index 9104896de..5a55cf8fb 100644
--- a/docs/plans/2026-10-07-ai-sdk-port.md
+++ b/docs/plans/2026-10-07-ai-sdk-port.md
@@ -80,16 +80,20 @@ In `tests/Benchmarks/Database`, compare ordinary request database lifecycles aga
### Incremental provider responses
-The current Guzzle StreamHandler path batches larger reads; simply replacing the SDK's byte reader with `fgets()`, `stream_get_line()` or `read(8192)` delays events. Fix the owning HTTP transport first, then use `Response::lines()`/`jsonLines()` from AI parsers.
+The PHP stream path has two delivery problems: Swoole's buffered-read defect (fixed upstream in #6235 but absent from stable 6.2.x), and blocking chunked-response filtering when body data arrives after headers, reproduced with Swoole hooks disabled. Its default `Connection: close` also prevents keep-alive reuse. Add an incremental cURL route in the owning HTTP transport, then use `Response::lines()`/`jsonLines()` from AI parsers. Retain fallback StreamHandler tests and their applicable runtime skips.
-Implement incremental streaming in this worktree's existing HTTP client, with a PSR response body that returns available chunks as they arrive. Integrate at `PendingRequest::buildHandlerStack()` and the factory's existing named-connection handler boundary, preserving Guzzle middleware and caller-supplied handlers/clients. Use framework Coroutine/ObjectPool primitives where ownership requires them; AI gateways use `Http`, never package-owned cURL/socket implementations. Validate the streaming implementation with equivalent verified-TLS measurements and lifecycle tests. Pending transfers and promises remain operation-owned rather than shared across coroutines.
+Implement a pull-driven PSR response body using Guzzle's CurlFactory and one exclusively owned multi handle per active transfer. Pause receives after accepting bounded data; resume when the caller consumes it, without selecting while buffered bytes remain. This needs no producer coroutine and keeps callbacks in the calling execution. Integrate at `PendingRequest::buildHandlerStack()` and the factory's named-handler boundary, preserving middleware and caller-supplied handlers/clients. AI gateways use `Http`, never package-owned cURL/socket implementations. Preserve compatible fallback behavior for stream-only options or runtimes the new route cannot serve.
+
+Named handlers retain at most three idle transports, each with `CURLMOPT_MAXCONNECTS = 1`, without capping active requests or adding admission configuration. Reuse exact proxy-ownership signatures and evict the least recently used idle transport when full. Unnamed and asynchronous streams use operation-owned transports without worker caching. Honor configured transport-sharing modes and Guzzle's proxy-tunnel ownership signatures when reusing native connection caches; never retain per-request credentials or callbacks on idle handles. Validate equivalent verified-TLS performance and new-connection counts under bursty arrivals before the PR.
+
+Support both declared Guzzle branches through their common factory/handle interfaces. Expose a final response after headers, ignoring informational responses except 101. When final cURL options enable origin/proxy authentication negotiation, wait for the first body write or transfer completion: intermediate 401 and even 2xx headers can precede an internally retried POST. Keep the header deadline through this decision. The retry callable passed to `CurlFactory::finish()` may restart only before exposure; never replay a published response. Invoke streaming statistics once at handler return, preserving callbacks in the caller's execution.
Required behavior:
-- Bounded admission and buffering; a slow consumer applies backpressure without unbounded queued events. Deadlines account for pool/admission waits as well as network I/O.
+- Bound buffering and idle retention; a slow consumer applies backpressure without unbounded queued events. Preserve streaming timeout semantics: `timeout` bounds the header phase, `connect_timeout` governs connection establishment, and `read_timeout` bounds idle gaps during headers and body reads, resetting on native readiness. The cURL route uses one 60-second idle default; explicit zero disables that limit. Document the older Guzzle fallback's context-timeout behavior. Do not cut long streams off using a transfer-wide deadline inherited from buffered requests.
- Per-request headers, credentials, cookies, body and middleware remain operation-owned. Reused physical transports must not retain request secrets/callbacks; dynamic origins must not create an ever-growing worker registry.
- Correct headers/informational responses, redirects, decompression, TLS verification, timeouts, middleware/fakes, stats and errors. Preserve supported options through the streaming handler or a compatible fallback; do not silently ignore them or reject existing usage without approval. Update transport capability guards, including the current `allow_url_fopen` check, to reflect the actual handler requirements.
-- EOF, explicit close, exceptions, cancellation and abandoned consumption settle resources once. If an owned producer exists, closing must actively stop and join it even when upstream is silent. Preserve pre-header errors and truncated-body errors instead of translating them to successful EOF.
+- EOF, explicit close, exceptions, cancellation and abandoned consumption settle resources once. Sequential consumption may move to another coroutine after the creator ends. A disconnect cancels the coroutine currently blocked on provider I/O; its cleanup removes and settles the transfer. Do not assume removing a handle from another coroutine wakes a blocked read. Preserve pre-header errors and truncated-body errors instead of translating them to successful EOF.
- Avoid redundant SDK-level `finally { close(); }` boilerplate where the owning response/generator chain already closes correctly. Test ownership before adding a close path.
Use a stable named HTTP identity per configured provider so TLS connections can be reused. Do not derive names from tenant secrets, trace IDs or arbitrary per-request configurations. Dynamic provider credentials travel in request options, not a cached PendingRequest.
@@ -98,7 +102,7 @@ Use a stable named HTTP identity per configured provider so TLS connections can
Add `IterableStreamedResponse::cancelOnDisconnect(): static` as response-level opt-in. Default AI SSE and both stream protocols enable it. Scope registration to response production in `ResponseBridge`; remove it before terminable work. Do not cancel unrelated controller side effects, background jobs or every request globally.
-Where existing Swoole connection-close delivery is reliable, compose internal handling with the configured application callback at `Server::registerSwooleEvents()`. Preserve the application callback even if internal cleanup encounters an error. Register only in supported effective modes, avoiding Swoole startup warnings. Index active responses by connection identity and HTTP/2 stream ID, retaining per-response ownership tokens so cleanup removes only its own registration. HTTP/1 uses its connection entry without an HTTP/2 stream. Full connection close cancels every opted-in owner on that connection. Keep the map bounded by active responses; a future server-level stream-cancel event can select one stream through the same map.
+Where existing Swoole connection-close delivery is reliable, compose internal handling with the configured application callback at `Server::registerSwooleEvents()`. Preserve the application callback even if internal cleanup encounters an error. Register only in supported effective modes, avoiding Swoole startup warnings. Group active registrations by connection and key them by producing coroutine, retaining the stream ID on each registration. This preserves concurrent pipelined HTTP/1 producers sharing stream ID zero. Cleanup removes only its own registration; full connection close cancels every opted-in owner on that connection. Keep the map bounded by active responses and retain stream identity for the future server-level stream-cancel event.
Current Swoole `RST_STREAM` handling has no PHP notification. A reset of one HTTP/2 stream while the provider is silent therefore remains detectable only by a subsequent failed write or operation completion/timeout. Document and test existing-runtime behavior honestly. The missing native capability is a server-level stream-cancel event identifying the connection and stream; a per-response native subscription is unnecessary. Keep this enhancement in `docs/todo.md` and integrate the actual released API when available; no per-stream polling, synthetic heartbeat solely for detection or dependency on an unaccepted/unreleased API.
@@ -106,7 +110,7 @@ Current Swoole `RST_STREAM` handling has no PHP notification. A reset of one HTT
Extend the existing `HttpClientStreamingTest`, `HttpClientResponseStreamTest` and related client/server suites using isolated loopback origins: paced SSE, split CRLF/UTF-8 lines, JSON lines, trailers, 1xx responses, truncated body, failures before headers, quiet upstream, slow consumer, explicit close and abandoned iteration. Assert cleanup and sibling-request progress under cancellation. Cover ordinary application close callbacks, opt-in boundaries and supported server modes; HTTP/2 reset tests must reflect the documented current limit. Reuse and extend this worktree's streaming fixtures rather than introducing duplicate test servers.
-Explicitly prove that cancelling a response producer interrupts the new streaming transport while it is waiting on a silent provider, both before headers and during a quiet response body. Synchronize the fixture so cancellation happens during the wait, without depending on a later provider chunk or timeout to unblock it. Assert that owned child work finishes, the upstream transfer is closed and its lease is settled, while sibling requests remain usable. Exercise the supported connection-close path through ResponseBridge as well as direct transport cancellation; merely observing a cancellation flag or dropping a generator is insufficient.
+Explicitly prove that cancelling response production interrupts the new transport while waiting on a silent provider, both before headers and during a quiet body. Synchronize the fixture without depending on a later provider chunk or timeout to unblock it. Assert that the transfer closes and settles while sibling requests remain usable. Cover creator-to-consumer coroutine handoff, bounded backpressure, idle reuse, proxy credential isolation, digest POST negotiation, and body reads continuing past the header timeout. Exercise connection-close cancellation through ResponseBridge as well as direct transport cancellation; merely observing a flag or dropping a generator is insufficient.
## 3. AI lifetimes, providers and deferred execution
From b365971d80719e4365cdca51b60b1fe6827a7632 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:15:45 +0000
Subject: [PATCH 09/22] Expose file and directory existence checks on the
filesystem contract
Declare fileExists() and directoryExists() on the shared contract while preserving the file-or-directory behavior of exists(). Existing adapters and pooled/scoped implementations already provide both operations; update the in-memory fixture and remove the obsolete read-through analysis suppression.
Move Sentry instrumentation to the base filesystem decorator so plain and cloud contract implementations receive the same tracing as adapters. Regenerate the Storage facade annotations and document the required methods for custom implementations.
Validation: filesystem cache fixtures, Sentry storage integration and facade tests pass; formatting, full PHPStan and the full framework suite pass.
---
src/contracts/src/Filesystem/Filesystem.php | 12 ++++++-
src/docs/filesystem.md | 6 ++--
src/docs/porting-from-laravel.md | 2 ++
src/filesystem/README.md | 2 ++
src/filesystem/src/ReadThroughFilesystem.php | 2 +-
.../Storage/FilesystemAdapterDecorator.php | 16 ---------
.../Features/Storage/FilesystemDecorator.php | 18 +++++++++-
src/support/src/Facades/Storage.php | 4 +--
tests/Cache/Fixtures/ArrayFilesystem.php | 18 +++++++++-
.../Features/StorageIntegrationTest.php | 35 +++++++++++++++++++
10 files changed, 91 insertions(+), 24 deletions(-)
diff --git a/src/contracts/src/Filesystem/Filesystem.php b/src/contracts/src/Filesystem/Filesystem.php
index 0e9db947a..28bd6378b 100644
--- a/src/contracts/src/Filesystem/Filesystem.php
+++ b/src/contracts/src/Filesystem/Filesystem.php
@@ -27,10 +27,20 @@ interface Filesystem
public function path(string $path): string;
/**
- * Determine if a file exists.
+ * Determine if a file or directory exists.
*/
public function exists(string $path): bool;
+ /**
+ * Determine if a file exists.
+ */
+ public function fileExists(string $path): bool;
+
+ /**
+ * Determine if a directory exists.
+ */
+ public function directoryExists(string $path): bool;
+
/**
* Get the contents of a file.
*/
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index e3082f930..26265654c 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -453,7 +453,7 @@ If the file you are retrieving contains JSON, you may use the `json` method to r
$orders = Storage::json('orders.json');
```
-The `exists` method may be used to determine if a file exists on the disk:
+The `exists` method may be used to determine if a file or directory exists on the disk:
```php
if (Storage::disk('s3')->exists('file.jpg')) {
@@ -461,7 +461,7 @@ if (Storage::disk('s3')->exists('file.jpg')) {
}
```
-The `missing` method may be used to determine if a file is missing from the disk:
+The `missing` method may be used to determine if a file or directory is missing from the disk:
```php
if (Storage::disk('s3')->missing('file.jpg')) {
@@ -481,6 +481,8 @@ if (Storage::disk('s3')->directoryMissing('photos')) {
}
```
+The `Hypervel\Contracts\Filesystem\Filesystem` contract includes `exists`, `fileExists`, and `directoryExists`, so these checks are available when a disk is injected through the contract. Custom implementations must provide all three methods.
+
### Images
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index c59ca2d95..fcf5ceca8 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -634,6 +634,8 @@ Hypervel's `Str::orderedUuid()` returns a UUIDv7, while Laravel returns a timest
Hypervel's `Filesystem::hash()` method uses `xxh128` by default. Pass `md5` explicitly when a port requires Laravel-compatible digests.
+Custom filesystem contract implementations must also provide `fileExists()` and `directoryExists()`. The existing `exists()` method continues to accept either a file or a directory. See [retrieving files](/docs/{{version}}/filesystem#retrieving-files).
+
Unlike Laravel, Hypervel honors `read-only` on scoped disk records. Remove that option from any scoped disk that must accept writes.
Rename any configured disk called `ondemand`; Hypervel reserves that name for [on-demand disk fakes](/docs/{{version}}/filesystem#on-demand-disks).
diff --git a/src/filesystem/README.md b/src/filesystem/README.md
index 41110d010..7b32cd144 100644
--- a/src/filesystem/README.md
+++ b/src/filesystem/README.md
@@ -11,6 +11,8 @@ Hypervel omits Laravel's legacy `Storage::cloud()` / `filesystem.cloud` default-
Hypervel's `Filesystem::hash()` method uses `xxh128` by default instead of `md5`. Pass `md5` explicitly when Laravel-compatible digests are required.
+Custom implementations of `Hypervel\Contracts\Filesystem\Filesystem` must provide `fileExists()` and `directoryExists()` as well as `exists()`, which accepts either kind of path.
+
The configured disk name `ondemand` is reserved. Disks explicitly set under that name, including fakes, intercept anonymous `build()` calls; configured disks with that name are rejected on resolution.
Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk adapters. Disks with equivalent client construction config share the expensive client pool while retaining their own bucket, root, visibility, and callback behavior. Pooled disks expose raw internals only through borrow-scoped `withClient()`, `withDriver()`, and `withAdapter()` callbacks.
diff --git a/src/filesystem/src/ReadThroughFilesystem.php b/src/filesystem/src/ReadThroughFilesystem.php
index f9d08ae51..1ccf6cc97 100644
--- a/src/filesystem/src/ReadThroughFilesystem.php
+++ b/src/filesystem/src/ReadThroughFilesystem.php
@@ -122,7 +122,7 @@ public function readStreamRange(string $path, ?int $start, ?int $end): mixed
*/
protected function readerFor(string $path): Cloud
{
- return $this->primary->fileExists($this->readThroughPrefixer->prefixPath($path)) // @phpstan-ignore method.notFound
+ return $this->primary->fileExists($this->readThroughPrefixer->prefixPath($path))
? $this->primary
: $this->fallback;
}
diff --git a/src/sentry/src/Features/Storage/FilesystemAdapterDecorator.php b/src/sentry/src/Features/Storage/FilesystemAdapterDecorator.php
index 8984697e5..75359f7be 100644
--- a/src/sentry/src/Features/Storage/FilesystemAdapterDecorator.php
+++ b/src/sentry/src/Features/Storage/FilesystemAdapterDecorator.php
@@ -48,22 +48,6 @@ public function assertDirectoryEmpty(string $path): static
return $this;
}
- /**
- * Determine if a file exists.
- */
- public function fileExists(string $path): bool
- {
- return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path'));
- }
-
- /**
- * Determine if a directory exists.
- */
- public function directoryExists(string $path): bool
- {
- return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path'));
- }
-
/**
* Get the checksum for a file.
*/
diff --git a/src/sentry/src/Features/Storage/FilesystemDecorator.php b/src/sentry/src/Features/Storage/FilesystemDecorator.php
index 34ae43657..9c15d8a9f 100644
--- a/src/sentry/src/Features/Storage/FilesystemDecorator.php
+++ b/src/sentry/src/Features/Storage/FilesystemDecorator.php
@@ -83,13 +83,29 @@ public function path(string $path): string
}
/**
- * Determine if a file exists.
+ * Determine if a file or directory exists.
*/
public function exists(string $path): bool
{
return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path'));
}
+ /**
+ * Determine if a file exists.
+ */
+ public function fileExists(string $path): bool
+ {
+ return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path'));
+ }
+
+ /**
+ * Determine if a directory exists.
+ */
+ public function directoryExists(string $path): bool
+ {
+ return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path'));
+ }
+
/**
* Get the contents of a file.
*/
diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php
index 39f9371ca..38d347645 100644
--- a/src/support/src/Facades/Storage.php
+++ b/src/support/src/Facades/Storage.php
@@ -44,7 +44,9 @@
* @method static bool delete(array|string $paths)
* @method static bool deleteDirectory(string $directory)
* @method static array directories(string|null $directory = null, bool $recursive = false)
+ * @method static bool directoryExists(string $path)
* @method static bool exists(string $path)
+ * @method static bool fileExists(string $path)
* @method static array files(string|null $directory = null, bool $recursive = false)
* @method static string|null get(string $path)
* @method static string getVisibility(string $path)
@@ -70,10 +72,8 @@
* @method static void buildTemporaryUrlsUsing(\Closure|null $callback)
* @method static string|false checksum(string $path, array $options = [])
* @method static bool copyToDisk(\Hypervel\Contracts\Filesystem\Filesystem|UnitEnum|string $disk, string $from, string|null $to = null)
- * @method static bool directoryExists(string $path)
* @method static bool directoryMissing(string $path)
* @method static \Symfony\Component\HttpFoundation\StreamedResponse download(string $path, string|null $name = null, array $headers = [])
- * @method static bool fileExists(string $path)
* @method static bool fileMissing(string $path)
* @method static array getConfig()
* @method static \Hypervel\Image\Image image(string $path)
diff --git a/tests/Cache/Fixtures/ArrayFilesystem.php b/tests/Cache/Fixtures/ArrayFilesystem.php
index cf377b303..744353003 100644
--- a/tests/Cache/Fixtures/ArrayFilesystem.php
+++ b/tests/Cache/Fixtures/ArrayFilesystem.php
@@ -23,7 +23,23 @@ public function path(string $path): string
public function exists(string $path): bool
{
- return array_key_exists($path, $this->files) || $this->files($path) !== [];
+ return $this->fileExists($path) || $this->directoryExists($path);
+ }
+
+ /**
+ * Determine if a file exists.
+ */
+ public function fileExists(string $path): bool
+ {
+ return array_key_exists($path, $this->files);
+ }
+
+ /**
+ * Determine if a directory exists.
+ */
+ public function directoryExists(string $path): bool
+ {
+ return $this->files($path) !== [];
}
public function get(string $path): ?string
diff --git a/tests/Sentry/Features/StorageIntegrationTest.php b/tests/Sentry/Features/StorageIntegrationTest.php
index d6c2da6ca..c0618daef 100644
--- a/tests/Sentry/Features/StorageIntegrationTest.php
+++ b/tests/Sentry/Features/StorageIntegrationTest.php
@@ -218,6 +218,41 @@ public function testAdapterSpecificOperationsAndFluentAssertionsRemainInstrument
$disk->deleteDirectory('empty');
}
+ public function testPlainFilesystemExistenceChecksRemainInstrumented(): void
+ {
+ $root = ParallelTesting::tempDir('SentryFilesystemExistence');
+ $files = new Filesystem;
+ $files->deleteDirectory($root);
+ $files->ensureDirectoryExists($root);
+
+ try {
+ $adapter = new LocalFilesystemAdapter($root);
+ $filesystem = new FilesystemAdapter(new Flysystem($adapter), $adapter);
+ $filesystem->put('file.txt', 'contents');
+ $filesystem->makeDirectory('empty');
+ $disk = new SentryFilesystem($filesystem, ['disk' => 'plain'], true, false);
+ $transaction = $this->startTransaction();
+
+ $this->assertTrue($disk->fileExists('file.txt'));
+ $this->assertFalse($disk->fileExists('empty'));
+ $this->assertTrue($disk->directoryExists('empty'));
+ $this->assertFalse($disk->directoryExists('file.txt'));
+ $this->assertTrue($disk->exists('empty'));
+
+ $spans = array_slice($transaction->getSpanRecorder()->getSpans(), 1);
+ $this->assertSame([
+ 'file.fileExists',
+ 'file.fileExists',
+ 'file.directoryExists',
+ 'file.directoryExists',
+ 'file.exists',
+ ], array_map(static fn ($span): ?string => $span->getOp(), $spans));
+ $this->assertSame(['path' => 'file.txt', 'disk' => 'plain'], $spans[0]->getData());
+ } finally {
+ $files->deleteDirectory($root);
+ }
+ }
+
public function testTemporaryUrlCapabilitiesAndCallbacksDelegateToWrappedAdapter(): void
{
$disks = config('filesystems.disks');
From d4933ef4c1d95ecb2b4f788321bacfd7a51ce94a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:16:00 +0000
Subject: [PATCH 10/22] Expose named property schemas from object types
Add ObjectType::getProperties() so schema consumers can inspect the original Type objects without serializing them or bypassing protected state. Preserve property names, object identity and empty-object behavior.
Correct property-array annotations across the factory, contract and static API to include integer keys produced by PHP numeric-string normalization. Document the accessor without changing schema serialization.
Validation: focused object-schema tests, formatting, full PHPStan and the full framework suite pass.
---
src/contracts/src/JsonSchema/JsonSchema.php | 2 +-
src/docs/json-schema.md | 6 ++++++
src/json-schema/src/JsonSchema.php | 2 +-
src/json-schema/src/JsonSchemaTypeFactory.php | 2 +-
src/json-schema/src/Types/ObjectType.php | 14 ++++++++++++--
tests/JsonSchema/ObjectTypeTest.php | 11 +++++++++++
6 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/src/contracts/src/JsonSchema/JsonSchema.php b/src/contracts/src/JsonSchema/JsonSchema.php
index 79749476e..6eed31fb4 100644
--- a/src/contracts/src/JsonSchema/JsonSchema.php
+++ b/src/contracts/src/JsonSchema/JsonSchema.php
@@ -20,7 +20,7 @@ interface JsonSchema
/**
* Create a new object schema instance.
*
- * @param array|(Closure(JsonSchema): array) $properties
+ * @param array|(Closure(JsonSchema): array) $properties
*/
public function object(Closure|array $properties = []): ObjectType;
diff --git a/src/docs/json-schema.md b/src/docs/json-schema.md
index aee7d3b22..61e6e8954 100644
--- a/src/docs/json-schema.md
+++ b/src/docs/json-schema.md
@@ -79,6 +79,12 @@ $schema = JsonSchema::object([
])->withoutAdditionalProperties();
```
+To inspect or reuse an object's property schemas, call `getProperties`. This returns the named `Type` instances rather than their serialized JSON Schema arrays:
+
+```php
+$properties = $schema->getProperties();
+```
+
### Array Schemas
diff --git a/src/json-schema/src/JsonSchema.php b/src/json-schema/src/JsonSchema.php
index 30775c568..dbc9f6329 100644
--- a/src/json-schema/src/JsonSchema.php
+++ b/src/json-schema/src/JsonSchema.php
@@ -17,7 +17,7 @@
use InvalidArgumentException;
/**
- * @method static ObjectType object(Closure|array $properties = [])
+ * @method static ObjectType object(Closure|array $properties = [])
* @method static AnyOfType anyOf(Closure|array $schemas)
* @method static IntegerType integer()
* @method static NumberType number()
diff --git a/src/json-schema/src/JsonSchemaTypeFactory.php b/src/json-schema/src/JsonSchemaTypeFactory.php
index 1894b0397..b076303cc 100644
--- a/src/json-schema/src/JsonSchemaTypeFactory.php
+++ b/src/json-schema/src/JsonSchemaTypeFactory.php
@@ -13,7 +13,7 @@ class JsonSchemaTypeFactory extends JsonSchema implements JsonSchemaContract
/**
* Create a new object schema instance.
*
- * @param array|(Closure(JsonSchemaTypeFactory): array) $properties
+ * @param array|(Closure(JsonSchemaTypeFactory): array) $properties
*/
public function object(Closure|array $properties = []): Types\ObjectType
{
diff --git a/src/json-schema/src/Types/ObjectType.php b/src/json-schema/src/Types/ObjectType.php
index 1227c1489..3248a951e 100644
--- a/src/json-schema/src/Types/ObjectType.php
+++ b/src/json-schema/src/Types/ObjectType.php
@@ -14,12 +14,22 @@ class ObjectType extends Type
/**
* Create a new object type instance.
*
- * @param array $properties
+ * @param array $properties
*/
public function __construct(protected array $properties = [])
{
}
+ /**
+ * Get the named property schemas.
+ *
+ * @return array
+ */
+ public function getProperties(): array
+ {
+ return $this->properties;
+ }
+
/**
* Disallow additional properties.
*/
@@ -33,7 +43,7 @@ public function withoutAdditionalProperties(): static
/**
* Set the type's default value.
*
- * @param null|array $value
+ * @param null|array $value
*/
public function default(?array $value): static
{
diff --git a/tests/JsonSchema/ObjectTypeTest.php b/tests/JsonSchema/ObjectTypeTest.php
index e8407b1e7..007b76313 100644
--- a/tests/JsonSchema/ObjectTypeTest.php
+++ b/tests/JsonSchema/ObjectTypeTest.php
@@ -81,6 +81,17 @@ public function testItMayBeInitializedWithAClosureButMayHaveProperties(): void
], $type->toArray());
}
+ public function testItExposesNamedPropertySchemasWithoutSerializingThem(): void
+ {
+ $properties = [
+ 'name' => JsonSchema::string()->required(),
+ '0' => JsonSchema::object(['active' => JsonSchema::boolean()]),
+ ];
+
+ $this->assertSame($properties, JsonSchema::object($properties)->getProperties());
+ $this->assertSame([], JsonSchema::object()->getProperties());
+ }
+
public function testNumericStringPropertyNamesRemainStringsInRequiredArray(): void
{
$type = JsonSchema::object([
From 6a172b6386f01ae2ac466e3a6d7389a442182c29 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:16:15 +0000
Subject: [PATCH 11/22] Cache recursive trait membership in class metadata
Add ClassMetadataCache::usesTrait() using the existing reflection cache. Resolve direct, nested and inherited traits once per queried class, then answer membership checks from that complete set.
Keep worker-lifetime keys bounded by classes rather than arbitrary class-and-trait lookup pairs, so negative lookups do not grow the cache. Clear the new metadata through the existing flushState() test-cleanup registration and avoid introducing a reverse dependency on Support helpers.
Validation: direct, nested, inherited, absent-trait and cache-reset coverage passes, together with formatting, full PHPStan and the full framework suite.
---
src/reflection/src/ClassMetadataCache.php | 42 +++++++++++++++++++++++
tests/Support/ClassMetadataCacheTest.php | 29 ++++++++++++++++
2 files changed, 71 insertions(+)
diff --git a/src/reflection/src/ClassMetadataCache.php b/src/reflection/src/ClassMetadataCache.php
index 68d12f215..0537d460e 100644
--- a/src/reflection/src/ClassMetadataCache.php
+++ b/src/reflection/src/ClassMetadataCache.php
@@ -32,6 +32,11 @@ class ClassMetadataCache
*/
protected static array $properties = [];
+ /**
+ * @var array>
+ */
+ protected static array $traits = [];
+
/**
* @var array>
*/
@@ -109,6 +114,22 @@ public static function properties(object|string $target): array
??= static::reflectClass($class)->getProperties();
}
+ /**
+ * Determine if the class uses a trait directly, through another trait or through a parent.
+ *
+ * @param class-string|object $target
+ * @param class-string $trait
+ *
+ * @throws ReflectionException
+ */
+ public static function usesTrait(object|string $target, string $trait): bool
+ {
+ $class = static::className($target);
+ static::$traits[$class] ??= static::resolveTraits(static::reflectClass($class));
+
+ return isset(static::$traits[$class][$trait]);
+ }
+
/**
* Get the cached class attribute metadata for the given class.
*
@@ -187,6 +208,26 @@ public static function hasPropertyAttribute(ReflectionProperty $property, string
= $property->getAttributes($attributeClass) !== [];
}
+ /**
+ * Collect the traits used by a class and its parents.
+ *
+ * @param ReflectionClass