Keep busy cURL multi handlers in their coroutine and add global middleware

Merged
BI
3d ago
+1869-394

This turns the fork into hypervel/guzzle: a drop-in replacement for guzzlehttp/guzzle that is safe to use when many Swoole coroutines share one PHP process, and that lets Hypervel's integrations see every request sent through Guzzle.

The problem

Hypervel runs applications in long-lived Swoole workers. Every request runs in its own coroutine, and they all share the same process. That causes three problems with stock Guzzle.

A cURL multi handler has no owner. A CurlMultiHandler drives one native multi handle. If a second coroutine sends a request through it, ticks it or waits on it while the first coroutine still has transfers in flight, the two coroutines drive the same native state. The second one reads the first one's completion messages and settles promises it doesn't own. Swoole's native cURL hook is stricter still: touching a multi handle that another coroutine is waiting on raises cURL is executing, cannot be operated and exits the worker.

Instrumentation can't see every client. Sentry and Telescope need to see every outgoing request, including requests from clients that SDKs build with their own handler stacks. Guzzle has no hook that covers every client.

Synchronous requests reconnect too often. Coroutines overlap synchronous requests on one CurlHandler. Each easy handle has its own connection cache, and the handler kept only 3 idle handles, so most concurrent requests closed their handle afterwards and the next requests opened new connections.

Until now Hypervel handled the first two with AOP aspects on CurlMultiHandler and Client. That worked, but it meant generating proxy classes for a third-party package and sending every call through the aspect layer. Putting this in the library itself is simpler and cheaper, and it covers clients and handlers that SDKs create.

What changes

Multi handler ownership. A CurlMultiHandler now records the coroutine that owns its work (-1 outside coroutines). Every entry point claims the handler first: __invoke(), tick(), execute(), close(), and the wait and cancel functions of its promises. The owner always passes. Another coroutine can take over only when the handler is idle and no handler call is running. Otherwise it gets a LogicException, thrown before anything changes:

Coroutine 7 cannot call tick() on a cURL multi handler with work owned by coroutine 3. Finish the work in its owning coroutine first.

"Idle" reuses the condition the proxy tunnel handover already used, now extracted into isIdle(): no pending transfers, no native execution or message processing, no deferred cancels and no deferred close. On top of that, each handler call is counted while it runs. A call can yield while the handler still looks idle: request preparation can read the body from a coroutine socket before the transfer is added, and a queued task can suspend the owner inside tick(). Without the count, another coroutine could claim the handler at that moment, and the owner would resume on a multi handle someone else is using.

__invoke()'s body moved unchanged into a private doInvoke(), so the ownership wrapper stays separate and upstream changes to request preparation merge cleanly.

One edge case needed its own guard: a queued tickInQueue() can outlive its owner's tick() if an earlier queued task throws. If another coroutine then claims the idle handler, that stale tick must not drive the new owner's transfers, so tickInQueue() returns unless it runs in the owning coroutine.

The rule only covers requests handled by CurlMultiHandler. With cURL available, the default handler uses it for asynchronous requests without stream enabled, and on a client with connection caps for synchronous ones too. Those should come from one coroutine at a time, or from a client per coroutine. CurlHandler, which takes the other synchronous requests, and StreamHandler, which takes streamed requests, have no owner, so one client can still send those from many coroutines at once.

A transfer still pending when its coroutine ends keeps the handler owned by that coroutine. Hypervel's scoped task queue (below) cancels such transfers when the coroutine ends; without one, callers have to wait on or cancel every transfer first. The docs and UPGRADING.md say so.

Transfer tracking. hypervel/guzzle-promises adds ScopedTaskQueueInterface, for task queues that run per execution scope. When Utils::queue() returns one, the multi handler passes each pending transfer to track() once addRequest() succeeds, so the queue can cancel transfers that a coroutine leaves unfinished. Hypervel's per-coroutine queue uses this to clean up at coroutine exit. The stock TaskQueue doesn't implement the interface, so nothing changes for anyone else.

Global middleware. Client::pushGlobalMiddleware($middleware, $name) and Client::removeGlobalMiddleware($name) manage a process-wide list of named middleware that wraps every client's transfers:

  • It has the same form as handler stack middleware and must return a promise.
  • It runs outside the client's handler stack. The client has already resolved the request URI against base_uri, but hasn't applied request options such as headers, query, body and json. The middleware receives the request and the request options merged with the client's defaults, without nulls.
  • The first middleware pushed runs outermost, as in HandlerStack. Pushing a name again replaces that middleware and moves it innermost.
  • Exceptions thrown by middleware, whether while the chain is composed or while it handles a request, reject the returned promise, as they do in a handler stack. Only a failure to apply the request options still throws synchronously, as it does without global middleware.

sendAsync() and requestAsync(), which every other send and request method goes through, call a new private transferWithGlobalMiddleware(). transfer() itself is untouched. With nothing registered it calls transfer() straight away. Otherwise it composes the chain per call: caching it on the client would hold the client in a reference cycle and delay freeing its native handles. Unlike HandlerStack, the middleware is therefore called with the next handler for each transfer, and the docs say that state shared across transfers belongs outside that call. The cost is about 60 ns per transfer with nothing registered, and under 1 µs with two pass-through middleware, against roughly 6 µs for a client transfer through a trivial handler.

More idle cURL handles. CurlHandler now keeps up to 256 idle easy handles by default instead of 3. Handles are still created only when requests need them. CurlMultiHandler keeps its default of 50, because its connections live in the multi handle's cache rather than in each easy handle. An explicit max_idle_handles, including 0, is still honored.

