Use coroutine-aware Guzzle forks instead of AOP

Merged
BI
8d ago
+1890-2365

This replaces Hypervel's Guzzle AOP integration with hypervel/guzzle and hypervel/guzzle-promises. Coroutine ownership lives directly in the promise and transport classes, while Sentry and Telescope use global Guzzle middleware. All five built-in Guzzle aspects and their PHPUnit proxy preparation are removed. Generic AOP support remains available for applications.

Why use forks?

Guzzle provides the HTTP client and promise APIs we want to keep. Hypervel runs many coroutines in one long-lived worker, so unfinished promises and active cURL multi-handlers also need a clear owner. One coroutine must not drive or settle another coroutine's unfinished work, and transfers left behind when a coroutine exits need to be canceled in that coroutine.

The existing AOP integration enforced those rules, but added interception to frequently called promise methods, generated proxies, and restrictions on when target classes could load. It also required maintaining interception points alongside upstream changes. Keeping the checks in the classes that own the work removes that layer and makes the lifecycle easier to follow.

The forks let us carry Swoole-specific ownership rules, requirements, and connection-retention defaults without requiring upstream Guzzle to adopt Hypervel's runtime assumptions. They retain upstream's public APIs and namespaces. Composer installs them in place of the upstream packages, so third-party SDKs with compatible version requirements receive the same behavior without application changes.

This does not remove the need to follow upstream. We will keep syncing upstream changes and contributing improvements that benefit Guzzle generally. The forks already include our upstream PRs for request-local stream deadlines, configurable idle cURL retention, filtered-stream buffering, and reading headers from the opened stream.

The underlying fork changes are in hypervel/guzzle#1 and hypervel/guzzle-promises#1.

What changes

  • Require the released forks across the root and consuming split packages. Support Guzzle 8, promises 3, and PSR-7 3; remove older-version branches, helpers, skips, and the Guzzle 7 compatibility CI job. SDKs restricted to Guzzle 7 are no longer supported.
  • Keep the coroutine-local task queue and owner-exit cleanup in Hypervel. Implement the fork's scoped queue interface so pending transfers stay alive until they settle or their owner exits. Remove the ownership aspects, maps, and framework-specific ownership exception; violations now use the fork's LogicException.
  • Delegate handler construction and idle retention to Guzzle. The synchronous handler keeps up to 256 idle easy handles by default, while the asynchronous handler keeps 50. Explicit idle limits remain supported. Framework clients still reject connection caps that would route shared synchronous requests through an active multi-handler.
  • Move Sentry's HTTP instrumentation into HttpClientIntegration, using named global middleware. Preserve trace propagation, per-transfer spans, breadcrumbs, transfer statistics, failure handling, and origin collection. Replace no_sentry_aspect with sentry_enabled => false for client or request opt-out. Trace headers still propagate when local HTTP spans and breadcrumbs are disabled.
  • Register Telescope's client request watcher through global middleware. Preserve recording exclusions, tags, statistics, and response handling. A request-level telescope_enabled => true can override a client's disabled default because the middleware receives merged options.
  • Remove Guzzle-specific proxy setup while retaining generic AOP registration, proxy-source classification, and application coverage. Update cleanup, package metadata checks, documentation, and tests to use the new integration directly.

Global middleware covers every Guzzle client, including clients constructed inside third-party SDKs. The integration no longer depends on generating Guzzle proxies before those classes load. Pending operations still belong to their owning coroutine; this does not make an active promise or multi-handler freely shareable across coroutines.

Performance

The comparison uses the AOP implementation at f2ebe434d and the fork integration at 1419afbc5, with three alternating before/after process pairs and three measured samples per process. Each checkout has independent optimized dependencies, matching checkout-path lengths, and identical unchanged dependency versions. Both use PHP 8.4.25, Swoole 6.3.0RC1, cURL 8.5.0, CLI OPcache enabled, JIT disabled, and GC enabled on the same idle six-vCPU Linux machine.

The HTTP origin runs separately, and its CPU is excluded. These are local workload measurements, not forecasts of production application capacity. Full sample ranges, operation counts, reproduction commands, and limitations are in the Guzzle report and Sentry report.

CPU and throughput

CPU and wall time are microseconds per operation. At concurrency greater than one, wall time divided by operation count measures batch throughput, not individual request latency.

WorkloadCPU before → afterCPU reductionWall before → afterOperations/sec before → after
Promise chain21.45 → 4.8177.6%21.46 → 4.8146,606 → 207,881
Framework synchronous HTTP463.87 → 430.357.2%1,650.56 → 1,605.34606 → 623
Guzzle asynchronous HTTP, concurrency 8226.46 → 183.0619.2%226.43 → 184.144,416 → 5,431
Direct Guzzle synchronous HTTP, concurrency 32345.17 → 220.3436.2%411.49 → 286.782,430 → 3,487
Cancel pending transfer86.83 → 73.1715.7%86.82 → 73.1711,517 → 13,667

