Make Guzzle coroutine-safe and reduce AOP overhead
Guzzle's process-wide promise queue can run one request's callbacks while another request drains it. In a long-lived coroutine worker, those callbacks can resolve the wrong request or tenant, or use a database transaction owned by another coroutine. Sharing an active cURL multi-handler has a second failure mode: one coroutine can drive another's native transfers and crash the worker.
This PR makes those operations stay with their owning coroutine, fixes the affected SDK consumers, and reduces the AOP cost of enforcing that rule. The same AOP improvements benefit existing instrumentation such as Sentry and Telescope.
Keep Guzzle callbacks and transfers in their owning coroutine
A mutable promise belongs to the native coroutine that creates it. Pending work must be registered, driven, settled and canceled there. Completed results remain reusable across coroutines, and an idle multi-handler can be reused sequentially. A promise that has adopted another pending promise is still unfinished work.
Each coroutine receives a normal Guzzle task queue through the supported Utils::queue() setter. Pending promise operations and active multi-handlers are checked before another coroutine can run their callbacks or change their state. A conflicting operation fails with an explanation of how to create and finish the work in the same coroutine. Applications retain concurrency through native coroutines and parallel(); they share completed results rather than handing pending operations between requests.
Transfer ownership is reserved before request preparation, because reading an upload body can yield. Active transfers remain alive until settlement or cancellation. At coroutine exit, unfinished transfers are canceled and the owner's queue drains their rejection handlers, allowing dependent promises and memoized providers to recover. Queue and transfer state is excluded from copied coroutine context. Outside coroutines, normal Guzzle queue and shutdown behavior is retained.
Registration belongs to the HTTP provider, so protection applies in HTTP requests, queue workers and console commands without requiring an observability package. It covers Guzzle's mutable promises and cURL multi-handler; arbitrary third-party promise implementations and custom transports do not acquire these guarantees automatically.
Why AOP is needed
Guzzle exposes a queue replacement hook, but no global hook for promise construction, pending promise operations, or multi-handler admission and driving. An application client subclass or outer middleware cannot intercept every promise created inside Guzzle or an SDK.
The aspects therefore target a small set of public methods: the mutable promise constructor, then, wait, cancel, resolve and reject; and the multi-handler's __invoke, tick, execute and close where available. They check ownership around the original methods without copying their implementations or replacing Guzzle's scheduling.
Proxy generation runs before provider boot. It rejects vendor classes loaded too early and loaded proxies missing required method interception, rather than silently leaving protection inactive. The error explains lazy client registration. Generated method metadata verifies coverage during bootstrap, without adding that inspection to each invocation.
Correct shared SDK and HTTP consumers
AWS credentials. SQS, S3 and SES clients can share a callable credential provider even when the clients themselves are pooled separately. The provider can retain an in-flight HTTP promise. A shared wrapper serializes provider invocation and its promise completion by callable identity, while each caller executes in its own coroutine and receives its own result or exception. Callbacks drained during that wait may request credentials again in the same coroutine without deadlocking; other coroutines still wait for the outermost call to finish. Existing SDK memoization, pool fingerprints, completed credential reuse and cancellation behavior are preserved. There is no additional credential cache or sharing of one caller's credentials with another.
Pusher and Reverb. The default client previously shared an async multi-handler between concurrent callers. Async handlers are now reused within the same client and coroutine, preserving same-coroutine fan-out and connection reuse. Weak client keys and coroutine-local storage release handlers when they are no longer needed. Synchronous transport reuse, middleware, timeouts, on_stats, custom handlers and supported transport options remain intact. Connection caps that require sharing one active multi-handler across coroutines are rejected; callers should bound coroutine concurrency or use rate limiting.
Named HTTP connections. Async requests previously lost the named connection's handler settings when creating a fresh handler. Factory::newConnectionHandler() now shares option derivation with the cached synchronous path. Async handlers remain isolated while retaining their registered transport settings. Per-call request presets and explicit handler/client overrides keep their existing meaning.
Reduce shared AOP work
The previous dispatcher rebuilt a generic pipeline for every intercepted call and reflected its closure to find the target instance. It now caches an immutable chain per class and method, containing only aspect class names and continuation closures. Each invocation still resolves its aspect from the current container. Generated proxies pass the intercepted instance directly, or null for static calls.
This preserves priority, short-circuiting, original-method bypass, exceptions, references, variadics, scoped aspects, container rebinding, and nested or concurrent calls. No target object, join point, container or aspect instance is retained in the chain. The unused AOP-specific pipeline and its dependency are removed; the general framework pipeline is unchanged.
Bootstrap now selects exact class rules directly, prepares wildcard matching once per rule, and creates the parser and printer only when generation is required. A separate authoritative Composer loader contains only proxy entries, keeping the application's source map unchanged. This avoids retaining copies of the complete application map. Composer discovery continues to identify the application loader, including after test resets. Content fingerprints, PSR-4 lookup, source overrides, proxy regeneration and validation before publication remain intact.
Make class-map overrides safe across application boots
The documented provider classMap() API previously copied the entire Composer application map when adding replacements. It also rejected the same replacement on a second Testbench boot, while test cleanup left unloaded overrides active for later applications that had not registered them.
Replacements now use their own authoritative Composer loader and leave the application map unchanged. Automatic test cleanup unregisters that loader and its generated replacement proxies together. A loaded target can be registered again only from the same canonical source; generated proxies record their original source as class metadata so the same rule applies to them. Different-source replacements still fail. Replacement proxies use separate, fixed filenames so they cannot overwrite ordinary proxies retained between application boots.
Generated proxies now live in bootstrap/cache/aop, alongside each release's other compiled framework files. Two overlapping releases sharing storage previously wrote the same proxy paths, allowing an older worker to load the newer release's code. The release-local directory prevents that collision without per-release hashes or accumulating files in shared storage. Cache clearing, Testbench cleanup and the about report follow the new location; about also recognizes the generated PHP files correctly.
Approaches considered and rejected
| Approach | Why it was not used |
|---|---|
| Replace only Guzzle's queue | Necessary, but insufficient: another coroutine could still operate directly on a pending promise or active multi-handler. |
| Client subclasses or outer middleware alone | Do not cover promises and callbacks constructed internally by Guzzle and third-party SDKs. |
| Copy request context into another coroutine | Copying values does not transfer native resource or database transaction ownership. |
| Replace the scheduler or make requests pump each other's queues | Adds scheduling machinery and changes wait/progress behavior. Keeping work in its native owner preserves the relevant resource boundary. |
| Maintain a Guzzle fork, vendor patch, or copied promise implementation | Duplicates dependency maintenance. Supported queue replacement plus narrow public-method interception provides the required coverage. |
| Give every Pusher transfer its own handler | Loses supported same-coroutine fan-out and connection reuse. Per-client, per-coroutine reuse preserves both. |
| Cache resolved aspects or coroutine state globally | Would break scoped bindings, rebinding, or request isolation. Only immutable dispatch metadata is cached. |
| Publish proxies into the main Composer map | Retains another complete class-map allocation. Composer's own separate authoritative loader holds just the proxy entries. |
The pass-through-aspect variant used below is a measurement control, not a production bypass. All measured outgoing request paths remain supported; the rejected items above are implementation approaches.
AOP performance improvements
These comparisons are against the initial ownership implementation, not against an uninstrumented framework:
| Measurement | Initial implementation | Optimized | Change |
|---|---|---|---|
| Guarded promise-chain CPU | 39.52 µs/op | 20.57 µs/op | About 48% lower |
| Cached startup | 181.16 ms | 78.15 ms | About 57% lower |
| Retained PHP heap after idle workload | 6,114.61 KiB | 3,164.52 KiB | 2.88 MiB less |
The compiled-chain comparison reduced pass-through dispatch from 31.62 to 15.80 µs/op. A separate comparison isolated direct instance passing, reducing guarded chain CPU from 21.72 to 20.48 µs/op. The earlier complete matrix measured 20.50 µs/op; the final bootstrap refresh measured 20.57 with the same dispatch code.
Ownership overhead across outgoing paths
The following compares the unchanged framework with the protected implementation. Values are client CPU microseconds per operation, not batch latency. The loopback origin's CPU is excluded.
| Workload | Concurrency | Baseline → protected | Added CPU |
|---|---|---|---|
| Idle loop † | 1 | 0.33 → 0.34 | 0.01, rounding-sensitive |
| Promise chain † | 1 | 3.23 → 20.57 | 17.34 |
| Synchronous HTTP † | 1 | 405.37 → 444.67 | 39.30 |
| Async HTTP | 1 | 289.60 → 377.82 | 88.22 |
| Async HTTP | 8 | 200.12 → 259.75 | 59.63 |
| Async HTTP † | 32 | 152.42 → 210.28 | 57.86 |
| Coroutine-parallel HTTP | 1 | 466.42 → 519.82 | 53.40 |
| Coroutine-parallel HTTP | 8 | 422.48 → 482.29 | 59.80 |
| Coroutine-parallel HTTP † | 32 | 430.56 → 468.59 | 38.03 |
| Response middleware/event | 1 | 301.30 → 339.90 | 38.61 |
| Credential provider | 1 | 371.19 → 419.12 | 47.94 |
| Shared credential provider | 8 | 294.69 → 409.29 | 114.60 |
| Pusher | 1 | 390.30 → 500.75 | 110.45 |
| Pusher | 8 | 299.13 → 374.29 | 75.16 |
| Pusher † | 32 | 267.77 → 330.34 | 62.57 |
| Cancel pending transfer † | 1 | 108.96 → 165.89 | 56.93 |
† Refreshed paired measurements after the bootstrap corrections. Other rows use the earlier complete matrix with the same dispatch and ownership implementation. Rows from different runs are not a controlled concurrency-scaling comparison.
Async completion through Utils::unwrap() makes 27 intercepted calls per operation at concurrency one, compared with nine in the promise probe. A separate pass-through comparison separates dispatch from ownership and queue handling; it explains the larger async and cancellation costs. There were no GC runs inside the timed operation loops.
The shared credential-provider workload deliberately serializes calls to one provider and includes a 1 ms endpoint delay. At concurrency eight, p95 latency increased from 5.396 to 22.474 ms. This includes waiting for the provider lock; it is not all AOP overhead. Completed memoized credentials remain reusable.
Guzzle 7 shows comparable absolute costs on the focused workloads: approximately 17 µs for promise chains, 43 µs for synchronous HTTP, 90 µs for async concurrency one, 53 µs for async concurrency eight, and 54 µs for cancellation. The source implementation does not need a separate version-specific AOP path.
Memory bounds and capacity planning
At the benchmarked revision, the integration retains approximately 124–188 KiB of additional PHP heap per warmed worker across the measured workloads, with roughly 0.94–1.19 MiB additional RSS. RSS includes shared code pages, so it is not a measure of private memory added to every worker. Allocated PHP memory after exit remained 16 → 16 MiB.
This retained metadata is not added for each request. Temporary ownership state follows outstanding promises and native transfers and is released as work finishes or is canceled. Repeated batches showed the same report-bookkeeping growth on baseline and protected processes, with no additional retained growth, and returned to the same descriptor count. Applications still need bounded outgoing concurrency and queues; the integration does not cap how much unfinished work an application can create.
At the measured 38.03 µs additional CPU per outgoing request for coroutine-parallel HTTP at concurrency 32:
| Outgoing requests/sec | Additional CPU capacity across the deployment |
|---|---|
| 1,000 | 0.038 cores |
| 10,000 | 0.380 cores |
| 50,000 | 1.902 cores |
These are arithmetic projections, not throughput measurements at those traffic levels. The tested HTTP and Pusher paths suggest a planning range of approximately 40–110 µs/request, or 0.4–1.1 additional cores at 10,000 outgoing requests/sec. Credential-provider contention is a separate serialized workload. The integration adds predictable CPU work without a request-count-dependent retained-memory cost.
Cached startup compared with the unchanged framework is 69.95 → 78.15 ms. One cold process requiring proxy generation took 132.45 ms; cold startup is reported separately from repeated warm measurements.
Measurement conditions and validation
Measurements precede the subsequent class-map override reset and release-local proxy corrections, and the switch to xxh128 for internal proxy fingerprints and helper names. These add generated source metadata and bootstrap lookup for registered overrides, without changing the per-request dispatcher or ownership checks. Their startup and retained-memory effects have not been remeasured; the resource figures describe the benchmarked revision.
The credential-provider measurements also precede the same-coroutine reentry correction. Its active-owner lookup and cleanup have not been remeasured. This correction does not change the HTTP, Pusher or shared AOP dispatch paths.
Measurements used PHP 8.4.25, Swoole 6.2.2, cURL 8.5.0, CLI OPcache enabled, JIT disabled, and an Intel Core i7-7700 with six logical processors exposed. Both checkouts used independently installed identical dependencies and optimized Composer autoloading. The primary matrix used Guzzle 8.2.0/promises 3.0.2; focused comparisons also used Guzzle 7.15.5/promises 2.5.3. Each workload used three alternating process pairs with warmup and three measured samples per process. The report includes ranges, operation counts, latency and resource snapshots.
Async/Pusher wall-time measurements at higher concurrency include the separate native hooked-select readiness stall addressed by Swoole #6290. CPU is reported separately rather than using that stall to make ownership overhead appear negligible. The default AWS Guzzle transport also takes the async transport path for synchronous SDK calls.
Regression coverage exercises real generated proxies, yielding request/tenant and database-transaction isolation, pending and adopted promises, cancellation, owner exit, upload preparation, shared clients, credential memoization, Pusher fan-out, cold/cached bootstrap and cleanup. Representative tests fail with the protections removed. Both supported Guzzle dependency families and their upstream promise suites were validated, together with the full framework suite, affected SDK consumers, Sentry/Telescope, package-mode Testbench, formatting and static analysis. CI adds a focused Guzzle 7 compatibility job alongside the normal dependency resolution.
The shared PHPUnit extension prepares framework proxy targets before test discovery without enabling optional instrumentation and resets test-owned queues safely. The formatter's return_assignment rule is also disabled because it removes assignments captured by reference in nested closures, breaking valid Guzzle wait callbacks.
Validation also exposed test-harness gaps. Coroutine tests now retain PHPUnit's native busy-loop alarm and enforce the same deadline while the test or its children are suspended, reporting the named test through PHPUnit's normal timeout result. Synchronous output-assertion tests run outside coroutines pending the upstream output-buffer integration. PHP-clock rate-limiter contract tests freeze their clock so I/O delays cannot change back-to-back decisions.
Notification transport snapshots also use SplObjectStorage::offsetExists() instead of the PHP 8.5-deprecated contains(), retaining PHP 8.4 compatibility.
The technical design is in docs/plans/2026-10-09-guzzle-coroutine-ownership.md. Reproducible commands and complete measurements are in tests/Benchmarks/GuzzleOwnership/README.md and results.md.
[!NOTE]
Make Guzzle coroutine-safe and reduce AOP dispatch overhead
- Adds coroutine ownership tracking for Guzzle promises and cURL multi-handlers. Pending promises and active transfers belong to their creating coroutine; foreign operations raise
CoroutineOwnershipException (CoroutineOwnership.php, Aspects/PromiseOperationAspect.php).
- Adds
CoroutineTaskQueue, CoroutineState, and TransportState so each coroutine has its own Guzzle task queue and pending transfers. Coroutine exit cancels unfinished transfers and drains that coroutine's callbacks (CoroutineTaskQueue.php).
- Rewrites AOP dispatch:
AspectManager caches compiled closure chains instead of aspect-name arrays, ProxyDispatcher invokes cached chains with the intercepted instance attached to the join point (AspectManager.php, ProxyDispatcher.php).
- Wraps callable AWS credential providers in
SerializedCredentialProvider for SQS, S3, SES mail, and filesystem clients, so shared providers serialize their work per caller. Adds shared Pusher/Reverb transports for synchronous calls and coroutine-local handlers for asynchronous calls (BroadcastManager.php).
- Moves the AOP proxy cache from
storage/framework/aop to bootstrap/cache/aop, switches generated fingerprints and helper names to xxh128, and adds ProxySource/ProxyMethod attributes to generated proxies. Adds a CI job for Guzzle 7 compatibility.
- Behavioral Change: Pusher/Reverb clients without an explicit handler now throw
InvalidArgumentException for host/total connection-limit options; ProceedingJoinPoint::getInstance now reports the dispatcher-supplied instance (null for static calls) instead of reflecting the closure; cached AOP entries are closures, so custom AspectManager consumers must be updated; existing proxy caches under storage/framework/aop are stale and regenerate under the new path.
Macroscope summarized 1e7f66f.