Upstream pull requests. The first seven commits carry four upstream pull requests with their original commits, minus the changelog entries:

  • #3934 keeps StreamHandler deadlines local to each request.
  • #3935 adds the max_idle_handles option that the new default builds on.
  • #3936 returns available bytes from filtered streamed responses instead of waiting for a full buffer.
  • #3937 reads StreamHandler response headers from the opened stream, which also fixes the stream handler under Swoole on PHP 8.4 and newer. Its follow-up commit was reconciled with #3934's deadline change.

Compatibility

Composer installs this package in place of guzzlehttp/guzzle, so it ends up inside third-party packages and SDKs that know nothing about Hypervel. The public API is untouched: no class, method, signature or interface changed. The only additions are the two static methods on Client.

Outside coroutines everything behaves like upstream apart from the higher CurlHandler idle default, since every call there has the same owner. Upstream's whole test suite passes; the one existing test that needed a change fakes a busy handler through reflection, and now also sets the handler's owner, as real work does. Inside coroutines, the only other difference is the ownership rule, which Hypervel already enforced through the aspects.

Package, tooling and docs

  • composer.json: renamed to hypervel/guzzle, replaces guzzlehttp/guzzle at self.version, requires PHP 8.4+ and ext-swoole, and depends on hypervel/guzzle-promises instead of guzzlehttp/promises. The code stays PHP 7.4-compatible so upstream's static analysis setup keeps working.
  • With PHP 8.4 as the minimum, PHPStan treats upstream's branches for older PHP versions as always true or false, so phpstan-baseline.neon is regenerated under PHP 7.4. The new entries only cover those version checks in upstream code.
  • tests/bootstrap-phpstan.php declares Swoole\Coroutine when Swoole isn't loaded, because PHPStan's bundled stub types getCid() as mixed.
  • CI runs the tests on PHP 8.4 and 8.5 (lowest dependencies on 8.4) inside Hypervel's components CI image, which has Swoole. The Windows job is gone, since Swoole doesn't run on native Windows, and so is the problem matcher step, which relied on setup-php. Composer Normalize, PHPStan and PHP-CS-Fixer stay on PHP 7.4 like upstream, and the steps that install the root package ignore the php and ext-swoole requirements. That's set per step on purpose: ignoring the PHP version for the PHP-CS-Fixer tool install pulls in symfony/console releases that don't parse on 7.4.
  • The unused Dockerfile is removed. It installed upstream Guzzle on PHP 7.4, so in this repository it would test the wrong package.
  • The docs cover multi handler ownership and tracking in handlers.md and global middleware in middleware.md. They name the fork's packages where they describe what gets installed, and they note in "Promise Queue Integration" that promises must be settled in the coroutine that owns them. The README has a Hypervel Fork section, the fork's requirements and version scheme, and Hypervel's support and security contacts. UPGRADING.md opens with a section on moving to the fork. CHANGELOG.md stays upstream's, with a note pointing at the fork's release notes.
  • AGENTS.md gets the fork rules, starting with the API compatibility requirement.

Tests

The new tests in CurlMultiHandlerTest run in real Swoole coroutines against the upstream test server:

  • Another coroutine's __invoke(), tick(), execute(), close(), wait() and cancel() are refused while the owner has a transfer in flight, nothing is sent, and the owner's transfer still completes.
  • Another coroutine can't claim the handler while the owner is preparing a request whose body read yields.
  • Another coroutine can't claim an idle handler while the owner is suspended inside tick(), execute() or a wait.
  • Another coroutine can use the handler once the owner's transfer has completed, been canceled, or failed during preparation.
  • A stale queued tick doesn't drive another coroutine's transfer, and that transfer still completes.
  • With a scoped task queue, immediate and delayed transfers are each tracked once, and a transfer that fails to attach isn't tracked.

ClientTest covers global middleware order relative to the handler stack, replacement, removal, the request and options that middleware receives, request option failures that the client throws, and every public send and request method. CurlHandlerTest and UtilsTest cover the new idle default, including that Utils::chooseHandler() keeps both defaults.

Each guard that protects a reachable path is load-bearing: removing any claim that a public call can reach, the counts in __invoke(), tick() and the wait function, the idle check, the stale tick guard, the tracking call or its position, or any part of the middleware chain makes a test fail. The counts in execute(), close() and the cancel function, the wait function's count after its drive, and the claims in the wait and cancel functions are already covered by other layers (the inner tick(), executeUntil()'s queue drain, and the promise's own ownership check). They're kept so every entry point holds the handler for its whole call, whatever upstream later changes about where code can suspend.

The coroutine tests use their own task queue and restore Swoole's runtime hooks afterwards. Coroutine\run() leaves the hooks enabled, and on PHP 8.5 hooked cURL rejects handles created while the hooks were off. For the same reason, the tests call the test server's control methods outside coroutines.

Verified locally:

  • PHPUnit (3150 tests) in the CI image on PHP 8.4 and 8.5, and with --prefer-lowest on 8.4, with the test server started in its own step as the container jobs do. Several full runs in random order pass as well.
  • Composer Normalize, PHPStan and PHP-CS-Fixer under PHP 7.4, the way static.yml runs them. PHPStan resolves hypervel/guzzle-promises 3.0.2.2, which drops the PromiseInterface<mixed, mixed> annotation from ScopedTaskQueueInterface::track(). The interface's template types are invariant, so that annotation rejected the Promise<ResponseInterface, mixed> the multi handler passes. Guzzle still requires ^3.0.2, since the fix doesn't change runtime behavior.