Cancellation timing covers transfer admission and cancellation, ending before the sample's queue drain. It does not measure the complete callback-cleanup cost.

Request latency

Workloadp50 before → after, msp95 before → after, ms
Framework synchronous HTTP1.632 → 1.5881.742 → 1.708
Guzzle asynchronous HTTP, concurrency 81.247 → 1.0291.721 → 1.419
Direct Guzzle synchronous HTTP, concurrency 328.924 → 6.27810.865 → 7.013

Both revisions include Swoole's extra 1 ms timer for immediate cURL work, addressed by swoole-src#6299. This affects synchronous wall time and latency on the tested runtime; these rows should not be read as a general comparison of synchronous and asynchronous transports.

Sentry HTTP instrumentation

These are microseconds per MockHandler request, including the underlying Guzzle operation and promise handling. They measure the combined integration change, not the middleware hook in isolation.

CaseBeforeAfterReduction
Sampled synchronous request154.51102.1733.9%
Recording off, trace propagation enabled101.7868.5632.6%
Explicit opt-out77.6849.8235.9%

Memory and connection reuse

These are process medians after the owner coroutine exits and garbage collection runs, not per-request allocations.

WorkloadLive PHP heap before → after, KiBRSS before → after, MiB
Promise chain3,316.30 → 3,167.2573.93 → 73.38
Framework synchronous HTTP4,364.98 → 4,282.7878.41 → 78.01
Guzzle asynchronous HTTP, concurrency 83,791.70 → 3,627.4676.61 → 76.00
Direct Guzzle synchronous HTTP, concurrency 323,794.30 → 3,637.2379.23 → 77.69
Cancel pending transfer3,586.63 → 3,492.1975.45 → 75.11

Allocated PHP memory was 18 MiB on both revisions. Every process returned to six open descriptors after exit.

The direct-client workload used 467 distinct local ports before and 32 after across each 512-request sample. Retaining handles improves connection reuse across concurrent bursts. Warm descriptors rose from 18 to 105 because the cache retained 32 handles instead of three. In this Linux/Swoole setup, each retained synchronous handle uses three descriptors. The cache grows on demand; the 256-handle default is a limit, not an upfront allocation.

There is a temporary memory cost when canceling many transfers without draining the queue: 1,000 cancellations retained about 4.7 MiB more until their callbacks ran. The first sample also grew reusable tracking and object-store capacity by about 114 KiB. Later samples added no further capacity, and owner-exit cleanup released the tracking and queue storage. The reports include the drain and cleanup snapshots. This is separate from the normal successful-request path.

Verification

Local formatting and both PHPStan suites pass. The full parallel test suite, Testbench package-mode suite, and independently installed dogfood applications pass, along with targeted HTTP, Sentry, Telescope, SDK transport, and generic AOP tests.

Coverage includes coroutine isolation, unfinished-transfer cancellation, callback context, retry-created work, shared AWS credentials, response and sink failures, tracing exclusions, proxy-source classification, and release of transport resources. The benchmark fixtures also check completed responses, expected instrumentation, and cleanup; their timings are not CI assertions.

[!NOTE]

Replace Guzzle AOP instrumentation with coroutine-aware Hypervel Guzzle forks

  • Swaps direct guzzlehttp/guzzle and guzzlehttp/promises for hypervel/guzzle ^8.2 and hypervel/guzzle-promises ^3.0.2 across all package manifests; drops Guzzle 7 and PSR-7 2.x support (see composer.json and src/http/README.md)
  • Removes Guzzle AOP surfaces: the transport-ownership aspect, promise aspects, and Guzzle proxy generation in AfterEachTestExtension (HandlerFactory.php now returns Utils::chooseHandler() directly)
  • Sentry and Telescope instrumentation moves from aspects to named global Guzzle middleware (ClientRequestWatcher.php, HttpClientIntegration); flush methods remove middleware between tests
  • Promise ownership becomes coroutine-scoped: CoroutineState tracks PromiseInterface transfers with track() and auto-removes them on settle; CoroutineTaskQueue implements ScopedTaskQueueInterface and cancels unfinished work at scope exit
  • PendingRequest drops Guzzle 7 compatibility branches; only ResponseException carries responses, so other exception types no longer produce a response through responseFromException (PendingRequest.php)
  • Behavioral Change: Guzzle 7 / promises 2 / PSR-7 2 installations break; the removed CoroutineState::add()/forget() and changed PendingRequest exception handling alter public API; Sentry opt-out option renamed to the boolean Sentry-enabled option (sentry.md)

Macroscope summarized 071b4ee.