diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9baf8519a..e515510d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,43 @@ jobs: OTEL_SDK_DISABLED: true run: vendor/bin/testbench package:test --parallel + guzzle_7_compatibility: + runs-on: ubuntu-latest + timeout-minutes: 15 + if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" + name: Guzzle 7 and promises 2 + + container: + image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Log trigger context + uses: ./.github/actions/log-trigger-context + + - name: Cache Composer dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /root/.composer/cache + key: composer-guzzle7-${{ hashFiles('composer.json') }} + restore-keys: composer-guzzle7- + + - name: Install the supported Guzzle 7 dependency family + run: | + COMPOSER_MEMORY_LIMIT=-1 composer update --prefer-dist -n -o \ + --with 'guzzlehttp/guzzle:^7.15.2' \ + --with 'guzzlehttp/promises:^2.5.2' \ + --with 'guzzlehttp/psr7:^2.13' + + - name: Run ownership and affected consumer tests + run: >- + vendor/bin/paratest -c phpunit.xml.dist + --filter '(Guzzle|PusherClientTest|AwsCredentialConsumerTest|SerializedCredentialProvider|HttpConnectionTest|CurlStreamingHandlerTest|HttpClientStreamingTest|AfterEachTestExtensionTest|GenerateProxiesTest|ServiceProviderDiTest)' + wayfinder_tests: runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index f12123848..aa5e557fc 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -82,9 +82,8 @@ 'phpdoc_to_comment' => [ 'ignored_tags' => ['var'], ], - 'return_assignment' => [ - 'skip_named_var_tags' => true, - ], + // This rewrite removes assignments captured by reference in nested closures. + 'return_assignment' => false, 'php_unit_method_casing' => [ 'case' => 'camel_case', ], diff --git a/docs/plans/2026-10-09-guzzle-coroutine-ownership.md b/docs/plans/2026-10-09-guzzle-coroutine-ownership.md new file mode 100644 index 000000000..4c45f5452 --- /dev/null +++ b/docs/plans/2026-10-09-guzzle-coroutine-ownership.md @@ -0,0 +1,191 @@ +# Guzzle coroutine ownership + +## Objective + +Prevent Guzzle callbacks and native transfers from running in another request's coroutine, without replacing Guzzle's scheduler or maintaining a dependency fork. Preserve normal framework concurrency, request context, tenant isolation, transaction ownership, and bounded resource lifetimes. + +## Why this change is needed + +Guzzle's process-wide task queue can execute one request's promise callbacks when an unrelated coroutine drains it. This includes middleware and event listeners belonging to a synchronous HTTP operation, not only application-created async callbacks. Request-scoped services can then resolve another request's state or use the wrong database transaction. + +A shared `CurlMultiHandler` also allows one coroutine to drive native transfers belonging to another. This can crash a Swoole worker. Separately pooled SDK clients do not prevent a shared custom credential-provider object from reusing its own in-flight HTTP work. The cached default Pusher/Reverb client has a similar problem through its public async methods. + +Use Guzzle's supported `Utils::queue()` setter for queue isolation. Guzzle supplies no equivalent global hook for construction and operations on its mutable promises or admission and driving of its multi-handler. Client subclasses and outer middleware cannot cover promises and middleware created inside dependencies. Narrow AOP interception is therefore needed at those public method boundaries. + +Do not use a maintained fork, Composer/vendor patch, private-member access, copied promise implementation, custom scheduler, context impersonation, or framework-wide wait pumping. Copying request context into a new coroutine does not reproduce ownership of transaction and other native resources. + +## Ownership contract + +- A mutable `GuzzleHttp\Promise\Promise` belongs to the native coroutine that constructs it, including the non-coroutine context outside Swoole coroutines. +- Pending work is created, driven, settled, and canceled by its owner. Another coroutine is refused before it can execute foreign callbacks, drive native work, or mutate pending state. +- Completed results remain reusable across coroutines. A promise reported as fulfilled because it adopted another pending promise is not yet a completed result. +- Each coroutine has its own stock Guzzle task queue. Queue and transfer state implement `NonCopyableContext`, so `fork()`, `forkOwned()`, and `parallel(copyContext: true)` cannot inherit them. Outside coroutines, retain normal Guzzle queue behavior, including shutdown draining. +- At coroutine exit, cancel unfinished native transfers and drain the owner's stock queue so cancellations reach dependent promises and reset memoized state. Repeat for transfers created by callbacks until both are empty. Callbacks stay in their owner; do not wait for abandoned HTTP work to succeed. A retained pending promise cannot later be driven by another request; its owner must finish the work before exiting. +- Framework concurrency remains available through native coroutines and `parallel()`: create and finish each operation in the child that owns it, then share completed results. Preserve ordinary sequential reuse of pooled clients and completed credential promises. +- These guarantees cover Guzzle's mutable promises and cURL multi transport. Do not claim arbitrary third-party `PromiseInterface` implementations or custom transports acquire ownership automatically. + +## Production structure and lifecycle + +### Shared Guzzle integration + +Place the integration under `Hypervel\Http\Client\Guzzle`, with aspects in its `Aspects` directory and a focused ownership exception under the HTTP package's exceptions. One `NonCopyableContext` holder owns the coroutine queue and active transfers, with one exit hook registered on first use of either. Use `Hypervel\Coroutine\Coroutine::defer()` so an escaping task failure cannot abort earlier resource defers; cancel remaining transfers in `finally`. Keep queue delegation and weak ownership bookkeeping separate. + +Register the aspects and queue integration through `HttpServiceProvider`, already part of `DefaultProviders` and package discovery. This must work independently of Sentry and Telescope, in HTTP requests, queue workers, and console commands. Add `hypervel/di` to the HTTP package's direct dependencies for its aspects; do not put Guzzle dependencies into Engine or generic coroutine primitives. + +Register aspect rules before `GenerateProxies`, which runs between provider registration and boot. Install the queue before framework code can schedule Guzzle work. An already-loaded vendor class cannot be replaced, so bootstrap ordering must guarantee interception or fail with an actionable error. Cover cold boot, cached proxies, repeated Testbench applications, and provider boot-time use. + +At `GenerateProxies`, reject a target already loaded from its original source before installing the generated class map. Accept an already-loaded generated proxy only when its rewritten methods cover the current rules. Apply this to all actual aspect targets, including the existing Sentry/Telescope Client aspects. PHP cannot replace a loaded class. The error must identify the class and explain lazy registration: bind a client factory in `register()` and instantiate after proxy generation, rather than eagerly loading the client there. Document this existing AOP loading requirement where the new built-in integration makes it relevant. Application-defined aspects have the same requirement in tests: avoid loading their targets in app-less tests before registration, or exercise them through application tests with the aspect registered before first use. Test that the early-load error names the class and provides this guidance, normal lazy registration works, and repeated compatible proxy boots remain valid. + +Have `ProxyCallVisitor` mark each method it actually rewrites with a framework-owned method attribute, such as `ProxyMethod`. At bootstrap, inspect this metadata on the loaded class without instantiating attributes; verify the concrete source methods selected by the current rules are covered. Exclude generated original-body helpers from that comparison. This records the loaded code's capabilities directly, without a separate mutable registry or using the file's process-wide fingerprint as a substitute. No per-call checks are needed. Preserve class-wide and wildcard behavior, and combine explicit constructor rules with class-wide rules so an application aspect cannot remove the ownership constructor interception. Test same/subset method requirements, additional required methods failing loudly, unrelated aspect changes, and mixed class/explicit-constructor rules. + +Use the same class-only method exclusions for rewriting and runtime aspect selection. An explicit constructor rule makes the constructor interceptable without opting unrelated whole-class aspects into it. Exact constructor rules and explicit method wildcards keep their existing behavior. + +Queue installation is idempotent: if `Utils::queue()` already returns the integration queue, retain it. Otherwise wrap its current queue as the non-coroutine delegate, preserving existing tasks and shutdown behavior. Do not create another shutdown-enabled `TaskQueue` on every application boot: its permanent shutdown closure retains it. Coroutine queues disable shutdown draining. Test repeated application boots preserve the same integration queue and outside delegate. + +Use existing context, cancellation, and test-reset primitives. Aspects are worker-safe services; all invocation state belongs in local variables or the owning coroutine. Ownership metadata is weakly keyed, with strong retention only for currently active transfers. Reset behavior must preserve already-generated proxies while clearing test-owned state; it must not erase live production ownership. + +Provide `flushState()` for static ownership bookkeeping, registered in `AfterEachTestSubscriber`'s framework cleanup. Cancel the current holder's remaining test-owned non-coroutine transfers before clearing ownership, aspect, or container state; do not execute abandoned outside-coroutine application callbacks during test reset. The existing context flush releases the holder. Keep queue installation idempotent across resets. Tests must join their coroutines and finish their queue work before teardown rather than rely on cleanup to run application code. + +Cancellation itself queues rejection handlers, so the test reset must also discard remaining outside-coroutine tasks. At the start of `AfterEachTestExtension::bootstrap()`, before test-state registrar discovery or integration installation, seed `Utils::queue()` with a shutdown-disabled stock `TaskQueue`; the integration retains this as its outside delegate. Use this shared extension, not components' `tests/bootstrap.php`, so application and split-package suites get the same behavior. At test reset, replace that delegate with a fresh shutdown-disabled queue after cancellation. No old shutdown closure then retains its callbacks. Do not merely replace a shutdown-enabled delegate: its original shutdown callback still retains and drains the old queue. Production continues to wrap the existing queue unchanged; test normal process-shutdown behavior in isolated processes using production bootstrap. + +Also use `AfterEachTestExtension::bootstrap()` to generate the relevant Guzzle proxy files before test discovery can load their classes. Store them in a per-worker directory from `ParallelTesting::tempDir()`, retained for that process and cleaned at shutdown. Cover only framework-owned aspects whose packages are installed: HTTP's Promise and multi-handler aspects, plus Sentry/Telescope's Client aspects when present. Generate from the union of their declared method rules without enabling optional observability at runtime or adding dependencies on optional packages. Restore pre-existing aspect rules afterward, and clear the visitor registry if it was empty before generation, so the first test has no extra registrations. Share aspect-default extraction with provider registration. Later application boots accept these already-loaded generated classes when their required methods are covered; test applications with and without optional instrumentation, verifying each aspect runs only when registered. Testbench providers activate their aspects normally; guard-dependent tests verify both proxy generation and active ownership interception. Keep clean-process bootstrap/error tests isolated. Adapt existing manual aspect tests to call the generated proxy directly where appropriate, avoiding double interception. Verify the shared extension through both components and package-mode tests; do not accept suite-order-dependent unproxied execution as validation. + +### HTTP connection options + +Named HTTP connections must retain their handler options on async requests. Add `Factory::newConnectionHandler()` as the uncached counterpart of `getConnectionHandler()`, sharing option derivation and the existing protected construction method. Async requests use a fresh handler with registered transport settings; sync requests retain the cached handler. Per-call preset replacement changes request options, not registered transport policy. Preserve explicit client/handler bypasses. Extend existing connection tests for async option forwarding, isolation and preset replacement, and clarify this in the HTTP client guide. + +### Class-map overrides + +Store registered replacements in a separate authoritative Composer loader instead of mutating and copying the complete application map. Keep their source entries separately from the loader's generated proxy paths, and overlay the source entries onto the temporary lookup used for proxy generation. Validate all proxies before publication. Publish ordinary proxies in the persistent proxy loader and replacement proxies in the override loader, so replacement cleanup removes both plain and generated replacements. Neither loader needs re-prepending on repeat boot. + +Use a fixed `.replacement.proxy.php` suffix for replacement proxies, preserving ordinary proxy filenames. The separate files prevent replacement generation from overwriting a file still referenced by the persistent loader. Files remain bounded to two per class, without per-release source hashes. + +Keep automatic class-map cleanup between tests: unregister and clear the override loader so unloaded replacements cannot leak into the next application. Re-registering a loaded target is valid only when its actual source matches the canonical replacement path. Use reflection for ordinary classes and a generated `ProxySource` class/trait attribute for proxies, whose reflection filename points to generated code. Read the attribute without instantiating it; no persistent source registry is needed. Reject different-source replacements and internal classes. + +Cover ordinary and proxied same-source registration after reset, different-source rejection, unloaded override cleanup with and without an existing ordinary proxy, and proxy priority across repeated boots. Exception rendering may fall back to the accurate file path for classless frames; it does not need another merged map. + +Generate proxies under `bootstrap/cache/aop`, alongside the release's other compiled framework files. Shared `storage` must not let an overlapping deployment overwrite proxy files that an older release has not loaded yet. Keep explicit `generate($directory)` calls unchanged. Update cache clearing, the `about` report, Testbench cleanup, documentation and benchmark cache isolation together; verify two application bases sharing storage preserve their own generated code. Ordinary PHP files must count as cached proxies in `about`. No migration or fallback for the old directory is needed. + +### Promise interception + +Intercept only `Promise::__construct`, `then`, `wait`, `cancel`, `resolve`, and `reject`. + +- Record ownership after successful construction. +- Before an operation, check the caller against the owner only where pending work or an unresolved adoption remains. Keep Guzzle's native behavior and errors for completed operations. +- Before accepting a pending adopted promise, verify its ownership. Record adoption only after the underlying settlement succeeds; a rejected self-resolution must not leave a self-reference or stale bookkeeping. +- Determine completion through adopted promises, including chains. Preserve fulfillment, rejection, callback ordering, cancellation, `otherwise()`, and `wait(false)` behavior within the owner. +- Release transfer accounting when its native transfer promise settles or cancels. Do not retain every completed promise for the worker lifetime. + +### Transport interception and retention + +Intercept `CurlMultiHandler::__invoke`, `tick`, `execute`, and `close` where provided by the installed supported version. + +- Reserve the handler for the current owner **before** invoking request preparation, because reading an upload body can yield. Same-owner overlapping transfers share the owner and an active count. +- Convert a successful pending transfer into active-transfer accounting. Release the reservation on a preparation exception or an immediately settled result. +- Reject a different owner attempting admission, driving, or closing while reservations or transfers remain. Release ownership after the final transfer completes, allowing sequential reuse. +- Use one release path so success, rejection, cancellation, and preparation failure cannot diverge in their accounting. +- Keep each coroutine's active transfer promises alive until settlement or exit cancellation. This prevents another coroutine's garbage collection from destroying a native multi handle while its owner is still using it. +- Retain non-coroutine transfers until settlement or process shutdown. Do not allow a request's garbage collection to destroy native handles created outside coroutines. +- Bound retention by outstanding work, not total requests served. Do not introduce a worker-wide strong registry of every promise, client, or callback. + +### Shared AWS credential providers + +Use one shared wrapper under `Hypervel\Support\Aws` for user-supplied callable credential providers in SQS, S3, and SES v2. These packages already consume Support; do not make filesystem or mail depend on queue. Declare direct coroutine/engine dependencies used by the wrapper in the split-package metadata. + +- Serialize provider invocation **and its promise wait** with the existing `Locker`, using the provider's underlying callable identity across separate wrappers and consumers. Bound-object methods share their object's lock; invokable-object and `[$object, '__invoke']` forms converge. Normalize named function/static callable identities without retaining an unbounded lookup registry. +- Each caller invokes the provider in its own context and gets its own completed result or rejection. No extra credential cache, failure cache, leader-result broadcasting, or tenant-result sharing. +- Acquire until `Locker::lock()` grants ownership; always unlock in `finally`. Preserve the original cancellation exception. Where AWS translates a canceled fetch into its own exception, restore cancellation only when the coroutine is actually canceled, preserving the SDK exception as the cause. +- Permit provider reentry from callbacks drained by the same native coroutine. Track only active lock owners by callable identity; the outermost frame acquires and releases the lock, and nested frames still wait for their own completed result. Other coroutines remain serialized. Clear owner metadata before unlocking and in test cleanup. +- Preserve explicit SDK memoization and SQS `AwsCredentialCache` behavior. Framework-built per-client provider chains remain per-client. Wrap effective custom providers at the client construction boundary after configuration precedence is resolved; preserve arrays, anonymous credentials, and supported provider selectors. +- Preserve pool identity: derive pool definitions and fingerprints from the original construction config before wrapping the provider. For S3, wrap in `createS3Client()`, not `s3ClientConfig()`, which also supplies the fingerprint. Verify equivalent SQS/S3/SES configurations with the same provider and explicit `pool.fingerprint` continue to share a pool. +- Do not infer aliases between distinct closures hiding the same provider. Do not change default pooling or broadly replace SDK handlers. + +### Pusher and Reverb + +Update the default client constructed by `BroadcastManager::pusher()` so synchronous requests retain reusable low-level transport and asynchronous requests share a handler only within the same client and native coroutine. Reverb uses the same construction path. Preserve same-coroutine promise fan-out and connection reuse; per-transfer handlers silently serialize that supported work. + +- Build a normal Guzzle handler stack around this dispatch choice; preserve middleware behavior. +- Lazily store async handlers in one broadcasting-owned `NonCopyableContext` holder containing a `WeakMap`, keyed by the client's stable shared-handler closure. Use a fixed coroutine-context key, not a worker-wide registry. Children copying context get their own holder; weak keys release handlers with their clients, and coroutine exit releases the holder. Transfer cancellation must run before context destruction. Outside-coroutine storage retains ordinary reuse and the existing context test reset. +- In the default path, move `transport_sharing` from client options to handler construction because Hypervel now supplies the handler. Pass `multiplex` to handler construction and preserve its request default. +- Preserve timeouts and caller callbacks such as `on_stats`. When the user supplies a custom handler, bypass the default-handler adaptation and pass its options unchanged to Guzzle, preserving Guzzle's own validation. Custom-handler ownership remains the caller's responsibility under the global guard. +- In the default-handler path, reject `max_host_connections` and `max_total_connections` with an actionable broadcasting-specific explanation: bound coroutine concurrency or rate limit instead of sharing one multi-handler across requests. Do not import HTTP's entire reserved-options policy; its unrelated cookie/handler/pool restrictions do not apply here. + +## Formatter correctness + +Disable PHP-CS-Fixer's `return_assignment` rule: it removes assignments captured by reference in nested closures, breaking the normal Guzzle wait-callback pattern. Keep valid code intact and disable the unsafe transformation explicitly; do not add dummy annotations or a custom fixer. + +## Correctness validation + +Use deterministic synchronization where practical to force the failing interleaving. Tests must assert behavior, callback context, resources, and errors rather than print results. Use isolated subprocesses for real proxy loading, dependency variants, native crash reproductions, and non-coroutine shutdown behavior. Give local servers dynamic ports and unconditional cleanup. + +Demonstrate the representative regressions fail on unchanged baseline code, using the same test/probe body and compatible bootstrap. Distinguish a reproduced defect from a new-API missing-class failure. For crash cases, assert subprocess exit/output rather than crashing the suite runner. Keep the original failure evidence and exact commands. + +Required coverage: + +| Area | Assertions | +| --- | --- | +| Queue/context isolation | A yielding queue drain cannot run another request's sync middleware, async callback, or event listener; request/tenant values and a real database transaction remain with the correct coroutine. Children do not inherit queues through either fork API or context-copying parallel execution. Concurrent synchronous requests through one shared default Guzzle client, including redirects and caller `on_stats`, still succeed in their respective contexts. | +| Promise operations | Owner operations work; foreign pending registration/wait/cancel/settlement/adoption fail before side effects; completed fulfillment and rejection remain reusable; adopted pending chains remain protected; failed self-settlement leaves no stale state; ordinary callback ordering and exception semantics hold. | +| Transport admission | Preparation yields, throws, or completes immediately; foreign admission is refused before native work; same-owner transfers and later sequential reuse work; successful uploads finish on both dependency families. | +| Cleanup | Fulfillment, rejection, cancellation, owner exit with abandoned transfers, and later GC release ownership exactly once; unrelated requests cannot execute abandoned callbacks; non-coroutine shutdown retains its queue behavior and native handle lifetime; repeated batches do not accumulate completed work. | +| AWS consumers | Exercise SQS, S3, and SES through their actual manager/client construction and pooling, not bare SDK clients misrepresented as defaults. Reused provider objects and supported callable forms serialize, callers receive their own context-specific credentials, memoized completed credentials remain reusable, and lock owners/waiters clean up on failure/cancellation. | +| Broadcasting | Concurrent public `triggerAsync()`/`triggerBatchAsync()` via Pusher and Reverb, sync requests, custom handler behavior, option forwarding, cap errors, and caller callbacks. | +| Framework integration | HTTP client sync/async and coroutine concurrency, lazy streaming bodies read after `send()` returns (including supported sequential coroutine handoff), relevant Saloon paths, queue/filesystem/mail/broadcasting consumers, and existing Sentry/Telescope callbacks with the integration active or observability disabled. Verify `CurlStreamingBody`'s read-time promise completion without extending the pending-promise restriction to its caller-owned stream. | +| Bootstrap | HTTP and console boot, cached proxy reuse, repeated Testbench applications, no Sentry/Telescope installed/enabled, and installed split-package requirements. No silent unproxied target classes. | + +Run upstream `guzzlehttp/promises` tests with production integration enabled for both supported dependency families, preserving applicable upstream assertions. Also test ordinary non-coroutine behavior. Use independently installed dependency environments. Preserve current supported Composer ranges rather than dropping a major to simplify the fix. + +Make dependency drift visible through ordinary CI tests. Keep the existing full-suite jobs' unlocked dependency resolution unchanged; they test what an ordinary framework install resolves, currently Guzzle 8/promises 3. Add only a focused Guzzle 7/promises 2 compatibility job, constraining PSR-7 to its compatible 2.x family and running the ownership and affected AWS/broadcasting tests. Composer's `update --with` supplies temporary constraints without changing manifests. Verify real proxy interception of every targeted method, constructor ownership, and the behavioral regression suite; signature checks alone cannot detect changed scheduling semantics. Reuse PHPUnit and Composer, without a custom source-drift analyzer or another full-suite matrix. Keep upstream-suite validation reproducible with the same integration bootstrap. + +PHPUnit setup/teardown runs outside the test coroutine, and Foundation setup can create temporary coroutines. Create and finish pending promise work in the coroutine that drives it; do not create pending fixtures in setup and pass them into the test coroutine. Full-suite validation must catch these lifecycle mismatches. Correct fixture ownership without weakening production checks or changing the behavior a test protects. Validate package-mode bootstrap as well as the components suite. + +### Coroutine test deadlines + +Keep PHPUnit's native SIGALRM armed for non-yielding PHP code. When it has a time limit, start one deadline coroutine waiting on a private, capacity-one channel. A first-registered root defer signals completion after the other root defers; buffering prevents that signal from blocking if the deadline has expired. After the root finishes, observe remaining children with short native sleeps until they finish or the original deadline expires. Do not join test coroutines: Swoole only allows one joiner, and the code under test may need that slot. Channel and sleep timers survive `Timer::clearAll()`; respect Swoole's one-millisecond sleep minimum. Route expiry through PHPUnit's existing `TimeoutException`, including exceptions dispatched by the native signal handler during the wait callback. Cancel the root with an exception, which its invocation wrapper catches, and other children without throwing into raw callbacks. Fixtures still own terminating and reaping their external processes. + +Verify busy loops, sleeping tests, children blocked after the root exits, normal child completion with root and nested-child joins, and disabled limits. Assertions about coroutine counts must compare against the infrastructure's initial count. PHPUnit output assertions remain outside coroutine execution until its upstream output-buffer integration is available; track that integration in `docs/todo.md` rather than accessing private PHPUnit state. + +## Documentation and future maintenance + +Keep the user-facing ownership explanation and native-coroutine example in `src/docs/http-client.md`; link from relevant consumer documentation rather than duplicating the explanation. Explain where to create and finish async work, safe reuse of completed results, exit cancellation, custom-handler responsibilities, and deliberate unpooled/direct mode. Show a supported replacement for transferring pending work between requests. + +Update broadcasting, queue, filesystem, and mail documentation where their consumer changes require it. Record deliberate public restrictions in the relevant README differences sections and the Laravel porting guide; do not turn internal bookkeeping or bug fixes into a divergence inventory. Keep AOP rationale and supported-version maintenance details with the owning implementation/tests and this plan. + +## Benchmark and optimization protocol + +Run measurements on an idle machine without concurrent tests, builds, or other benchmarks. + +Keep the harness and reproducible commands under `tests/Benchmarks/GuzzleOwnership`, with a results summary beside them. Keep raw reports outside the repository. No production benchmark command or timing assertions in CI. + +1. Compare the unchanged baseline with the implementation, using independently installed dependencies and the exact same harness, PHP/Swoole/Guzzle versions, settings, origin, payloads, concurrency, and operation counts. Record commit IDs, runtime/extension versions, CPU/VM details, OPcache/JIT/GC, and commands in measurement output. +2. Separate cold proxy generation/startup, cached startup, and warm runtime. AOP still constructs join points and dispatches aspects per intercepted call; startup generation does not establish that runtime overhead is negligible. +3. Measure promise construction/chains and owner checks, synchronous local HTTP, owner-driven async HTTP, coroutine-parallel HTTP, middleware/listeners, representative credential-provider contention, and public Pusher async use. Include success, bounded cancellation/cleanup, and idle/no-Guzzle controls. Keep correctness checks outside timed loops where they would distort costs. +4. Use controlled baseline/no-op-aspect/full-guard variants in isolated benchmark processes to separate AOP dispatch cost from ownership bookkeeping where useful. These are harness variants, not production bypass flags. End-to-end comparisons use the complete production integration. +5. Measure wall time, process CPU, throughput and latency distribution at bounded increasing concurrency. Measure PHP allocated/peak memory, RSS, open descriptors, active transfer cleanup, and memory after repeated batches/GC. Account for harness bookkeeping and separate origin CPU; PHP heap alone does not describe native cURL resource costs. +6. Use short warmups and a small fixed set of alternating paired runs; report median and spread, absolute differences, and percentages. Increase repetitions only to resolve a specific remaining uncertainty. Do not blindly repeat large matrices, introduce CPU-pinning machinery, or treat noise as a regression or improvement. +7. Never compare an unsafe/crashing baseline with successful work as if it delivered equivalent throughput. Use safe matching workloads for overhead deltas and separately report that the corrected supported scenario completes reliably. Benchmark both dependency families on the focused promise/transport workloads; run the wider consumer matrix on the primary version and investigate material version differences. +8. Measure shared AOP dispatch and resource costs separately from Guzzle-specific costs. Inspect generated calls, chain construction, container resolution and allocation; consider worker-startup preparation or immutable caching where measurements justify it. Preserve aspect ordering, argument/reference semantics, short-circuiting, exceptions, dynamic container bindings, scoped/transient aspects, coroutine reentrancy and cleanup. Do not cache invocation state or aspect instances merely to save resolution work. Prefer direct simplification and removal of repeated work, and measure material changes. + +### Shared AOP optimization + +Replace the per-call generic AOP pipeline with a compiled chain of static closures cached by class and method in `AspectManager`. Each closure captures only its aspect class and next closure; resolve the current container and aspect on invocation. Publish the complete chain after priority resolution, retaining the existing ordering and cache-reset boundary. Never retain a join point, intercepted instance, application container or aspect instance in the cache. The method set bounds worker-lifetime storage. + +Remove the unused AOP-specific `Pipeline`; keep the general Laravel-style pipeline unchanged. Registered aspects use `process()`, including classes that do not extend `AbstractAspect`. Consolidate the removed pipeline tests into dispatcher behavior tests. Verify short-circuiting, original-method bypass, exception identity, dynamic container replacement/rebinding, yielding concurrent and nested calls, argument/reference semantics, and collection of invocation objects without cyclic GC. Use a scoped aspect and assert target identity in the concurrent-dispatch case rather than adding separate lifetime and instance-concurrency matrices. Existing generated-proxy and bootstrap coverage must continue to pass. + +Pass the intercepted instance from generated proxies to `ProceedingJoinPoint` instead of reflecting its closure on every `getInstance()` call. Pass null for static methods and update the manual aspect test helper and direct construction sites. Verify identity across multiple objects, constructors, static calls and concurrent invocations. Measure this separately from compiled dispatch. The existing generator fingerprint invalidates cached proxy files. Do not cache intercepted objects for the worker lifetime. + +Publish generated proxies through one dedicated Composer ClassLoader owned by GenerateProxies. Make it class-map-authoritative, give it no vendor directory, and prepend it only after proxies exist and generation plus loaded-class validation succeed. Reuse it across boots; its small map contains only proxy entries. Keep the application loader and original source paths unchanged, avoiding Composer's full-map copy when adding replacements. Preserve PSR-4 lookup, source overrides, regeneration, wildcard discovery and atomic publication on failure. + +Resolve the application loader through the first entry of ClassLoader::getRegisteredLoaders(), preserving the existing no-loader error and explicit test loader injection. This excludes the vendor-directory-less proxy loader even after Composer's cached loader is reset. GenerateProxies::flushState() unregisters and clears its loader, but must not be called by global per-test cleanup: pregenerated Guzzle proxies must survive test discovery and subsequent applications. Generator unit tests use an isolated subclass with its own static loader, and unregister only that loader during teardown. Retain real production bootstrap subprocess coverage and add the auxiliary-loader discovery regression. + +Select exact class rules through a direct map lookup after stripping the method suffix. For wildcard class rules, build the pattern once and use `preg_grep` on the class names, allocating that local name list only when a wildcard is encountered. Keep selection order and deduplication. Verify application-map preservation and mixed exact/wildcard selection through existing bootstrap tests; measure startup and memory separately from runtime dispatch. + +Initialize Ast's parser only in `parse()` and its printer only when printing generated code; `proxy()` delegates parsing to `parse()`. Keep Ast local to each generation call. Cached proxy boot must not construct or load the unused parser and printer. Extend the existing cold/cached bootstrap subprocess case to verify these classes load on cold generation and remain unloaded on cached boot, while ownership still works. Preserve visitor reflection and all generation semantics; measure the startup and memory effect separately. + +Fingerprint the generator's PHP source files with a sorted `glob` and the existing filesystem reads. Avoid loading Symfony Finder solely to enumerate this fixed directory. + +The result table must include workload, versions/concurrency, baseline and final time or throughput, absolute/percentage delta, memory/RSS and cleanup differences, sample spread, and relevant limitations. Rerun affected comparisons after an optimization. Never describe unmeasured overhead as noise or invent acceptable thresholds. + +## Measurements + +The [benchmark report](../../tests/Benchmarks/GuzzleOwnership/results.md) records the shared AOP improvements, ownership overhead, retained memory, cleanup behavior, sample ranges, and dependency versions. It also translates measured per-request CPU into example deployment capacity costs, distinguishing those projections from measured throughput. diff --git a/docs/todo.md b/docs/todo.md index 162d69cea..41be125c2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -30,6 +30,7 @@ ## Testing +- Integrate the resolution of [PHPUnit #7039](https://github.com/sebastianbergmann/phpunit/issues/7039) into `RunTestsInCoroutine` once available; [PR #7040](https://github.com/sebastianbergmann/phpunit/pull/7040) proposes suspend/resume output buffering. Follow the accepted upstream design, including calling `parent::invokeTestMethod()` if capture is handled there. Add regression coverage for output expectations, flushed output and buffer cleanup; update the testing guidance and reassess output-related coroutine opt-outs. - Lift the PHPUnit `13.3.*` pin in the root, Testbench and dogfood package manifests, and in the `hypervel/hypervel` application skeleton, once ParaTest supports PHPUnit 13.4. PHPUnit 13.4.0 made the internal `PhpHandler` constructor require an event emitter, and ParaTest 7.25.0 and Hypervel's `RunsInParallel` still construct it without one, so parallel runs fail before any test starts. When lifting the pin, pass the emitter to `PhpHandler` in `RunsInParallel`, as PHPUnit 13.4's own `Application` does. ## Filesystem diff --git a/src/broadcasting/README.md b/src/broadcasting/README.md index 5771df726..810f0d1c9 100644 --- a/src/broadcasting/README.md +++ b/src/broadcasting/README.md @@ -11,6 +11,8 @@ The outgoing channel formatter and incoming channel authorizer are also worker-w Custom drivers may opt into Hypervel's connection pooling through the broadcast manager. +The default Pusher/Reverb client rejects Guzzle's `max_host_connections` and `max_total_connections` options because enforcing them across coroutines requires an unsafe shared multi-handler. Bound broadcast concurrency or use rate limiting instead. + Mercure uses a standalone HTTP hub. FrankenPHP's in-process `mercure_publish()` integration is not available under Swoole. The broadcast service provider does not implement Laravel's `DeferrableProvider` marker because Hypervel has no deferred service provider mechanism. diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index db6b02dba..45ffab310 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -26,10 +26,12 @@ "require": { "php": "^8.4", "guzzlehttp/guzzle": "^7.15.2 || ^8.2", + "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", "hypervel/connection-pool": "^0.4", "hypervel/container": "^0.4", + "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", @@ -38,6 +40,7 @@ "hypervel/reflection": "^0.4", "hypervel/routing": "^0.4", "hypervel/support": "^0.4", + "psr/http-message": "^2.0", "psr/log": "^3.0", "symfony/http-kernel": "^8.1" }, diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 541a6a8e5..58ab0aeb0 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -7,6 +7,10 @@ use Ably\AblyRest; use Closure; use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Multiplexing; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Utils; use Hypervel\Broadcasting\Broadcasters\AblyBroadcaster; use Hypervel\Broadcasting\Broadcasters\Broadcaster as BaseBroadcaster; use Hypervel\Broadcasting\Broadcasters\LogBroadcaster; @@ -17,6 +21,7 @@ use Hypervel\Broadcasting\Mercure\CreatesMercureDrivers; use Hypervel\Bus\DispatchLockContext; use Hypervel\Bus\UniqueLock; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Broadcasting\Broadcaster; use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactoryContract; use Hypervel\Contracts\Broadcasting\ShouldBeUnique; @@ -42,6 +47,7 @@ use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use Hypervel\Support\RebindsCallbacksToSelf; use InvalidArgumentException; +use Psr\Http\Message\RequestInterface; use Psr\Log\LoggerInterface; use Pusher\Pusher; use ReflectionException; @@ -62,6 +68,8 @@ class BroadcastManager implements BroadcastingFactoryContract use RebindsCallbacksToSelf; use ResolvesQueueRoutes; + protected const string PUSHER_HANDLERS_CONTEXT_KEY = '__broadcasting.pusher_handlers'; + /** * The array of resolved broadcast drivers. */ @@ -395,17 +403,51 @@ protected function createPusherDriver(array $config): Broadcaster */ public function pusher(array $config): Pusher { - $guzzleClient = new GuzzleClient( - array_merge( - [ - 'connect_timeout' => 10, - 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, - 'timeout' => 30, - ], - $config['client_options'] ?? [], - ), + $clientOptions = array_merge( + [ + 'connect_timeout' => 10, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, + 'timeout' => 30, + ], + $config['client_options'] ?? [], ); + if (! isset($clientOptions['handler'])) { + foreach (['max_host_connections', 'max_total_connections'] as $option) { + if (isset($clientOptions[$option])) { + throw new InvalidArgumentException( + "The [{$option}] broadcasting client option requires a shared multi-handler, which is unsafe across coroutines. Bound coroutine concurrency or use rate limiting instead." + ); + } + } + + $handlerOptions = Arr::only($clientOptions, ['transport_sharing']); + unset($clientOptions['transport_sharing']); + + if (($clientOptions['multiplex'] ?? null) === Multiplexing::NONE) { + $handlerOptions['multiplex'] = Multiplexing::NONE; + } + + $sharedHandler = Utils::chooseHandler($handlerOptions)(...); + + // Async transfers may share a multi-handler only within their owning coroutine. + $clientOptions['handler'] = HandlerStack::create( + static function (RequestInterface $request, array $options) use ($sharedHandler, $handlerOptions): PromiseInterface { + if (! empty($options['synchronous'])) { + return $sharedHandler($request, $options); + } + + $context = CoroutineContext::get(self::PUSHER_HANDLERS_CONTEXT_KEY) + ?? CoroutineContext::set(self::PUSHER_HANDLERS_CONTEXT_KEY, new PusherHandlerContext); + $handler = $context->handlers[$sharedHandler] ??= Utils::chooseHandler($handlerOptions); + + return $handler($request, $options); + }, + ); + } + + $guzzleClient = new GuzzleClient($clientOptions); + $pusher = new Pusher( $config['key'], $config['secret'], diff --git a/src/broadcasting/src/PusherHandlerContext.php b/src/broadcasting/src/PusherHandlerContext.php new file mode 100644 index 000000000..520b0b452 --- /dev/null +++ b/src/broadcasting/src/PusherHandlerContext.php @@ -0,0 +1,26 @@ + */ + public readonly WeakMap $handlers; + + /** + * Create a coroutine-local collection of client handlers. + */ + public function __construct() + { + $this->handlers = new WeakMap; + } +} diff --git a/src/cache/src/Console/ClearCommand.php b/src/cache/src/Console/ClearCommand.php index ca5980455..ddd12c3e8 100644 --- a/src/cache/src/Console/ClearCommand.php +++ b/src/cache/src/Console/ClearCommand.php @@ -130,7 +130,7 @@ protected function cache(): Repository */ public function flushProxies(): void { - $this->files->deleteDirectory(storage_path('framework/aop')); + $this->files->deleteDirectory($this->hypervel->bootstrapPath('cache/aop')); } /** diff --git a/src/di/composer.json b/src/di/composer.json index 056a7e298..46f6e7740 100644 --- a/src/di/composer.json +++ b/src/di/composer.json @@ -37,7 +37,6 @@ "hypervel/collections": "^0.4", "hypervel/container": "^0.4", "hypervel/filesystem": "^0.4", - "hypervel/pipeline": "^0.4", "hypervel/reflection": "^0.4", "hypervel/support": "^0.4", "nikic/php-parser": "^5.7" diff --git a/src/di/src/Aop/Aspect.php b/src/di/src/Aop/Aspect.php index e3d648061..304fb6357 100644 --- a/src/di/src/Aop/Aspect.php +++ b/src/di/src/Aop/Aspect.php @@ -47,6 +47,10 @@ public static function isMatchClassRule(string $target, string $rule): array [$class, $method] = explode('::', $target); } + if ($ruleMethod === null && in_array($method, RewriteCollection::CLASS_EXCLUDED_METHODS, true)) { + return [false, null]; + } + if ($method === null) { if (! str_contains($ruleClass, '*')) { /* @@ -132,7 +136,7 @@ private static function parseClasses(array $collection, string $class, RewriteCo if ($isMatch) { if ($method === null) { $rewriteCollection->setLevel(RewriteCollection::CLASS_LEVEL); - return; + continue; } $rewriteCollection->add($method); } diff --git a/src/di/src/Aop/AspectCollector.php b/src/di/src/Aop/AspectCollector.php index c417dec7c..b2e6b2b3f 100644 --- a/src/di/src/Aop/AspectCollector.php +++ b/src/di/src/Aop/AspectCollector.php @@ -4,6 +4,9 @@ namespace Hypervel\Di\Aop; +use Hypervel\Support\ClassMetadataCache; +use ReflectionProperty; + /** * Static registry of aspect class rules and priorities. * @@ -21,6 +24,32 @@ class AspectCollector */ protected static array $aspectRules = []; + /** + * Register an aspect using its public default targeting rules and priority. + * + * Boot-only. Register before proxy generation; loaded classes cannot be rewritten. + * + * @param class-string $aspect + */ + public static function register(string $aspect): void + { + $reflectionClass = ClassMetadataCache::reflectClass($aspect); + $properties = $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC); + + $classes = []; + $priority = null; + + foreach ($properties as $property) { + if ($property->getName() === 'classes') { + $classes = $property->getDefaultValue(); + } elseif ($property->getName() === 'priority') { + $priority = $property->getDefaultValue(); + } + } + + static::setAround($aspect, $classes, $priority); + } + /** * Register an aspect with its class targeting rules. * diff --git a/src/di/src/Aop/AspectManager.php b/src/di/src/Aop/AspectManager.php index 75b17b379..835c244a4 100644 --- a/src/di/src/Aop/AspectManager.php +++ b/src/di/src/Aop/AspectManager.php @@ -4,41 +4,36 @@ namespace Hypervel\Di\Aop; +use Closure; + /** - * Runtime cache of resolved aspects per class::method. - * - * Once the aspect pipeline for a given class method is resolved, - * it is cached here so subsequent calls skip the resolution logic. + * Cache immutable aspect chains for the worker's intercepted methods. */ class AspectManager { /** - * @var array>> - */ - protected static array $container = []; - - /** - * Get the resolved aspects for a class method. + * @var array> */ - public static function get(string $class, string $method): array - { - return static::$container[$class][$method] ?? []; - } + protected static array $chains = []; /** - * Determine if aspects have been resolved for a class method. + * Get the compiled aspect chain for a class method. + * + * @return null|Closure(ProceedingJoinPoint): mixed */ - public static function has(string $class, string $method): bool + public static function get(string $class, string $method): ?Closure { - return isset(static::$container[$class][$method]); + return static::$chains[$class][$method] ?? null; } /** - * Set the resolved aspects for a class method. + * Set the compiled aspect chain for a class method. + * + * @param Closure(ProceedingJoinPoint): mixed $value */ - public static function set(string $class, string $method, array $value): void + public static function set(string $class, string $method, Closure $value): void { - static::$container[$class][$method] = $value; + static::$chains[$class][$method] = $value; } /** @@ -46,6 +41,6 @@ public static function set(string $class, string $method, array $value): void */ public static function flushState(): void { - static::$container = []; + static::$chains = []; } } diff --git a/src/di/src/Aop/Ast.php b/src/di/src/Aop/Ast.php index 1dccbeb6d..742ea9e5f 100644 --- a/src/di/src/Aop/Ast.php +++ b/src/di/src/Aop/Ast.php @@ -12,21 +12,17 @@ class Ast { - private Parser $astParser; + private ?Parser $astParser = null; - private PrettyPrinterAbstract $printer; - - public function __construct() - { - $this->astParser = (new ParserFactory)->createForNewestSupportedVersion(); - $this->printer = new Standard; - } + private ?PrettyPrinterAbstract $printer = null; /** * Parse PHP code into an AST. */ public function parse(string $code): ?array { + $this->astParser ??= (new ParserFactory)->createForNewestSupportedVersion(); + return $this->astParser->parse($code); } @@ -38,7 +34,7 @@ public function parse(string $code): ?array */ public function proxy(string $className, string $sourceFilePath, string $sourceCode): string { - $stmts = $this->astParser->parse($sourceCode) ?? []; + $stmts = $this->parse($sourceCode) ?? []; $traverser = new NodeTraverser; $visitorMetadata = new VisitorMetadata($className, $sourceFilePath); @@ -51,6 +47,7 @@ public function proxy(string $className, string $sourceFilePath, string $sourceC } $modifiedStmts = $traverser->traverse($stmts); + $this->printer ??= new Standard; return $this->printer->prettyPrintFile($modifiedStmts); } diff --git a/src/di/src/Aop/Pipeline.php b/src/di/src/Aop/Pipeline.php deleted file mode 100644 index 103983028..000000000 --- a/src/di/src/Aop/Pipeline.php +++ /dev/null @@ -1,46 +0,0 @@ -container->make($pipe); - } - if (! $passable instanceof ProceedingJoinPoint) { - throw new InvalidDefinitionException('$passable must be a ProceedingJoinPoint object.'); - } - $passable->pipe = $stack; - return method_exists($pipe, $this->method) ? $pipe->{$this->method}($passable) : $pipe($passable); - }; - }; - } -} diff --git a/src/di/src/Aop/ProceedingJoinPoint.php b/src/di/src/Aop/ProceedingJoinPoint.php index a1c91fa17..847272ff9 100644 --- a/src/di/src/Aop/ProceedingJoinPoint.php +++ b/src/di/src/Aop/ProceedingJoinPoint.php @@ -7,7 +7,6 @@ use Closure; use Hypervel\Di\Exceptions\Exception; use Hypervel\Support\ClassMetadataCache; -use ReflectionFunction; use ReflectionMethod; class ProceedingJoinPoint @@ -16,16 +15,20 @@ class ProceedingJoinPoint public ?Closure $pipe = null; + /** + * Create an invocation of an intercepted method. + */ public function __construct( public Closure $originalMethod, public string $className, public string $methodName, - public array $arguments + public array $arguments, + protected readonly ?object $instance ) { } /** - * Delegate to the next aspect in the pipeline. + * Delegate to the next aspect in the chain. */ public function process(): mixed { @@ -88,12 +91,10 @@ public function getReflectMethod(): ReflectionMethod } /** - * Get the object instance the original method is bound to. + * Get the object instance whose method was intercepted. */ public function getInstance(): ?object { - $ref = new ReflectionFunction($this->originalMethod); - - return $ref->getClosureThis(); + return $this->instance; } } diff --git a/src/di/src/Aop/ProxyCallVisitor.php b/src/di/src/Aop/ProxyCallVisitor.php index fb787637e..b5b1fedc8 100644 --- a/src/di/src/Aop/ProxyCallVisitor.php +++ b/src/di/src/Aop/ProxyCallVisitor.php @@ -8,6 +8,8 @@ use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\ArrayItem; +use PhpParser\Node\Attribute; +use PhpParser\Node\AttributeGroup; use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Assign; @@ -219,6 +221,11 @@ public function leaveNode(Node $node) $isTarget = array_pop($this->classLikeStack); if ($isTarget && ! $node instanceof Interface_) { + $node->attrGroups[] = new AttributeGroup([ + new Attribute(new FullyQualified(ProxySource::class), [ + new Arg(new String_($this->visitorMetadata->sourceFilePath)), + ]), + ]); array_unshift($node->stmts, new TraitUse([ new FullyQualified(ProxyMarker::class), ])); @@ -258,6 +265,7 @@ private function rewriteMethod(ClassMethod $node): array new Arg(new MagicConstFunction), new Arg($this->buildArgumentMap($node->params)), new Arg($this->buildForwardingClosure($node)), + new Arg($node->isStatic() ? new ConstFetch(new Name('null')) : new Variable('this')), ]); if ($this->returnsByExpression($node)) { @@ -267,6 +275,9 @@ private function rewriteMethod(ClassMethod $node): array } $node->stmts = $statements; + $node->attrGroups[] = new AttributeGroup([ + new Attribute(new FullyQualified(ProxyMethod::class), [new Arg(new String_($this->helperMethodName))]), + ]); return [$node, $helper]; } @@ -706,7 +717,7 @@ private function beginMethodRewrite(ClassMethod $method): void { $this->activeMethod = $method; $this->nestedFunctionStack = []; - $hash = substr(hash('sha256', $this->targetClassName . '::' . $method->name->toString()), 0, 12); + $hash = substr(hash('xxh128', $this->targetClassName . '::' . $method->name->toString()), 0, 12); $this->helperMethodName = $this->reserveMethodName("__hypervelAopOriginal_{$hash}"); $usedVariables = []; diff --git a/src/di/src/Aop/ProxyDispatcher.php b/src/di/src/Aop/ProxyDispatcher.php index 6a4dbb584..735baa232 100644 --- a/src/di/src/Aop/ProxyDispatcher.php +++ b/src/di/src/Aop/ProxyDispatcher.php @@ -12,32 +12,26 @@ class ProxyDispatcher { /** - * Dispatch an intercepted method through its aspect pipeline. + * Dispatch an intercepted method through its aspect chain. */ public static function dispatch( string $className, string $methodName, array $arguments, - Closure $originalMethod + Closure $originalMethod, + ?object $instance ): mixed { $proceedingJoinPoint = new ProceedingJoinPoint( $originalMethod, $className, $methodName, - $arguments + $arguments, + $instance ); - $aspects = self::resolveAspects($className, $methodName); + $chain = AspectManager::get($className, $methodName) ?? self::compile($className, $methodName); - if ($aspects === []) { - return $proceedingJoinPoint->processOriginalMethod(); - } - - return (new Pipeline(Container::getInstance())) - ->via('process') - ->through($aspects) - ->send($proceedingJoinPoint) - ->then(static fn (ProceedingJoinPoint $point) => $point->processOriginalMethod()); + return $chain($proceedingJoinPoint); } /** @@ -101,16 +95,12 @@ public static function captureVariadicArguments(array &$arguments, int $limit, b } /** - * Resolve and cache the aspects for a class method. + * Compile and cache the ordered aspect chain for a class method. * - * @return array + * @return Closure(ProceedingJoinPoint): mixed */ - private static function resolveAspects(string $className, string $methodName): array + private static function compile(string $className, string $methodName): Closure { - if (AspectManager::has($className, $methodName)) { - return AspectManager::get($className, $methodName); - } - $matchedAspects = []; foreach (AspectCollector::getClassRules() as $aspect => $rules) { @@ -124,7 +114,7 @@ private static function resolveAspects(string $className, string $methodName): a $queue = new SplPriorityQueue; - foreach (array_unique($matchedAspects) as $aspect) { + foreach ($matchedAspects as $aspect) { $queue->insert($aspect, AspectCollector::getPriority($aspect)); } @@ -135,10 +125,21 @@ private static function resolveAspects(string $className, string $methodName): a $queue->next(); } - // Publish only the complete immutable list so another coroutine can never + $chain = static fn (ProceedingJoinPoint $point): mixed => $point->processOriginalMethod(); + + foreach (array_reverse($resolvedAspects) as $aspect) { + $next = $chain; + $chain = static function (ProceedingJoinPoint $point) use ($aspect, $next): mixed { + $point->pipe = $next; + + return Container::getInstance()->make($aspect)->process($point); + }; + } + + // Publish only the complete immutable chain so another coroutine can never // observe a cache entry while it is still being assembled. - AspectManager::set($className, $methodName, $resolvedAspects); + AspectManager::set($className, $methodName, $chain); - return $resolvedAspects; + return $chain; } } diff --git a/src/di/src/Aop/ProxyManager.php b/src/di/src/Aop/ProxyManager.php index 04e3fd1fb..56df816f9 100644 --- a/src/di/src/Aop/ProxyManager.php +++ b/src/di/src/Aop/ProxyManager.php @@ -27,12 +27,16 @@ class ProxyManager private ?string $commonFingerprint = null; /** + * Generate proxies from the supplied source map. + * * @param array $classMap Map of class names to their source file paths * @param string $proxyDir Directory where proxy files are written + * @param array $replacements Source overrides whose proxies need separate files */ public function __construct( protected array $classMap = [], - protected string $proxyDir = '' + protected string $proxyDir = '', + protected array $replacements = [] ) { $this->filesystem = new Filesystem; $this->proxies = $this->generateProxyFiles($this->initProxiesByReflectionClassMap( @@ -118,28 +122,10 @@ protected function getProxyFilePath(string $className): string return rtrim($this->getProxyDir(), '/\\') . DIRECTORY_SEPARATOR . rawurlencode($className) + . (isset($this->replacements[$className]) ? '.replacement' : '') . '.proxy.php'; } - /** - * Determine if a rule matches a target class name. - */ - protected function isMatch(string $rule, string $target): bool - { - if (str_contains($rule, '::')) { - [$rule] = explode('::', $rule); - } - - if (! str_contains($rule, '*') && $rule === $target) { - return true; - } - - $preg = str_replace(['*', '\\'], ['.*', '\\\\'], $rule); - $pattern = "/^{$preg}$/"; - - return preg_match($pattern, $target) === 1; - } - /** * Determine which classes in the class map need proxy generation. * @@ -153,13 +139,24 @@ protected function initProxiesByReflectionClassMap(array $reflectionClassMap = [ } $proxies = []; + $classNames = null; foreach (AspectCollector::getClassRules() as $rules) { foreach ($rules as $rule) { - foreach ($reflectionClassMap as $class => $path) { - if ($this->isMatch($rule, $class)) { - $proxies[$class] = true; + [$classRule] = explode('::', $rule, 2); + + if (! str_contains($classRule, '*')) { + if (isset($reflectionClassMap[$classRule])) { + $proxies[$classRule] = true; } + + continue; + } + + $pattern = str_replace(['*', '\\'], ['.*', '\\\\'], $classRule); + + foreach (preg_grep("/^{$pattern}$/", $classNames ??= array_keys($reflectionClassMap)) as $class) { + $proxies[$class] = true; } } } @@ -197,7 +194,7 @@ private function readSource(string $className, string $sourceFilePath): array */ private function fingerprint(string $className, string $sourceFilePath, string $sourceCode): string { - return hash('sha256', serialize([ + return hash('xxh128', serialize([ 'common' => $this->commonFingerprint ??= $this->buildCommonFingerprint(), 'class' => $className, 'source_path' => $sourceFilePath, @@ -210,7 +207,7 @@ private function fingerprint(string $className, string $sourceFilePath, string $ */ private function buildCommonFingerprint(): string { - return hash('sha256', serialize([ + return hash('xxh128', serialize([ 'aspect_rules' => AspectCollector::getRules(), 'visitors' => $this->visitorFingerprints(), 'aop_source' => $this->aopSourceFingerprint(), @@ -258,21 +255,11 @@ private function aopSourceFingerprint(): string { $sources = []; - foreach ($this->filesystem->files(__DIR__) as $file) { - if ($file->getExtension() !== 'php') { - continue; - } - - $path = $file->getRealPath(); - - if ($path === false) { - throw new InvalidDefinitionException('Unable to fingerprint the AOP generator source.'); - } - - $sources[$file->getFilename()] = $this->filesystem->get($path); + foreach (glob(__DIR__ . '/*.php') as $path) { + $sources[basename($path)] = $this->filesystem->get($path); } - return hash('sha256', serialize($sources)); + return hash('xxh128', serialize($sources)); } /** @@ -300,7 +287,7 @@ private function readEmbeddedFingerprint(string $proxyFilePath): ?string $fingerprint = trim(substr($header, strlen(self::FINGERPRINT_HEADER))); return str_starts_with($header, self::FINGERPRINT_HEADER) - && preg_match('/^[a-f0-9]{64}$/D', $fingerprint) === 1 + && preg_match('/^[a-f0-9]{32}$/D', $fingerprint) === 1 ? $fingerprint : null; } diff --git a/src/di/src/Aop/ProxyMethod.php b/src/di/src/Aop/ProxyMethod.php new file mode 100644 index 000000000..fc0965bc4 --- /dev/null +++ b/src/di/src/Aop/ProxyMethod.php @@ -0,0 +1,18 @@ +level === self::CLASS_LEVEL) { - if (in_array($method, $this->shouldNotRewriteMethods, true)) { - return false; - } + if ($this->level === self::CLASS_LEVEL && ! in_array($method, self::CLASS_EXCLUDED_METHODS, true)) { return true; } @@ -110,10 +105,10 @@ public function getClass(): string } /** - * Get the methods that should never be rewritten. + * Get the methods excluded by class-only rules. */ public function getShouldNotRewriteMethods(): array { - return $this->shouldNotRewriteMethods; + return self::CLASS_EXCLUDED_METHODS; } } diff --git a/src/di/src/Bootstrap/GenerateProxies.php b/src/di/src/Bootstrap/GenerateProxies.php index 8525d8be3..e0cffc012 100644 --- a/src/di/src/Bootstrap/GenerateProxies.php +++ b/src/di/src/Bootstrap/GenerateProxies.php @@ -4,12 +4,19 @@ namespace Hypervel\Di\Bootstrap; +use Composer\Autoload\ClassLoader; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Di\Aop\Aspect; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\AstVisitorRegistry; use Hypervel\Di\Aop\ProxyCallVisitor; use Hypervel\Di\Aop\ProxyManager; +use Hypervel\Di\Aop\ProxyMarker; +use Hypervel\Di\Aop\ProxyMethod; +use Hypervel\Di\ClassMap\ClassMapManager; +use Hypervel\Di\Exceptions\InvalidDefinitionException; use Hypervel\Support\Composer; +use ReflectionClass; /** * Generate AOP proxy classes for registered aspects. @@ -21,17 +28,24 @@ */ class GenerateProxies { - /** - * The source class map used for proxy generation. - * - * @var null|array - */ - protected static ?array $sourceClassMap = null; + protected static ?ClassLoader $proxyLoader = null; /** * Bootstrap the AOP proxy generation. */ public function bootstrap(ApplicationContract $app): void + { + if (AspectCollector::hasAspects()) { + $this->generate($app->bootstrapPath('cache/aop')); + } + } + + /** + * Generate and register proxies in the given directory before their targets load. + * + * Boot or tests only. Loaded classes cannot be replaced in the running process. + */ + public function generate(string $proxyDir): void { if (! AspectCollector::hasAspects()) { return; @@ -41,12 +55,83 @@ public function bootstrap(ApplicationContract $app): void AstVisitorRegistry::insert(ProxyCallVisitor::class); } - $proxyDir = $app->storagePath('framework/aop/'); $classMap = $this->buildClassMap(); - $proxyManager = new ProxyManager($classMap, $proxyDir); + $replacements = ClassMapManager::getEntries(); + $proxyManager = new ProxyManager($classMap, $proxyDir, $replacements); + $proxies = $proxyManager->getProxies(); + + if ($proxies === []) { + return; + } + + foreach (array_keys($proxies) as $class) { + $this->ensureLoadedProxyCoversRules($class); + } + + $ordinaryProxies = array_diff_key($proxies, $replacements); + + if ($ordinaryProxies !== []) { + if (static::$proxyLoader === null) { + static::$proxyLoader = new ClassLoader; + static::$proxyLoader->setClassMapAuthoritative(true); + static::$proxyLoader->register(true); + } + + static::$proxyLoader->addClassMap($ordinaryProxies); + } + + ClassMapManager::applyProxies($proxies); + } + + /** + * Reject already-loaded targets whose code cannot apply the registered aspects. + */ + protected function ensureLoadedProxyCoversRules(string $class): void + { + if (! class_exists($class, false) && ! trait_exists($class, false)) { + return; + } + + $reflection = new ReflectionClass($class); + + if (! in_array(ProxyMarker::class, $reflection->getTraitNames(), true)) { + throw new InvalidDefinitionException( + "The AOP target [{$class}] was loaded before proxy generation. " + . 'Bind a factory in register() and instantiate it after proxy generation, during boot() or later.' + ); + } + + $rewritten = $helpers = []; + + foreach ($reflection->getMethods() as $method) { + foreach ($method->getAttributes(ProxyMethod::class) as $attribute) { + $rewritten[$method->getName()] = true; + $helpers[$attribute->getArguments()[0]] = true; + } + } + + $rules = Aspect::parse($class); - Composer::getLoader()->addClassMap($proxyManager->getProxies()); + foreach ($reflection->getMethods() as $method) { + $name = $method->getName(); + + if ( + $method->isAbstract() + || $method->getDeclaringClass()->getName() !== $class + || $method->getFileName() !== $reflection->getFileName() + || isset($helpers[$name]) + || isset($rewritten[$name]) + || ! $rules->shouldRewrite($name) + ) { + continue; + } + + throw new InvalidDefinitionException( + "The loaded AOP proxy [{$class}::{$name}] does not intercept this method. " + . 'Register all applicable aspects before the class is first loaded and restart the worker after changing rules.' + ); + } } /** @@ -65,7 +150,11 @@ public function bootstrap(ApplicationContract $app): void protected function buildClassMap(): array { $loader = Composer::getLoader(); - $classMap = $this->mergeSourceClassMap($loader->getClassMap()); + $classMap = $loader->getClassMap(); + + foreach (ClassMapManager::getEntries() as $class => $path) { + $classMap[$class] = $path; + } foreach (AspectCollector::getRules() as $rule) { foreach ($rule['classes'] as $classRule) { @@ -79,9 +168,7 @@ protected function buildClassMap(): array if (! isset($classMap[$className])) { $file = $loader->findFile($className); - if ($file !== false && ! str_ends_with($file, '.proxy.php')) { - // A flushed source map can leave a proxied PSR-4 class visible only through Composer's proxy entry. - static::$sourceClassMap[$className] = $file; + if ($file !== false) { $classMap[$className] = $file; } } @@ -92,41 +179,13 @@ protected function buildClassMap(): array } /** - * Merge Composer's current class map into the proxy source map. - * - * Runtime class-map overrides are legitimate source entries and may be - * registered after the first application boot. Generated proxy paths are - * excluded because they point at boot artifacts, not source files. - * - * @param array $classMap - * @return array - */ - protected function mergeSourceClassMap(array $classMap): array - { - static::$sourceClassMap ??= []; - - foreach ($classMap as $class => $path) { - if (! str_ends_with($path, '.proxy.php')) { - static::$sourceClassMap[$class] = $path; - } - } - - return static::$sourceClassMap; - } - - /** - * Flush the captured source class map. - * - * Tests only. This is for tests that swap Composer's loader before - * proxy generation; do not register it with the global after-test - * subscriber because normal test cleanup runs after proxy paths have - * already been added to the loader. Flushing mid-worker loses accumulated - * findFile() resolutions for already-proxied PSR-4 classes, so those - * classes are skipped from regeneration until the loader is swapped or the - * process restarts. + * Flush all static state. */ public static function flushState(): void { - static::$sourceClassMap = null; + // Only isolated loader tests may unregister this loader. Normal after-test + // cleanup must retain the framework proxies prepared before test discovery. + static::$proxyLoader?->unregister(); + static::$proxyLoader = null; } } diff --git a/src/di/src/ClassMap/ClassMapManager.php b/src/di/src/ClassMap/ClassMapManager.php index f845e6fc7..749e67011 100644 --- a/src/di/src/ClassMap/ClassMapManager.php +++ b/src/di/src/ClassMap/ClassMapManager.php @@ -4,16 +4,17 @@ namespace Hypervel\Di\ClassMap; -use Hypervel\Support\Composer; +use Composer\Autoload\ClassLoader; +use Hypervel\Di\Aop\ProxySource; +use ReflectionClass; use RuntimeException; /** - * Manages class map overrides applied to Composer's autoloader. + * Keep package class replacements separate from the application's Composer map. * * Allows packages to replace classes at the autoloader level, * so the replacement file is loaded instead of the original. - * Entries are applied immediately when added and fail hard - * if the target class is already loaded. + * Already-loaded targets can only be registered again from the same source. */ class ClassMapManager { @@ -22,11 +23,13 @@ class ClassMapManager */ protected static array $entries = []; + protected static ?ClassLoader $loader = null; + /** * Add class map entries and apply them to the Composer autoloader. * * Each entry maps an original class name to the path of its replacement file. - * Fails immediately if any target class is already loaded. + * Fails if a target is already loaded from a different source. * * Boot-only. Class-map overrides mutate the worker's Composer autoloader and * must be registered before any target class is autoloaded. @@ -37,6 +40,16 @@ public static function add(array $map): void { foreach ($map as $class => $path) { if (class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false)) { + $reflection = new ReflectionClass($class); + $attributes = $reflection->getAttributes(ProxySource::class); + $source = $attributes === [] + ? $reflection->getFileName() + : $attributes[0]->getArguments()[0]; + + if ($source !== false && $source === realpath($path)) { + continue; + } + throw new RuntimeException( "Cannot override class map for [{$class}]: class is already loaded. " . 'Class map entries must be registered before the target class is autoloaded.' @@ -44,9 +57,26 @@ public static function add(array $map): void } } + if (static::$loader === null) { + static::$loader = new ClassLoader; + static::$loader->setClassMapAuthoritative(true); + static::$loader->register(true); + } + static::$entries = array_merge(static::$entries, $map); + static::$loader->addClassMap($map); + } - Composer::getLoader()->addClassMap($map); + /** + * Publish generated replacements with the lifetime of their source overrides. + * + * Boot-only. These autoload entries affect every request in the worker. + * + * @param array $proxies + */ + public static function applyProxies(array $proxies): void + { + static::$loader?->addClassMap(array_intersect_key($proxies, static::$entries)); } /** @@ -72,7 +102,8 @@ public static function getEntries(): array */ public static function flushState(): void { - // Does not remove entries already applied to Composer's autoloader. + static::$loader?->unregister(); + static::$loader = null; static::$entries = []; } } diff --git a/src/docs/aop.md b/src/docs/aop.md index 883325fd2..da011eaec 100644 --- a/src/docs/aop.md +++ b/src/docs/aop.md @@ -96,7 +96,7 @@ public array $classes = [ | --- | --- | | `App\Services\ReportService` | Every method of the class except `__construct` | | `App\Services\ReportService::generate` | A single method | -| `App\Services\*` | Classes matching the wildcard pattern | +| `App\Services\*` | Every method except `__construct` in classes matching the wildcard pattern | | `App\Services\ReportService::gen*` | Methods matching the wildcard pattern | Whenever possible, use `ClassName::class . '::method'` for exact rules so class names remain refactorable. @@ -222,7 +222,9 @@ $this->aspects(ProfileReports::class, TraceHttpRequests::class); ## Proxy Generation -Hypervel generates AOP proxy classes automatically during application bootstrap. Generated proxies are written to the `storage/framework/aop` directory. +Hypervel generates AOP proxy classes automatically during application bootstrap. Generated proxies are written to the `bootstrap/cache/aop` directory. Keeping these files with each release prevents overlapping deployments from overwriting another release's proxies when they share a storage directory. + +Target classes must not be loaded before proxy generation. Bind factories in your providers' `register` methods and create their instances during `boot` or later. Hypervel reports an error if a target was loaded too early, or if an already-loaded proxy cannot apply the current method rules. Restart workers after changing aspect rules. If no aspects have been registered, proxy generation does nothing. Existing proxies are reused only while their content fingerprint still matches the source path and contents, aspect rules, registered AST visitors, generator implementation, PHP parser, and PHP version. Changes to any of those inputs regenerate the affected proxy during the next application bootstrap. @@ -245,6 +247,8 @@ php artisan about In full application or Testbench tests, target classes may already be proxied during application bootstrap. In that case, call the target method normally and the proxy will intercept the call automatically. +The shared PHPUnit extension prepares the framework's Guzzle proxies before test discovery, without enabling optional Sentry or Telescope instrumentation. Application providers still determine which aspects run. For application-defined aspects, register them before first loading their targets, usually through a Testbench application provider. + For focused tests where the target object is not already proxied, use the `Hypervel\Foundation\Testing\Concerns\InteractsWithAop` trait. This trait can run a method through the registered aspect pipeline without relying on generated proxy files: ```php diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index 44ab01b14..25ebb8376 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -163,6 +163,10 @@ The Pusher-compatible `reverb` and `pusher` connections accept an optional `log` Pusher JSONP responses are disabled by default. If a legacy client requires JSONP, you may explicitly enable it by setting the connection's `jsonp` option to `true`. +You may configure Guzzle options for Pusher and Reverb through the connection's `client_options` array. The default client reuses synchronous connections. Asynchronous requests can run concurrently and reuse connections within the coroutine that sends them. If you use the SDK's `triggerAsync` or `triggerBatchAsync` methods directly, create and wait for each promise in the same coroutine. + +The default client does not support `max_host_connections` or `max_total_connections`, since enforcing these limits would require sharing an active transport across coroutines. Bound concurrent broadcasts using [coroutine concurrency](/docs/{{version}}/coroutines#running-work-in-parallel) or [rate limiting](/docs/{{version}}/rate-limiting) instead. If you supply a custom `handler`, you are responsible for its coroutine safety and transport configuration. + Then, set the `BROADCAST_CONNECTION` environment variable to `pusher` in your application's `.env` file: ```ini diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 26265654c..d092f4f49 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -110,6 +110,8 @@ The credential variables use the AWS SDK's standard names, while the region foll The optional `AWS_ROOT` value scopes the disk to a key prefix within the bucket. When it is empty, the disk operates from the bucket root. +If you supply a callable `credentials` provider, calls to that provider run one at a time, including when it is shared with SQS or SES. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. + #### FTP Driver Configuration diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 55c81d161..8585d4814 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -22,6 +22,7 @@ - [Handling Failures](#handling-failures) - [Per-Request Callbacks](#per-request-callbacks) - [Running Concurrent Requests After the Response](#running-concurrent-requests-after-the-response) + - [Guzzle Promises](#guzzle-promises) - [Connections](#connections) - [Restricting Destinations](#restricting-destinations) - [Allowing Internal Networks](#allowing-internal-networks) @@ -960,10 +961,30 @@ defer(function () { })->always(); ``` + +### Guzzle Promises + +When using Guzzle's asynchronous APIs directly, create and finish each operation in the same coroutine. This keeps its callbacks, request context, and database transactions together. For example, create the client and wait for its response inside each parallel task: + +```php +use GuzzleHttp\Client; + +use function Hypervel\Coroutine\parallel; + +$responses = parallel([ + fn () => (new Client)->getAsync('https://example.com/users')->wait(), + fn () => (new Client)->getAsync('https://example.com/orders')->wait(), +]); +``` + +Hypervel throws a `Hypervel\Http\Exceptions\CoroutineOwnershipException` if another coroutine tries to use a pending Guzzle promise or drive a cURL multi-handler with active work. Completed results may be shared. When a coroutine exits, Hypervel cancels its unfinished native transfers and runs its queued callbacks in that same coroutine, allowing cancellation to reach dependent promises. Finish any work you need before returning from the coroutine. + +These checks apply to Guzzle's mutable promises and cURL multi-handlers, including those used by third-party SDKs. Custom transports and other promise implementations remain responsible for their own coroutine safety. When supplying a custom handler, keep each active multi-handler within one coroutine. Once all its transfers finish, it may be reused by another coroutine. + ## Connections -Hypervel's HTTP client supports named connection presets for services your application calls frequently. Synchronous requests on a registered connection share one low-level Guzzle transport handler, which retains reusable cURL handles and keep-alive connection state. Every pending request still receives a fresh Guzzle client and middleware stack, so request-specific middleware, callbacks, and options never become frozen onto the first request. Asynchronous requests use isolated handlers because a worker-lived cURL multi-handler cannot be driven safely by concurrent coroutines. +Hypervel's HTTP client supports named connection presets for services your application calls frequently. Synchronous requests on a registered connection share one low-level Guzzle transport handler, which retains reusable cURL handles and keep-alive connection state. Every pending request still receives a fresh Guzzle client and middleware stack, so request-specific middleware, callbacks, and options never become frozen onto the first request. Asynchronous requests use isolated handlers because a worker-lived cURL multi-handler cannot be driven safely by concurrent coroutines. These handlers still use the connection's registered transport sharing and multiplexing settings. For streamed responses, each named connection keeps up to 32 idle connections per worker for reuse. Connections are opened as needed; this does not limit how many requests can run at once. @@ -993,7 +1014,7 @@ The second argument is a request-option preset. It accepts normal Guzzle request - `handler` is rejected. Use `setHandler()` for a request-specific handler. - `pool` is rejected. HTTP clients are not object-pooled. - `max_host_connections` and `max_total_connections` are rejected. Use bounded coroutine fan-out or the rate limiter instead. -- `transport_sharing` is consumed only while registering the connection's low-level handler. It accepts Guzzle's `TransportSharing` modes and is never passed into request options. +- `transport_sharing` configures the connection's shared and isolated handlers. It accepts Guzzle's `TransportSharing` modes and is never passed into request options. - `multiplex` remains a request option. The `Multiplexing::NONE` mode also configures the connection handler so its guarantee applies to every request using that handler. The dedicated connection-cap options are rejected at every option layer. `pool`, `handler`, `cookies`, and `transport_sharing` are also rejected from global options, per-call connection overrides, fluent `withOptions()` calls, and raw `send()` options. This keeps cookie and handler ownership consistent regardless of which option layer supplied a value. @@ -1006,7 +1027,7 @@ $response = Http::connection('github') ->get('/user'); ``` -Option precedence is deterministic and does not depend on chaining order: factory global options are the lowest layer, followed by the registered connection preset, an optional per-call connection override, and finally fluent request options. Passing an empty per-call array intentionally clears the registered preset for that request: +Option precedence is deterministic and does not depend on chaining order: factory global options are the lowest layer, followed by the registered connection preset, an optional per-call connection override, and finally fluent request options. Passing an empty per-call array intentionally clears the registered request preset. The connection's handler settings remain unchanged: ```php // Uses the registered GitHub preset... diff --git a/src/docs/mail.md b/src/docs/mail.md index 509387349..6c2eff2ba 100644 --- a/src/docs/mail.md +++ b/src/docs/mail.md @@ -214,6 +214,8 @@ To utilize AWS [temporary credentials](https://docs.aws.amazon.com/IAM/latest/Us ], ``` +If you supply a callable `credentials` provider instead of static keys, calls to that provider run one at a time, including when it is shared with SQS or S3. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. For pooled mailers, configure `pool.fingerprint` as described under [transport pooling](#transport-pooling). + To interact with SES's [subscription management features](https://docs.aws.amazon.com/ses/latest/dg/sending-email-subscription-management.html), you may return the `X-Ses-List-Management-Options` header in the array returned by the [headers](#headers) method of a mail message: ```php diff --git a/src/docs/packages.md b/src/docs/packages.md index 832f53847..e5138236a 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -319,7 +319,7 @@ public function register(): void The replacement file should define the original class name. In the example above, Composer is still being asked to load `VendorPackage\Client`; Hypervel simply tells Composer to load that class from your replacement file instead of the dependency's original file. The replacement should remain compatible with the original class's public API, since other code will still interact with it as `VendorPackage\Client`. -Class map overrides mutate Composer's autoloader immediately and must be registered before the target class, interface, or trait has been loaded. If the target has already been loaded, Hypervel will throw an exception. For this reason, class map overrides should be registered in the service provider's `register` method before resolving services or referencing classes that may load the target. +Class map overrides take effect immediately and must be registered before the target class, interface, or trait has been loaded. Register them in the service provider's `register` method before resolving services or referencing classes that may load the target. Repeating an override for the same source file is safe, including when Testbench boots another application. Attempting to replace a loaded target with a different source file throws an exception. > [!WARNING] > Class map overrides affect the worker's autoloader for the lifetime of the process. They should only be used for boot-time package integration, not request-specific behavior. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 9c16f76b8..3c798c8a1 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -512,6 +512,8 @@ Maintenance views prepared with `down --render` are served by running Hypervel w For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` patterns with Hypervel's coroutine helpers, typically `parallel` from `Hypervel\Coroutine`. See the [HTTP client documentation](/docs/{{version}}/http-client#concurrent-requests) for examples. +Create and finish pending Guzzle operations within one coroutine; share completed results instead of pending promises or active multi-handlers. Hypervel enforces this for Guzzle's mutable promises and cURL multi-handlers. Register SDK clients lazily in service providers so their classes load after proxy generation. See [Guzzle promises](/docs/{{version}}/http-client#guzzle-promises) and [proxy generation](/docs/{{version}}/aop#proxy-generation). + `withNtlmAuth()` and Saloon's NTLM authenticator are not provided. Integrations requiring NTLM must supply their own authentication implementation. Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver). @@ -526,6 +528,8 @@ Connectors may be shared by concurrent requests, so they are read-only. Move cod ### Broadcasting +For Pusher and Reverb, replace Guzzle's `max_host_connections` and `max_total_connections` client options with bounded coroutine concurrency or rate limiting. The default client rejects these options because their shared transport cannot be driven safely by concurrent coroutines. See [Pusher broadcasting](/docs/{{version}}/broadcasting#pusher-manual-installation). + Mercure applications must configure a standalone HTTP hub. Hypervel runs on Swoole and does not use FrankenPHP's in-process `mercure_publish()` integration. See [Mercure broadcasting](/docs/{{version}}/broadcasting#mercure). diff --git a/src/docs/queues.md b/src/docs/queues.md index f7dbcec07..b8f664a96 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -166,6 +166,8 @@ Hypervel uses a complete `key` and `secret` pair when both are configured, inclu The optional `credentials` setting takes precedence and may contain an AWS credential value or a supported `ecs` or `instance` provider. If you supply callable or object credentials, set `pool.fingerprint` because these values cannot form an automatic pool identity. The optional `version` setting defaults to `latest`. Within the optional `http` array, `timeout` and `connect_timeout` each default to 60 seconds, and additional AWS SDK HTTP options are preserved. +Calls to the same custom credential provider run one at a time, including when it is shared with S3 or SES. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. + When credentials come from the `ecs` or `instance` provider or from the AWS SDK's default credential chain, you may enable the optional `credential_cache` setting so that the processes and pooled connections on the same host or container share the credentials they fetch, instead of each fetching its own copy: ```php diff --git a/src/docs/testing.md b/src/docs/testing.md index 10974d0d2..3f84979b4 100644 --- a/src/docs/testing.md +++ b/src/docs/testing.md @@ -172,6 +172,9 @@ class NonCoroutineRuntimeTest extends TestCase } ``` +> [!WARNING] +> PHPUnit's output assertions currently cannot capture output printed inside Hypervel's test coroutine. If the test does not need coroutine execution, set `$runTestsInCoroutine = false`; otherwise, an output assertion may incorrectly pass. This issue is tracked in [PHPUnit #7039](https://github.com/sebastianbergmann/phpunit/issues/7039). + The standard PHPUnit `setUp` and `tearDown` methods run outside the test method's coroutine. If your setup or teardown work needs to share the same coroutine-aware lifecycle as the test method, define `setUpInCoroutine` or `tearDownInCoroutine` methods: ```php diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 922075b3d..2a3da96d5 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -17,6 +17,7 @@ use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\ObjectPool\PoolDefinition; use Hypervel\Support\Arr; +use Hypervel\Support\Aws\SerializedCredentialProvider; use Hypervel\Support\RebindsCallbacksToSelf; use Hypervel\Support\Str; use InvalidArgumentException; @@ -560,6 +561,10 @@ protected static function s3ArgumentNames(): array */ protected function createS3Client(array $clientConfig): S3Client { + if (is_callable($clientConfig['credentials'] ?? null)) { + $clientConfig['credentials'] = new SerializedCredentialProvider($clientConfig['credentials']); + } + return new S3Client($clientConfig); } diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 29448d207..73d327719 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -156,7 +156,7 @@ protected function gatherApplicationInformation(): void 'Config' => static::format($this->hypervel->configurationIsCached(), console: $formatCachedStatus), 'Events' => static::format($this->hypervel->eventsAreCached(), console: $formatCachedStatus), 'Routes' => static::format($this->hypervel->routesAreCached(), console: $formatCachedStatus), - 'AOP Proxies' => static::format($this->hasPhpFiles($this->hypervel->storagePath('framework/aop'), 'cache'), console: $formatCachedStatus), + 'AOP Proxies' => static::format($this->hasPhpFiles($this->hypervel->bootstrapPath('cache/aop')), console: $formatCachedStatus), 'Views' => static::format($this->hasPhpFiles(config()->string('view.compiled')), console: $formatCachedStatus), ]); diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAop.php b/src/foundation/src/Testing/Concerns/InteractsWithAop.php index 9557a93bc..2d9c79172 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAop.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAop.php @@ -26,14 +26,9 @@ trait InteractsWithAop /** * Determine if the given instance is already running through an AOP proxy. * - * This is useful in tests that need to handle both execution paths - * explicitly. In isolated runs, the target class may be proxied during - * bootstrap and should be called normally. In larger test runs, load order - * may mean the original class was loaded before proxy generation, so the - * test must use callWithAspects() instead. - * - * Keeping this check separate makes it clear whether the test is using the - * real proxy path or the manual pipeline path. + * Call generated proxies normally. Use callWithAspects() only for focused + * tests that register aspects directly without generating proxies; it does + * not bypass bootstrap's rejection of targets loaded before generation. */ protected function isAopProxied(object $instance): bool { @@ -69,7 +64,8 @@ protected function callWithAspects(object $instance, string $method, array $argu $className, $method, $this->buildAopArguments($reflectionMethod, $arguments), - $reflectionMethod->getClosure($instance) + $reflectionMethod->getClosure($instance), + $reflectionMethod->isStatic() ? null : $instance ); } diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index bb9ea30e4..3fb3ec007 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -9,7 +9,10 @@ use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Eloquent\Model; +use Hypervel\Engine\Coroutine as EngineCoroutine; +use SebastianBergmann\Invoker\TimeoutException; use Swoole\Coroutine; +use Swoole\Coroutine\Channel; use Swoole\Timer; use Throwable; @@ -39,6 +42,7 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m $testResult = null; $exception = null; + $timeoutException = null; $capture = static function (callable $callback) use (&$exception): void { try { @@ -48,17 +52,20 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m } }; - run(function () use (&$testResult, &$exception, $capture, $methodName, $testArguments): void { - $this->clearNonCoroutineTransactionContext(); + run(function () use (&$testResult, &$exception, &$timeoutException, $capture, $methodName, $testArguments): void { + $shouldBootFramework = false; - if ($this->copyNonCoroutineContext) { - CoroutineContext::copyFromNonCoroutine(); - DatabaseTransactionsManager::copyFromNonCoroutineState(); - } + try { + $this->enforceCoroutineTestTimeLimit($timeoutException); + $this->clearNonCoroutineTransactionContext(); - $shouldBootFramework = $this->shouldBootFrameworkForTest(); + if ($this->copyNonCoroutineContext) { + CoroutineContext::copyFromNonCoroutine(); + DatabaseTransactionsManager::copyFromNonCoroutineState(); + } + + $shouldBootFramework = $this->shouldBootFrameworkForTest(); - try { if ($shouldBootFramework) { $this->invokeSetupInCoroutine(); } @@ -78,6 +85,10 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m } }); + if ($timeoutException !== null) { + throw $timeoutException; + } + if ($exception !== null) { throw $exception; } @@ -85,6 +96,78 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m return $testResult; } + /** + * Enforce PHPUnit's deadline while the test or its children are suspended. + */ + protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutException): void + { + if (! function_exists('pcntl_alarm')) { + return; + } + + // Keep the native alarm armed so non-yielding tests are still interrupted. + $remaining = pcntl_alarm(0); + pcntl_alarm($remaining); + + if ($remaining === 0) { + return; + } + + $root = Coroutine::getCid(); + $deadline = hrtime(true) / 1e9 + $remaining; + // Buffer the exit signal so cleanup cannot block after the deadline has expired. + $completed = new Channel(1); + // Register first so the signal follows all other root cleanup defers. + Coroutine::defer(static function () use ($completed): void { + $completed->push(true); + }); + + Coroutine::create(static function () use ($root, $deadline, $completed, &$timeoutException): void { + $current = Coroutine::getCid(); + + try { + $remaining = $deadline - hrtime(true) / 1e9; + + // Native channel and sleep timers survive Timer::clearAll() cleanup. + if ($remaining > 0 && $completed->pop($remaining)) { + while (true) { + $coroutines = array_filter( + iterator_to_array(Coroutine::list()), + static fn (int $coroutine): bool => $coroutine !== $current && $coroutine !== $root, + ); + + if ($coroutines === []) { + return; + } + + $remaining = $deadline - hrtime(true) / 1e9; + + if ($remaining <= 0) { + break; + } + + // Joining here would consume the sole join slot needed by the test. + Coroutine::sleep(max(0.001, min($remaining, 0.01))); + } + } + + pcntl_signal_dispatch(); + + throw new TimeoutException('The coroutine test exceeded its time limit.'); + } catch (TimeoutException $exception) { + $timeoutException = $exception; + pcntl_alarm(0); + + foreach (Coroutine::list() as $coroutine) { + if ($coroutine !== $current) { + // The root catches cancellation; raw child callbacks may not. + EngineCoroutine::cancelById($coroutine, $coroutine === $root); + } + } + } + }); + } + /** * Determine whether tests run in a coroutine. */ diff --git a/src/http/README.md b/src/http/README.md index 5d85a2d27..9aa4a9eec 100644 --- a/src/http/README.md +++ b/src/http/README.md @@ -19,6 +19,8 @@ JSON:API resources load requested relationships before attribute callbacks run; Laravel's `Http::pool()` and `Http::batch()` APIs are intentionally not ported. They are built around Guzzle promise concurrency, while Hypervel uses Swoole coroutine-native concurrency through `parallel`, `Hypervel\Coroutine\Parallel`, and `defer`. See the [concurrent requests documentation](https://hypervel.org/docs/http-client#concurrent-requests). +Pending Guzzle promises and active cURL multi-handlers belong to the coroutine that created their work. Create and finish each operation in that coroutine, then share completed results. See [Guzzle promises](https://hypervel.org/docs/http-client#guzzle-promises). + `TrustHosts` fails closed when no trusted host patterns resolve. If the middleware is enabled and no resolver, `at()` list, or valid `app.url` host provides a trusted pattern, Hypervel rejects all hosts using a never-matching sentinel. Laravel and Symfony leave the trusted host list empty in this case, which accepts every host. Configure a valid `app.url`, `TrustHosts::at()`, or `TrustHosts::resolveHostsUsing()` when enabling the middleware. Configure trusted proxies through `$middleware->trustProxies(...)` in `bootstrap/app.php`. Hypervel does not read the legacy `trustedproxy.proxies` configuration key or include Laravel's Cloud, Forge, and Vapor host-specific proxy behavior. diff --git a/src/http/composer.json b/src/http/composer.json index e6852fd1c..1d50487c7 100644 --- a/src/http/composer.json +++ b/src/http/composer.json @@ -38,6 +38,7 @@ "hypervel/contracts": "^0.4", "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", + "hypervel/di": "^0.4", "hypervel/engine": "^0.4", "hypervel/macroable": "^0.4", "hypervel/object-pool": "^0.4", diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index 808fbc4c0..f0299f999 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -663,11 +663,15 @@ public function hasConnection(string $name): bool */ public function getConnectionHandler(string $name): callable { - $this->ensureConnectionIsRegistered($name); + return $this->connectionHandlers[$name] ??= $this->newConnectionHandler($name); + } - if (isset($this->connectionHandlers[$name])) { - return $this->connectionHandlers[$name]; - } + /** + * Create an isolated transport handler with the connection's registered options. + */ + public function newConnectionHandler(string $name): callable + { + $this->ensureConnectionIsRegistered($name); $config = $this->connectionConfigs[$name]; $handlerOptions = Arr::only($config, ['transport_sharing']); @@ -676,9 +680,7 @@ public function getConnectionHandler(string $name): callable $handlerOptions['multiplex'] = Multiplexing::NONE; } - return $this->connectionHandlers[$name] = $this->createConnectionHandler( - $handlerOptions, - ); + return $this->createConnectionHandler($handlerOptions); } /** diff --git a/src/http/src/Client/Guzzle/Aspects/PromiseConstructionAspect.php b/src/http/src/Client/Guzzle/Aspects/PromiseConstructionAspect.php new file mode 100644 index 000000000..4f10c8709 --- /dev/null +++ b/src/http/src/Client/Guzzle/Aspects/PromiseConstructionAspect.php @@ -0,0 +1,26 @@ +process(); + CoroutineOwnership::record($proceedingJoinPoint->getInstance()); + + return $result; + } +} diff --git a/src/http/src/Client/Guzzle/Aspects/PromiseOperationAspect.php b/src/http/src/Client/Guzzle/Aspects/PromiseOperationAspect.php new file mode 100644 index 000000000..9e000a188 --- /dev/null +++ b/src/http/src/Client/Guzzle/Aspects/PromiseOperationAspect.php @@ -0,0 +1,55 @@ +getInstance(); + $method = $proceedingJoinPoint->methodName; + CoroutineOwnership::ensureOwner($promise, $method); + + if ($promise->getState() !== PromiseInterface::PENDING || ($method !== 'resolve' && $method !== 'reject')) { + return $proceedingJoinPoint->process(); + } + + $value = $proceedingJoinPoint->arguments['keys'][$method === 'resolve' ? 'value' : 'reason'] ?? null; + $adopting = $value instanceof PromiseInterface && ! CoroutineOwnership::completed($value); + + if ($adopting && $value instanceof Promise) { + CoroutineOwnership::ensureOwner($value, 'adopt'); + } + + $result = $proceedingJoinPoint->process(); + + // A rejected self-resolution must not leave adoption metadata behind. + if ($adopting) { + CoroutineOwnership::adopt($promise, $value); + } + + CoroutineOwnership::settled($promise); + + return $result; + } +} diff --git a/src/http/src/Client/Guzzle/Aspects/TransportOwnershipAspect.php b/src/http/src/Client/Guzzle/Aspects/TransportOwnershipAspect.php new file mode 100644 index 000000000..a2bf6aacb --- /dev/null +++ b/src/http/src/Client/Guzzle/Aspects/TransportOwnershipAspect.php @@ -0,0 +1,56 @@ +getInstance(); + + if ($proceedingJoinPoint->methodName !== '__invoke') { + CoroutineOwnership::ensureTransportOwner($handler, $proceedingJoinPoint->methodName); + + return $proceedingJoinPoint->process(); + } + + CoroutineOwnership::reserve($handler); + + try { + $result = $proceedingJoinPoint->process(); + } catch (Throwable $exception) { + CoroutineOwnership::release($handler); + + throw $exception; + } + + if ($result instanceof Promise && $result->getState() === PromiseInterface::PENDING) { + CoroutineOwnership::track($result, $handler); + } else { + CoroutineOwnership::release($handler); + } + + return $result; + } +} diff --git a/src/http/src/Client/Guzzle/CoroutineOwnership.php b/src/http/src/Client/Guzzle/CoroutineOwnership.php new file mode 100644 index 000000000..a3911b122 --- /dev/null +++ b/src/http/src/Client/Guzzle/CoroutineOwnership.php @@ -0,0 +1,162 @@ + */ + protected static ?WeakMap $owners = null; + + /** @var null|WeakMap */ + protected static ?WeakMap $adopted = null; + + /** @var null|WeakMap */ + protected static ?WeakMap $transfers = null; + + /** @var null|WeakMap */ + protected static ?WeakMap $transports = null; + + /** + * Record the coroutine that successfully constructed a mutable promise. + */ + public static function record(Promise $promise): void + { + static::$owners ??= new WeakMap; + static::$owners[$promise] = Coroutine::id(); + } + + /** + * Require pending work to remain with its constructing coroutine. + */ + public static function ensureOwner(Promise $promise, string $operation): void + { + $owner = static::$owners[$promise] ?? null; + $current = Coroutine::id(); + + if ($owner === null || $owner === $current) { + return; + } + + // Settlement and cancellation already ignore or reject settled promises; + // only observation can still drive an adopted pending value. + $completed = in_array($operation, ['then', 'wait', 'adopt'], true) + ? static::completed($promise) + : $promise->getState() !== PromiseInterface::PENDING; + + if (! $completed) { + throw new CoroutineOwnershipException( + "Coroutine [{$current}] cannot call [{$operation}] on a pending promise owned by coroutine [{$owner}]. " + . 'Create and finish the operation in the same coroutine, then share its completed result.' + ); + } + } + + /** + * Determine whether a promise and any adopted promise hold completed results. + */ + public static function completed(PromiseInterface $promise): bool + { + if ($promise->getState() === PromiseInterface::PENDING) { + return false; + } + + $adopted = $promise instanceof Promise ? (static::$adopted[$promise] ?? null) : null; + + return $adopted === null || static::completed($adopted); + } + + /** + * Record a pending adoption only after Guzzle accepts the settlement. + */ + public static function adopt(Promise $promise, PromiseInterface $value): void + { + static::$adopted ??= new WeakMap; + static::$adopted[$promise] = $value; + } + + /** + * Refuse foreign access while a multi-handler has reserved or active transfers. + */ + public static function ensureTransportOwner(CurlMultiHandler $handler, string $operation): void + { + $state = static::$transports[$handler] ?? null; + $current = Coroutine::id(); + + if ($state !== null && $state->owner !== $current) { + throw new CoroutineOwnershipException( + "Coroutine [{$current}] cannot call [{$operation}] on a cURL multi-handler with active work owned by coroutine [{$state->owner}]. " + . 'Create and finish each operation in its owning coroutine.' + ); + } + } + + /** + * Reserve ownership before request preparation can yield. + */ + public static function reserve(CurlMultiHandler $handler): void + { + static::ensureTransportOwner($handler, '__invoke'); + static::$transports ??= new WeakMap; + $state = static::$transports[$handler] ??= new TransportState(Coroutine::id()); + ++$state->active; + } + + /** + * Retain a pending native transfer until settlement or owner exit. + */ + public static function track(Promise $promise, CurlMultiHandler $handler): void + { + static::$transfers ??= new WeakMap; + static::$transfers[$promise] = $handler; + CoroutineState::forCurrent()->add($promise); + } + + /** + * Release accounting when a native transfer settles or is canceled. + */ + public static function settled(Promise $promise): void + { + $handler = static::$transfers[$promise] ?? null; + + if ($handler === null) { + return; + } + + unset(static::$transfers[$promise]); + CoroutineState::current()?->forget($promise); + static::release($handler); + } + + /** + * Release one reservation or completed transfer. + */ + public static function release(CurlMultiHandler $handler): void + { + if (--static::$transports[$handler]->active === 0) { + unset(static::$transports[$handler]); + } + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$owners = null; + static::$adopted = null; + static::$transfers = null; + static::$transports = null; + } +} diff --git a/src/http/src/Client/Guzzle/CoroutineState.php b/src/http/src/Client/Guzzle/CoroutineState.php new file mode 100644 index 000000000..6a9b1fe83 --- /dev/null +++ b/src/http/src/Client/Guzzle/CoroutineState.php @@ -0,0 +1,101 @@ + */ + protected array $transfers = []; + + /** + * Create a queue whose lifetime follows its coroutine rather than the process. + */ + public function __construct() + { + $this->queue = new TaskQueue(false); + } + + /** + * Get the current coroutine's state without creating it. + */ + public static function current(): ?self + { + return CoroutineContext::get(self::CONTEXT_KEY); + } + + /** + * Get the current coroutine's state and register owner-exit cleanup. + */ + public static function forCurrent(): self + { + if (($state = static::current()) !== null) { + return $state; + } + + $state = CoroutineContext::set(self::CONTEXT_KEY, new self); + + if (Coroutine::id() >= 0) { + Coroutine::defer($state->finish(...)); + } + + return $state; + } + + /** + * Retain a pending transfer in its owner's context. + */ + public function add(Promise $transfer): void + { + $this->transfers[spl_object_id($transfer)] = $transfer; + } + + /** + * Release a settled transfer. + */ + public function forget(Promise $transfer): void + { + unset($this->transfers[spl_object_id($transfer)]); + } + + /** + * Cancel unfinished transfers. + */ + public function cancel(): void + { + foreach ($this->transfers as $identifier => $transfer) { + unset($this->transfers[$identifier]); + $transfer->cancel(); + } + } + + /** + * Propagate cancellation before releasing the owner's queue and transfers. + */ + public function finish(): void + { + try { + do { + $this->cancel(); + $this->queue->run(); + } while ($this->transfers !== [] || ! $this->queue->isEmpty()); + } finally { + // Release native transfers if queue processing exits early. + $this->cancel(); + } + } +} diff --git a/src/http/src/Client/Guzzle/CoroutineTaskQueue.php b/src/http/src/Client/Guzzle/CoroutineTaskQueue.php new file mode 100644 index 000000000..61d935796 --- /dev/null +++ b/src/http/src/Client/Guzzle/CoroutineTaskQueue.php @@ -0,0 +1,83 @@ +current()->isEmpty(); + } + + /** + * Add a task to the current coroutine's queue. + */ + public function add(callable $task): void + { + $this->current()->add($task); + } + + /** + * Run the current coroutine's pending tasks. + */ + public function run(): void + { + $this->current()->run(); + } + + /** + * Discard outside-coroutine test tasks without retaining a shutdown callback. + * + * Tests only. The PHPUnit extension seeds a shutdown-disabled delegate before installation. + */ + public function resetOutside(): void + { + $this->outside = new TaskQueue(false); + } + + /** + * Resolve the queue owned by the current native coroutine. + */ + protected function current(): TaskQueueInterface + { + if (Coroutine::id() < 0) { + return $this->outside; + } + + return CoroutineState::forCurrent()->queue; + } +} diff --git a/src/http/src/Client/Guzzle/TransportState.php b/src/http/src/Client/Guzzle/TransportState.php new file mode 100644 index 000000000..924d901a1 --- /dev/null +++ b/src/http/src/Client/Guzzle/TransportState.php @@ -0,0 +1,17 @@ +handler; - if ($handler === null && ! $this->async && $this->connection !== null && $this->factory !== null) { - $handler = $this->factory->getConnectionHandler($this->connection); + if ($handler === null && $this->connection !== null && $this->factory !== null) { + $handler = $this->async + ? $this->factory->newConnectionHandler($this->connection) + : $this->factory->getConnectionHandler($this->connection); } $handler ??= CurlStreamingHandler::wrap(Utils::chooseHandler()); diff --git a/src/http/src/Exceptions/CoroutineOwnershipException.php b/src/http/src/Exceptions/CoroutineOwnershipException.php new file mode 100644 index 000000000..395aa5bb2 --- /dev/null +++ b/src/http/src/Exceptions/CoroutineOwnershipException.php @@ -0,0 +1,11 @@ +aspects([ + PromiseConstructionAspect::class, + PromiseOperationAspect::class, + TransportOwnershipAspect::class, + ]); + $this->registerPsr18Discovery(); $this->registerRequestFactory(); $this->registerClientEventRebindHandler(); diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index 3b4485d60..543202466 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -22,6 +22,7 @@ use Hypervel\Mail\Transport\SesV2Transport; use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Support\Arr; +use Hypervel\Support\Aws\SerializedCredentialProvider; use Hypervel\Support\ConfigurationUrlParser; use Hypervel\Support\Str; use InvalidArgumentException; @@ -479,10 +480,14 @@ protected function createSendmailTransport(array $config): SendmailTransport */ protected function createSesV2Transport(array $config): SesV2Transport { - $config = Arr::except($config, ['transport']); + $config = $this->addSesCredentials(Arr::except($config, ['transport'])); + + if (is_callable($config['credentials'] ?? null)) { + $config['credentials'] = new SerializedCredentialProvider($config['credentials']); + } return new SesV2Transport( - new SesV2Client($this->addSesCredentials($config)), + new SesV2Client($config), $config['options'] ?? [] ); } diff --git a/src/notifications/src/Support/TransportSnapshot.php b/src/notifications/src/Support/TransportSnapshot.php index 3ce19b25c..e3470602c 100644 --- a/src/notifications/src/Support/TransportSnapshot.php +++ b/src/notifications/src/Support/TransportSnapshot.php @@ -102,7 +102,7 @@ private function encode(object $value, SplObjectStorage $seen): object return $value; } - if ($seen->contains($value)) { + if ($seen->offsetExists($value)) { return new TransportReference($seen[$value]); } @@ -134,7 +134,7 @@ private function encode(object $value, SplObjectStorage $seen): object foreach ($state as $name => $member) { if ($value instanceof Response && $name === "\0*\0decodeUsing" && $member instanceof Closure) { - if (! $seen->contains($member)) { + if (! $seen->offsetExists($member)) { $closureIndex = count($this->nodes); $seen[$member] = $closureIndex; $this->nodes[$closureIndex] = ['kind' => 'closure', 'closure' => new SerializableClosure($member)]; diff --git a/src/queue/src/Connectors/SqsConnector.php b/src/queue/src/Connectors/SqsConnector.php index 9e2b037b1..dc46967ad 100644 --- a/src/queue/src/Connectors/SqsConnector.php +++ b/src/queue/src/Connectors/SqsConnector.php @@ -14,6 +14,7 @@ use Hypervel\Queue\AwsCredentialCache; use Hypervel\Queue\SqsQueue; use Hypervel\Support\Arr; +use Hypervel\Support\Aws\SerializedCredentialProvider; use InvalidArgumentException; class SqsConnector implements ConnectorInterface @@ -134,7 +135,7 @@ protected function resolveCredentialProvider(array $config): mixed $provider = is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials; if (! is_string($provider)) { - return $provider; + return is_callable($provider) ? new SerializedCredentialProvider($provider) : $provider; } $options = is_array($credentials) ? Arr::except($credentials, ['provider']) : []; diff --git a/src/support/composer.json b/src/support/composer.json index 0fc9f367f..053941643 100644 --- a/src/support/composer.json +++ b/src/support/composer.json @@ -35,7 +35,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/events": "^0.4", "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", diff --git a/src/support/src/Aws/SerializedCredentialProvider.php b/src/support/src/Aws/SerializedCredentialProvider.php new file mode 100644 index 000000000..c77caf014 --- /dev/null +++ b/src/support/src/Aws/SerializedCredentialProvider.php @@ -0,0 +1,96 @@ + Lock key => owning coroutine ID */ + protected static array $owners = []; + + /** + * Create a serialized credential provider. + */ + public function __construct(callable $provider) + { + $this->provider = $provider(...); + $this->lockKey = 'aws-credential-provider.' . $this->identity($provider); + } + + /** + * Identify the state behind a callable: one lock per bound object, whichever method is called on it. + */ + protected function identity(callable $provider): string + { + if (is_string($provider) && str_contains($provider, '::')) { + $provider = explode('::', $provider, 2); + } + + return match (true) { + is_object($provider) => 'object:' . spl_object_id($provider), + is_array($provider) && is_object($provider[0]) => 'object:' . spl_object_id($provider[0]), + is_array($provider) => 'static:' . strtolower(ltrim($provider[0], '\\')), + default => 'function:' . strtolower(ltrim($provider, '\\')), + }; + } + + /** + * Resolve this caller's credentials before releasing the shared provider. + */ + public function __invoke(mixed ...$arguments): PromiseInterface + { + // Waiting drains Guzzle callbacks, which may request credentials again. + $coroutine = Coroutine::id(); + $acquiresLock = (static::$owners[$this->lockKey] ?? null) !== $coroutine; + + if ($acquiresLock) { + while (! Locker::lock($this->lockKey)) { + // The previous caller finished; compete to run the provider next. + } + + static::$owners[$this->lockKey] = $coroutine; + } + + try { + return Create::promiseFor(($this->provider)(...$arguments)->wait()); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + // SDK providers can wrap an interrupted fetch in CredentialsException. + if (Coroutine::isCanceled()) { + throw new CanceledException('Resolving AWS credentials was canceled.', previous: $exception); + } + + return Create::rejectionFor($exception); + } finally { + if ($acquiresLock) { + unset(static::$owners[$this->lockKey]); + Locker::unlock($this->lockKey); + } + } + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$owners = []; + } +} diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index a4c3bf16e..644dd61d2 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -277,16 +277,14 @@ public static function setLoader(ClassLoader $classLoader): ClassLoader } /** - * Find the Composer autoloader from registered autoload functions. + * Find the application Composer autoloader registered with a vendor directory. */ protected static function findLoader(): ClassLoader { - $loaders = spl_autoload_functions(); + $loaders = ClassLoader::getRegisteredLoaders(); - foreach ($loaders as $loader) { - if (is_array($loader) && $loader[0] instanceof ClassLoader) { - return $loader[0]; - } + if ($loaders !== []) { + return reset($loaders); } throw new RuntimeException('Composer loader not found.'); diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php index 3feb759bb..380a1a600 100644 --- a/src/support/src/Facades/Http.php +++ b/src/support/src/Facades/Http.php @@ -39,6 +39,7 @@ * @method static void macro(string $name, callable|object $macro) * @method static mixed macroCall(string $method, array $parameters) * @method static void mixin(object $mixin, bool $replace = true) + * @method static callable newConnectionHandler(string $name) * @method static bool preventingStrayRequests() * @method static \GuzzleHttp\Psr7\Response psr7Response(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) * @method static \Hypervel\Http\Client\Factory record() diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index 6b86474a5..6f4d2e84d 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -17,7 +17,6 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\View\Compilers\CompilerInterface; -use ReflectionProperty; use RuntimeException; abstract class ServiceProvider @@ -593,21 +592,7 @@ protected function aspects(string|array $aspects): void $aspects = is_array($aspects) ? $aspects : func_get_args(); foreach ($aspects as $aspect) { - $reflectionClass = ClassMetadataCache::reflectClass($aspect); - $properties = $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC); - - $classes = []; - $priority = null; - - foreach ($properties as $property) { - if ($property->getName() === 'classes') { - $classes = $property->getDefaultValue(); - } elseif ($property->getName() === 'priority') { - $priority = $property->getDefaultValue(); - } - } - - AspectCollector::setAround($aspect, $classes, $priority); + AspectCollector::register($aspect); } } @@ -615,7 +600,7 @@ protected function aspects(string|array $aspects): void * Register class map overrides. * * Applies entries to the Composer autoloader immediately. - * Fails hard if any target class is already loaded. + * Rejects loaded targets from a different source; repeating the same override is safe. * Must be called during register(), before the target class is autoloaded. * * @param array $map originalClass => replacementFilePath diff --git a/src/testbench/testbench.yaml b/src/testbench/testbench.yaml index cf1aa99e1..dad306200 100644 --- a/src/testbench/testbench.yaml +++ b/src/testbench/testbench.yaml @@ -27,4 +27,4 @@ purge: directories: - public/build - public/vendor/* - - storage/framework/aop + - bootstrap/cache/aop diff --git a/src/testing/composer.json b/src/testing/composer.json index 73cf4b446..e9b664a1d 100644 --- a/src/testing/composer.json +++ b/src/testing/composer.json @@ -28,6 +28,7 @@ "ext-dom": "*", "ext-mbstring": "*", "composer-runtime-api": "^2.2", + "guzzlehttp/promises": "^2.5.2 || ^3.0.2", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/console": "^0.4", diff --git a/src/testing/src/PHPUnit/AfterEachTestExtension.php b/src/testing/src/PHPUnit/AfterEachTestExtension.php index 0d4e25f8e..66a1cb50d 100644 --- a/src/testing/src/PHPUnit/AfterEachTestExtension.php +++ b/src/testing/src/PHPUnit/AfterEachTestExtension.php @@ -4,6 +4,19 @@ namespace Hypervel\Testing\PHPUnit; +use GuzzleHttp\Promise\TaskQueue; +use GuzzleHttp\Promise\Utils; +use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Di\Aop\AstVisitorRegistry; +use Hypervel\Di\Bootstrap\GenerateProxies; +use Hypervel\Filesystem\Filesystem; +use Hypervel\Http\Client\Guzzle\Aspects\PromiseConstructionAspect; +use Hypervel\Http\Client\Guzzle\Aspects\PromiseOperationAspect; +use Hypervel\Http\Client\Guzzle\Aspects\TransportOwnershipAspect; +use Hypervel\Http\Client\Guzzle\CoroutineTaskQueue; +use Hypervel\Sentry\Aspects\GuzzleHttpClientAspect as SentryGuzzleHttpClientAspect; +use Hypervel\Telescope\Aspects\GuzzleHttpClientAspect as TelescopeGuzzleHttpClientAspect; +use Hypervel\Testing\ParallelTesting; use PHPUnit\Runner\Extension\Extension; use PHPUnit\Runner\Extension\Facade; use PHPUnit\Runner\Extension\ParameterCollection; @@ -16,6 +29,10 @@ class AfterEachTestExtension implements Extension */ public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void { + Utils::queue(new TaskQueue(false)); + CoroutineTaskQueue::install(); + $this->generateGuzzleProxies(); + TestStateRegistrars::forRootInstall()->register(); $cleanup = new AfterEachTestSubscriber; @@ -24,4 +41,45 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete $facade->registerSubscriber($cleanup); $facade->registerSubscriber(new AfterEachTestExecutionFinishedSubscriber($cleanup)); } + + /** + * Generate installed framework proxies before test discovery loads their targets. + */ + protected function generateGuzzleProxies(): void + { + $existing = AspectCollector::getRules(); + $visitorsWereEmpty = AstVisitorRegistry::getQueue()->isEmpty(); + $directory = ParallelTesting::tempDir('guzzle-proxies'); + + try { + foreach ([ + PromiseConstructionAspect::class, + PromiseOperationAspect::class, + TransportOwnershipAspect::class, + SentryGuzzleHttpClientAspect::class, + TelescopeGuzzleHttpClientAspect::class, + ] as $aspect) { + if (! class_exists($aspect)) { + continue; + } + + AspectCollector::register($aspect); + } + + (new GenerateProxies)->generate($directory); + } finally { + // Proxy generation must not enable optional instrumentation in tests. + AspectCollector::flushState(); + foreach ($existing as $aspect => $rule) { + AspectCollector::setAround($aspect, $rule['classes'], $rule['priority']); + } + if ($visitorsWereEmpty) { + AstVisitorRegistry::flushState(); + } + } + + register_shutdown_function(static function () use ($directory): void { + (new Filesystem)->deleteDirectory($directory); + }); + } } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 33b3a8a00..3df6da90f 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -106,6 +106,14 @@ public function flushStateAfterTest(): void */ protected function flushFrameworkState(): void { + // Cancel while ownership aspects and the owning context still exist. + \Hypervel\Http\Client\Guzzle\CoroutineState::current()?->cancel(); + \Hypervel\Http\Client\Guzzle\CoroutineOwnership::flushState(); + $queue = \GuzzleHttp\Promise\Utils::queue(); + if ($queue instanceof \Hypervel\Http\Client\Guzzle\CoroutineTaskQueue) { + $queue->resetOutside(); + } + \Carbon\Carbon::resetMacros(); \Carbon\Carbon::resetToStringFormat(); \Carbon\Carbon::serializeUsing(null); @@ -268,6 +276,7 @@ protected function flushFrameworkState(): void \Hypervel\Session\Middleware\StartSession::flushState(); \Hypervel\Session\Store::flushState(); \Hypervel\Support\Arr::flushState(); + \Hypervel\Support\Aws\SerializedCredentialProvider::flushState(); \Hypervel\Support\Benchmark::flushState(); \Hypervel\Support\BinaryCodec::flushState(); \Hypervel\Support\ClassMetadataCache::flushState(); diff --git a/tests/Benchmarks/GuzzleOwnership/Fixtures/NoopAspect.php b/tests/Benchmarks/GuzzleOwnership/Fixtures/NoopAspect.php new file mode 100644 index 000000000..a8aa3c925 --- /dev/null +++ b/tests/Benchmarks/GuzzleOwnership/Fixtures/NoopAspect.php @@ -0,0 +1,19 @@ +process(); + } +} diff --git a/tests/Benchmarks/GuzzleOwnership/Fixtures/server.php b/tests/Benchmarks/GuzzleOwnership/Fixtures/server.php new file mode 100644 index 000000000..a7f036dec --- /dev/null +++ b/tests/Benchmarks/GuzzleOwnership/Fixtures/server.php @@ -0,0 +1,24 @@ +set(['worker_num' => 1, 'enable_coroutine' => true, 'log_level' => SWOOLE_LOG_WARNING]); +$server->on('workerStart', static function (Server $server): void { + echo 'READY ' . $server->ports[0]->port . "\n"; +}); +$server->on('request', static function (Request $request, Response $response): void { + $delay = (int) ($request->get['delay_us'] ?? 0); + if ($delay > 0) { + Coroutine::sleep($delay / 1e6); + } + + $response->header('Content-Type', 'application/json'); + $response->end('{"result":"accepted"}'); +}); +$server->start(); diff --git a/tests/Benchmarks/GuzzleOwnership/README.md b/tests/Benchmarks/GuzzleOwnership/README.md new file mode 100644 index 000000000..0ff1074ae --- /dev/null +++ b/tests/Benchmarks/GuzzleOwnership/README.md @@ -0,0 +1,51 @@ +# Guzzle ownership benchmark + +This developer harness compares the unchanged branch base with the reviewed ownership implementation. It reports startup separately from warm operations, using the production HTTP provider and generated proxies. It is not a CI test or a prediction of remote service throughput. + +See [the measured results](results.md) for the ownership cost, shared AOP improvements, and resource comparisons. + +Run measurements on an idle machine, without concurrent tests, builds, or other benchmarks. + +Use independent baseline and modified worktrees with identical installed dependency versions. The runner's `--root` selects that checkout and its autoloader, so the same harness can test both without changing baseline source. Record the Git revisions and keep the checkouts clean. Run the focused workloads with both Guzzle 8/promises 3/PSR-7 3 and Guzzle 7/promises 2/PSR-7 2; the broader consumer workloads need only the primary family unless results show a material version difference. + +Run `composer dump-autoload -o` in both checkouts before measuring. Installing an optimized baseline while using a previously unoptimized main checkout gives misleading startup and memory differences. Reports include class-map size and mode; exact entry counts may differ with the source changes. Pre-generate the proxies before warm comparisons and allow PHP's `opcache.file_update_protection` interval to pass, so newly written proxy files receive the same OPcache treatment as existing files. Measure cold startup separately. + +Start the separate origin: + +```sh +php tests/Benchmarks/GuzzleOwnership/Fixtures/server.php +``` + +It prints `READY `. Use that port in another terminal: + +```sh +php -d opcache.enable_cli=1 -d opcache.jit=disable \ + tests/Benchmarks/GuzzleOwnership/benchmark.php \ + --root=/absolute/path/to/checkout --endpoint=http://127.0.0.1:PORT \ + --scenario=async --operations=1000 --concurrency=8 --samples=5 \ + --storage=/tmp/guzzle-benchmark-current > /tmp/guzzle-current-async.json +``` + +Use separate storage directories for each checkout, dependency family and variant. The first process with an empty directory measures cold proxy generation; the next uses its cached files. Runtime samples include one explicitly labeled warmup. Startup measures autoloading, application construction, provider registration and proxy bootstrap, not a complete application boot. Stop the origin and remove the temporary worktrees, storage directories and dependency projects when finished. + +| Scenario | Work | +| --- | --- | +| `idle` | Same measurement loop without Guzzle work; identifies harness costs. | +| `promise` | Construct, chain, resolve and wait on mutable promises. | +| `sync` | Sequential public Hypervel HTTP client requests using a named connection. | +| `async` | Guzzle async fan-out, created and completed in one coroutine. | +| `parallel` | Public HTTP requests through the same named connection in bounded child coroutines. | +| `middleware` | A response middleware callback dispatching an event. | +| `credentials` | Concurrent invocations of a shared callable that fetches and resolves credentials, using the serialization wrapper when installed. | +| `pusher` | Same-coroutine fan-out through the public client built by `BroadcastManager`. | +| `cancel` | Create and cancel pending native transfers, then drain their rejection callbacks. | + +`concurrency` controls fan-out for `async`, `parallel`, `credentials` and `pusher`. Other scenarios execute sequentially. Use bounded levels such as 1, 8 and 32, with operation counts sufficient to exceed timer granularity. Keep inputs identical within each comparison. Adding `?delay_us=1000` to the endpoint supplies a controlled fetch delay for credential contention; the public Pusher endpoint uses its own URL and does not inherit that query. + +The credential provider creates independent fetches on the baseline, so the comparison is safe. Use `--concurrency=1` to measure per-call wrapper and ownership overhead. At higher concurrency, its serialized version queues callers; with `delay_us` set, compare p95 operation latency with concurrency times the uncontended fetch time. The harness records total latency, not separate I/O and lock-wait timings. Do not attribute all added latency to AOP, or benchmark the known unsafe shared pending-provider or cross-coroutine Pusher cases on the baseline as equivalent successful work; regression tests cover their correction. + +On the modified checkout, `--variant=noop` replaces the registered ownership aspects with one pass-through aspect targeting the same methods and uses the stock task queue. This diagnostic separates ordinary AOP dispatch from the combined ownership and queue bookkeeping. It is not a production mode, and end-to-end before/after tables use `production` on both checkouts. Never use the diagnostic to evaluate correctness or cross-coroutine misuse. + +Reports include wall time, process CPU, throughput, p50/p95 operation latency, PHP used/allocated/peak memory, Linux RSS, descriptor counts and GC statistics before/after each sample and after coroutine exit. The separately running origin's CPU is excluded. Results and latency arrays consume memory proportional to the operation count; they are released before each `after_gc` snapshot. Final cleanup includes coroutine-owned handlers and queues. Report both retained warm resources and resources after exit; PHP heap alone does not account for native cURL memory. + +Use a small fixed set of alternating baseline/current process pairs, initially three pairs per scenario. Report medians and spread, absolute changes and percentages; add repetitions only to resolve observed uncertainty. Compare cold startup, cached startup and warm runtime independently. Keep raw JSON outside the repository and include exact commands, versions, CPU/VM details, OPcache/JIT/GC settings and limitations with the result tables. Measurements that reveal meaningful overhead require investigation before adding an optimization. Do not introduce timing assertions or assume a difference is noise. diff --git a/tests/Benchmarks/GuzzleOwnership/benchmark.php b/tests/Benchmarks/GuzzleOwnership/benchmark.php new file mode 100644 index 000000000..406bdee23 --- /dev/null +++ b/tests/Benchmarks/GuzzleOwnership/benchmark.php @@ -0,0 +1,314 @@ +#!/usr/bin/env php + 1000, 'concurrency' => 8, 'samples' => 5] as $key => $default) { + $value = filter_var($options[$key] ?? $default, FILTER_VALIDATE_INT); + if ($value === false || $value < 1) { + throw new InvalidArgumentException("--{$key} must be a positive integer."); + } + $settings[$key] = $value; +} + +/** + * Read client resources, excluding the separately running origin. + */ +function resources(): array +{ + preg_match('/^VmRSS:\s+(\d+) kB$/m', file_get_contents('/proc/self/status'), $resident); + + return [ + 'heap_bytes' => memory_get_usage(), + 'allocated_bytes' => memory_get_usage(true), + 'peak_bytes' => memory_get_peak_usage(true), + 'rss_bytes' => (int) $resident[1] * 1024, + 'open_fds' => count(scandir('/proc/self/fd')) - 2, + 'gc' => gc_status(), + ]; +} + +/** + * Return CPU consumed by this process in seconds. + */ +function cpuSeconds(): float +{ + $usage = getrusage(); + + return $usage['ru_utime.tv_sec'] + $usage['ru_stime.tv_sec'] + + ($usage['ru_utime.tv_usec'] + $usage['ru_stime.tv_usec']) / 1e6; +} + +/** + * Return measured operation latency percentiles in milliseconds. + */ +function percentiles(array $latencies): array +{ + sort($latencies, SORT_NUMERIC); + + return [ + 'p50_ms' => $latencies[(int) ceil(count($latencies) * 0.5) - 1], + 'p95_ms' => $latencies[(int) ceil(count($latencies) * 0.95) - 1], + ]; +} + +$startupStarted = hrtime(true); +$startupCpu = cpuSeconds(); +$initialResources = resources(); +$loader = require $root . '/vendor/autoload.php'; +$app = new Application($root); +$storage = $options['storage'] ?? sys_get_temp_dir() . '/hypervel-guzzle-benchmark-' . getmypid(); +$app->useStoragePath($storage); +$app->useBootstrapPath($storage . '/bootstrap'); +// The unchanged baseline stores generated proxies beneath storage instead. +$cachedProxies = is_dir($app->bootstrapPath('cache/aop')) || is_dir($storage . '/framework/aop'); +(new HttpServiceProvider($app))->register(); + +if ($variant === 'noop') { + // Diagnostic only: use the current branch's exact targets without its queue or ownership work. + $rules = AspectCollector::getClassRules(); + if ($rules === []) { + throw new LogicException('Use the noop variant only on the ownership branch.'); + } + require __DIR__ . '/Fixtures/NoopAspect.php'; + AspectCollector::flushState(); + AspectCollector::setAround(NoopAspect::class, array_merge(...array_values($rules))); + Utils::queue(new TaskQueue(false)); +} +(new GenerateProxies)->bootstrap($app); +$startup = [ + 'proxy_cache_preexisting' => $cachedProxies, + 'wall_ms' => (hrtime(true) - $startupStarted) / 1e6, + 'cpu_ms' => (cpuSeconds() - $startupCpu) * 1000, + 'before' => $initialResources, + 'after' => resources(), +]; + +$git = new Process(['git', 'rev-parse', 'HEAD'], $root); +$git->mustRun(); +$cpuInfo = file_get_contents('/proc/cpuinfo'); +preg_match('/^model name\s*:\s*(.+)$/m', $cpuInfo, $cpuModel); +$metadata = [ + 'commit' => trim($git->getOutput()), + 'php' => PHP_VERSION, + 'swoole' => phpversion('swoole'), + 'curl' => curl_version(), + 'guzzle' => InstalledVersions::getPrettyVersion('guzzlehttp/guzzle'), + 'promises' => InstalledVersions::getPrettyVersion('guzzlehttp/promises'), + 'psr7' => InstalledVersions::getPrettyVersion('guzzlehttp/psr7'), + 'system' => php_uname(), + 'cpu' => [ + 'model' => $cpuModel[1] ?? null, + 'logical_processors' => preg_match_all('/^processor\s*:/m', $cpuInfo), + ], + 'opcache' => ini_get('opcache.enable_cli'), + 'opcache_file_update_protection' => ini_get('opcache.file_update_protection'), + 'jit' => ini_get('opcache.jit'), + 'autoload_classmap_entries' => count($loader->getClassMap()), + 'autoload_classmap_authoritative' => $loader->isClassMapAuthoritative(), + 'gc_enabled' => gc_enabled(), + 'command' => $argv, + 'scenario' => $scenario, + 'variant' => $variant, + 'settings' => $settings, + 'storage' => $storage, +]; +unset($cpuInfo, $cpuModel); +$reports = []; +$failure = null; +run(static function () use ($app, $scenario, $endpoint, $settings, &$reports, &$failure): void { + try { + $client = in_array($scenario, ['async', 'credentials', 'cancel'], true) + ? new Client(['timeout' => 5, 'connect_timeout' => 2, 'proxy' => '']) + : null; + $factory = in_array($scenario, ['sync', 'parallel'], true) ? $app->make(Factory::class) : null; + $factory?->registerConnection('benchmark', ['timeout' => 5, 'proxy' => '']); + $eventCount = 0; + $middlewareClient = null; + if ($scenario === 'middleware') { + $events = new Dispatcher; + $events->listen('benchmark.response', static function () use (&$eventCount): void { ++$eventCount; }); + $stack = HandlerStack::create(); + $stack->push(static fn (callable $handler): Closure => static function (RequestInterface $request, array $options) use ($handler, $events): PromiseInterface { + return $handler($request, $options)->then(static function (ResponseInterface $response) use ($events): ResponseInterface { + $events->dispatch('benchmark.response'); + + return $response; + }); + }); + $middlewareClient = new Client(['handler' => $stack, 'timeout' => 5, 'proxy' => '']); + } + $provider = static function () use ($client, $endpoint): PromiseInterface { + $promise = new Promise(static function () use (&$promise, $client, $endpoint): void { + $client->get($endpoint); + $promise->resolve(new Credentials('benchmark', 'secret')); + }); + + return $promise; + }; + $credentials = $scenario === 'credentials' && class_exists(SerializedCredentialProvider::class) + ? new SerializedCredentialProvider($provider) + : $provider; + $pusher = null; + if ($scenario === 'pusher') { + $pusher = (new BroadcastManager($app))->pusher([ + 'key' => 'key', 'secret' => 'secret', 'app_id' => 'app', + 'options' => ['host' => '127.0.0.1', 'port' => parse_url($endpoint, PHP_URL_PORT), 'scheme' => 'http', 'useTLS' => false], + 'client_options' => ['timeout' => 5, 'proxy' => ''], + ]); + } + + $operation = static function () use ($scenario, $client, $factory, $middlewareClient, $credentials, $endpoint): mixed { + if ($scenario === 'idle') { + return 3; + } + if ($scenario === 'promise') { + $promise = new Promise; + $result = $promise->then(static fn (int $value): int => $value + 1) + ->then(static fn (int $value): int => $value + 1); + $promise->resolve(1); + + return $result->wait(); + } + if ($scenario === 'credentials') { + return $credentials()->wait()->getAccessKeyId(); + } + if ($scenario === 'cancel') { + $promise = $client->getAsync($endpoint); + $promise->cancel(); + Utils::queue()->run(); + + return $promise->getState(); + } + if ($scenario === 'middleware') { + return (string) $middlewareClient->get($endpoint)->getBody(); + } + + return $factory->connection('benchmark')->get($endpoint)->throw()->body(); + }; + + for ($sample = -1; $sample < $settings['samples']; ++$sample) { + $count = $sample < 0 ? min(32, $settings['operations']) : $settings['operations']; + $expected = match ($scenario) { + 'idle', 'promise' => 3, + 'credentials' => 'benchmark', + 'cancel' => PromiseInterface::REJECTED, + 'pusher' => 'accepted', + default => '{"result":"accepted"}', + }; + $latencies = []; + $values = []; + $eventCount = 0; + gc_collect_cycles(); + memory_reset_peak_usage(); + $before = resources(); + $cpu = cpuSeconds(); + $started = hrtime(true); + for ($offset = 0; $offset < $count; $offset += $settings['concurrency']) { + $size = min($settings['concurrency'], $count - $offset); + if (in_array($scenario, ['async', 'pusher'], true)) { + $promises = []; + for ($index = 0; $index < $size; ++$index) { + $begin = hrtime(true); + $promise = $scenario === 'pusher' + ? $pusher->triggerAsync('channel', 'event', []) + : $client->getAsync($endpoint); + $promises[] = $promise->then(static function (mixed $response) use ($begin, $scenario, &$latencies): string { + $latencies[] = (hrtime(true) - $begin) / 1e6; + + return $scenario === 'pusher' ? $response->result : (string) $response->getBody(); + }); + } + array_push($values, ...Utils::all($promises)->wait()); + unset($promise, $promises); + } else { + $measured = static function () use ($operation, &$latencies): mixed { + $begin = hrtime(true); + $value = $operation(); + $latencies[] = (hrtime(true) - $begin) / 1e6; + + return $value; + }; + if (in_array($scenario, ['parallel', 'credentials'], true)) { + array_push($values, ...parallel(array_fill(0, $size, $measured))); + } else { + for ($index = 0; $index < $size; ++$index) { + $values[] = $measured(); + } + } + } + } + $elapsed = (hrtime(true) - $started) / 1e9; + $cpuElapsed = cpuSeconds() - $cpu; + $after = resources(); + if (count($values) !== $count || array_filter($values, static fn (mixed $value): bool => $value !== $expected) !== []) { + throw new RuntimeException('The workload did not produce the expected completed results.'); + } + if ($scenario === 'middleware' && $eventCount !== $count) { + throw new RuntimeException('A middleware response event was lost.'); + } + $latency = percentiles($latencies); + unset($measured, $values, $latencies); + gc_collect_cycles(); + $reports[] = [ + 'warmup' => $sample < 0, 'operations' => $count, + 'wall_ms' => $elapsed * 1000, 'cpu_ms' => $cpuElapsed * 1000, + 'operations_per_second' => $count / $elapsed, + 'latency' => $latency, 'before' => $before, 'after' => $after, 'after_gc' => resources(), + ]; + } + $factory?->forgetConnectionHandlers(); + } catch (Throwable $exception) { + $failure = $exception; + } +}); +if ($failure !== null) { + throw $failure; +} +gc_collect_cycles(); +echo json_encode(['metadata' => $metadata, 'startup' => $startup, 'samples' => $reports, 'after_exit' => resources()], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n"; diff --git a/tests/Benchmarks/GuzzleOwnership/results.md b/tests/Benchmarks/GuzzleOwnership/results.md new file mode 100644 index 000000000..054c31e99 --- /dev/null +++ b/tests/Benchmarks/GuzzleOwnership/results.md @@ -0,0 +1,147 @@ +# Guzzle ownership and AOP measurements + +The ownership checks add measurable CPU cost. Shared AOP optimizations reduce total guarded promise-chain CPU time by about 48% compared with the first implementation, and reduce its added overhead above baseline by about 52%. On the measured Guzzle 8 code, synchronous HTTP adds about 39 µs of client CPU per request; coroutine-parallel HTTP at concurrency 32 adds about 38 µs. These are local workloads, not estimates of application response time or remote-service throughput. + +## Method + +Baseline: unchanged `0.4` at `4f592496a605fe5028c908ae50ee12173428957b`. The measured implementation is committed through `5c1c44850`, apart from the final equivalent loader-creation simplification made after measurements. The benchmark harness is identical for both. Each checkout has independently installed dependencies and optimized, non-authoritative Composer autoloading. + +These measurements precede the subsequent class-map override cleanup, repeat-boot and release-local proxy corrections, and the switch to `xxh128` for internal proxy fingerprints and helper names. Those corrections add generated source metadata and bootstrap lookup for registered overrides; they do not change the per-request dispatch or ownership checks. Their startup and retained-memory effects have not been remeasured, so the resource figures below describe the named benchmark revision rather than the later corrections. + +The credential-provider rows also precede the correction allowing nested SDK callbacks to resolve credentials while their own coroutine holds the provider lock. Its active-owner lookup and cleanup have not been remeasured; the HTTP, Pusher and shared AOP dispatch paths are unchanged by that correction. + +Measurements used PHP 8.4.25, Swoole 6.2.2, cURL 8.5.0 with OpenSSL 3.0.13, Linux x86-64, and an Intel Core i7-7700 at 3.60 GHz with six logical processors exposed. CLI OPcache was enabled, JIT disabled, GC enabled, and `opcache.file_update_protection=2`. Proxy files were generated before warm comparisons and allowed to age past that interval. The loopback origin ran in a separate process; its CPU is excluded. No tests or other measurements ran concurrently. + +Each workload has three alternating baseline/candidate process pairs, each with a 32-operation warmup followed by three measured samples. Tables show the median of the nine warm samples and their minimum–maximum range. This range is not a confidence interval. Runtime figures exclude application/proxy startup. Wall time divided by operation count measures batch throughput; individual latency is reported separately where useful. + +| Workload | Operations per sample | Concurrency | +| --- | ---: | --- | +| Idle loop | 50,000 | 1 | +| Promise chain | 20,000 | 1 | +| Synchronous HTTP, response middleware, cancellation | 1,000 | 1 | +| Coroutine-parallel HTTP | 512 | 1, 8, 32 | +| Async HTTP, Pusher | 128 | 1, 8, 32 | +| Shared credential provider | 128 | 1, 8 | + +The credential endpoint adds a controlled 1 ms delay. The baseline provider creates independent operations, so that comparison is safe; the candidate serializes calls to the shared provider. It measures the cost of that correctness guarantee as well as the Guzzle integration. Known unsafe cross-coroutine promise/transport sharing is covered by regression tests, not treated as a valid throughput baseline. + +Reproduce with the commands in [README.md](README.md), substituting the operation counts and concurrency above. Raw reports retain exact commands and versions. The complete runtime matrices are `complete-g8-*` and `complete-g7-*`. After the final bootstrap-only corrections, `loader-g8-*` refreshes all resource rows and their seven runtime comparisons; the other runtime rows retain the earlier measurements of unchanged dispatch and ownership code. `loader-g7-idle-*` refreshes Guzzle 7 startup and idle resources. Earlier `matched-g8-*`, `compact-map-*`, and `lazy-ast-*` reports identify optimization stages. Initial `g8-*` and `final-g8-*` resource comparisons had different autoloader settings and are excluded from final conclusions. + +## Shared AOP improvements + +The dispatcher now reuses a compiled chain containing only aspect class names and continuation closures. It still resolves each aspect from the current container for each invocation. Generated proxies supply their target instance directly, avoiding repeated closure reflection. Invocation objects and request state are never stored in the chain. + +The promise probe constructs three mutable promises and intercepts nine calls per operation. The no-op variant uses the same intercepted methods with a pass-through aspect and the stock task queue. It isolates dispatch overhead; it is not a production mode. + +| Warm promise probe | Original dispatcher CPU, µs/op | Compiled chain CPU, µs/op | Change | +| --- | ---: | ---: | ---: | +| Pass-through aspect | 31.62 | 15.80 | −50.0% | +| Production ownership checks | 39.52 | 21.23 | −46.3% | + +A separate alternating comparison at 100,000 chains per sample isolated direct instance passing: **21.72 → 20.48 µs/op**, a further **5.7% reduction**, with ranges 20.93–22.91 and 20.15–21.30. Each of its three process pairs improved. The first complete matrix measured **20.50 µs/op**, about **48.1% below the original 39.52 µs/op**. Against its unchanged framework baseline of 3.22 µs/op, added overhead fell from **36.30 to 17.28 µs/op**, a **52.4% reduction**. The final bootstrap refresh measured 20.57 µs/op with the same dispatch code. + +Bootstrap publishes proxies through a separate, authoritative Composer loader containing only proxy entries. The application loader retains its original paths without copying its complete class map. Exact class rules use direct lookup, and the PHP parser/printer are created only when generating code. The existing source fingerprint and visitor behavior remain intact; native directory enumeration replaces Symfony Finder for the fixed generator directory. + +| Cached bootstrap stage | Startup median (range), ms | Retained PHP heap, KiB | RSS, MiB | +| --- | ---: | ---: | ---: | +| Ownership implementation before bootstrap optimization | 181.16 (173.86–185.98) | 6,114.61 | 74.64 | +| Targeted original paths and class lookup | 108.39 (101.21–109.97) | 4,834.70 | 73.27 | +| Lazy parser/printer | 83.72 (81.49–85.49) | 4,510.60 | 71.49 | +| Dedicated proxy loader and native directory enumeration | 78.15 (75.86–82.25) | 3,164.52 | 69.96 | + +The first three stages use three cached idle processes each. The final startup row uses all 21 cached candidate processes from the seven refreshed workloads, whose startup is identical and completes before workload setup; its heap/RSS values use the three idle processes. Combined, these changes remove about **2.88 MiB of retained PHP heap** and **57% of cached startup time** from the first ownership implementation. The corresponding 21 baseline processes measured **69.95 ms** (68.07–80.48), versus **78.15 ms** for the candidate: **8.20 ms** added at startup. One fresh candidate process requiring proxy generation took **132.45 ms**; this cold process is excluded from cached timings and resource tables. + +## Final Guzzle 8 comparison + +Both checkouts use Guzzle 8.2.0, promises 3.0.2, PSR-7 3.1.0 and AWS SDK 3.399.1. CPU and wall columns are **µs per completed operation**. Ranges show baseline / candidate CPU samples. + +| Workload | Concurrency | CPU baseline → candidate | Added CPU | CPU sample ranges | Wall baseline → candidate | +| --- | ---: | ---: | ---: | --- | ---: | +| Idle † | 1 | 0.33 → 0.34 | 0.01 (rounding-sensitive) | 0.33–0.37 / 0.33–0.55 | 0.33 → 0.34 | +| Promise chain † | 1 | 3.23 → 20.57 | 17.34 (536.5%) | 3.17–3.37 / 20.08–21.87 | 3.23 → 20.57 | +| Synchronous HTTP † | 1 | 405.37 → 444.67 | 39.30 (9.7%) | 395.04–423.56 / 436.49–458.81 | 1,573.61 → 1,617.44 | +| Async HTTP | 1 | 289.60 → 377.82 | 88.22 (30.5%) | 274.25–307.85 / 370.02–408.18 | 323.25 → 424.67 | +| Async HTTP | 8 | 200.12 → 259.75 | 59.63 (29.8%) | 193.23–222.44 / 254.02–279.17 | 62,754.72 → 62,820.01 | +| Async HTTP † | 32 | 152.42 → 210.28 | 57.86 (38.0%) | 144.83–159.85 / 200.55–233.86 | 15,793.33 → 15,850.79 | +| Coroutine-parallel HTTP | 1 | 466.42 → 519.82 | 53.40 (11.4%) | 444.83–472.85 / 493.32–532.92 | 2,739.11 → 2,800.48 | +| Coroutine-parallel HTTP | 8 | 422.48 → 482.29 | 59.80 (14.2%) | 401.90–442.48 / 465.33–494.56 | 692.99 → 755.78 | +| Coroutine-parallel HTTP † | 32 | 430.56 → 468.59 | 38.03 (8.8%) | 405.40–461.20 / 453.02–487.84 | 502.64 → 535.97 | +| Response middleware/event | 1 | 301.30 → 339.90 | 38.61 (12.8%) | 294.46–308.23 / 332.78–348.05 | 1,459.76 → 1,495.88 | +| Credential provider | 1 | 371.19 → 419.12 | 47.94 (12.9%) | 346.78–391.45 / 402.99–476.49 | 3,742.10 → 3,803.64 | +| Credential provider | 8 | 294.69 → 409.29 | 114.60 (38.9%) | 283.31–321.66 / 394.66–427.19 | 701.72 → 2,827.56 | +| Pusher | 1 | 390.30 → 500.75 | 110.45 (28.3%) | 385.42–418.23 / 494.96–507.88 | 441.51 → 548.78 | +| Pusher | 8 | 299.13 → 374.29 | 75.16 (25.1%) | 293.68–307.20 / 369.04–405.23 | 62,855.13 → 62,938.13 | +| Pusher † | 32 | 267.77 → 330.34 | 62.57 (23.4%) | 254.00–296.39 / 322.06–374.62 | 15,900.80 → 15,968.84 | +| Cancel pending transfer † | 1 | 108.96 → 165.89 | 56.93 (52.3%) | 106.50–124.49 / 157.19–169.16 | 108.99 → 165.90 | + +† Refreshed `loader-g8-*` runs after the bootstrap corrections. Unmarked rows are from `complete-g8-*`, using the same dispatch and ownership implementation. Do not treat rows from different runs as a controlled concurrency-scaling comparison. + +The high async/Pusher wall times at concurrency 8 and 32 include the known hooked-select readiness stall addressed by [Swoole #6290](https://github.com/swoole/swoole-src/pull/6290). They must not be used to claim negligible AOP overhead. CPU measurements expose the added work separately. A patched older Swoole build confirmed the reproduction, but it is below Hypervel's supported minimum and was not used for these performance tables. The default AWS Guzzle transport also uses `sendAsync`, including for synchronous SDK calls, so the native issue is not limited to explicitly async application code. + +| Individual operation latency | Baseline p50 / p95, ms | Candidate p50 / p95, ms | +| --- | ---: | ---: | +| Synchronous HTTP | 1.563 / 1.687 | 1.610 / 1.731 | +| Coroutine-parallel HTTP, concurrency 32 | 10.132 / 12.269 | 10.858 / 12.615 | +| Credential provider, concurrency 1 | 3.720 / 3.899 | 3.779 / 3.963 | +| Credential provider, concurrency 8 | 4.702 / 5.396 | 12.118 / 22.474 | + +The contended credential-provider latency includes waiting for the provider lock. This is the deliberate serialization needed for a shared provider that may retain pending work; it is not the cost of aspect dispatch alone. A memoized, already-completed credential result remains reusable. + +## Capacity planning + +The 38.03 µs added CPU measured for coroutine-parallel HTTP at concurrency 32 is per outgoing request, not per batch. Multiplying it by the outgoing request rate gives the additional CPU capacity across a deployment: + +| Outgoing requests per second | Additional CPU cores at 38.03 µs/request | +| ---: | ---: | +| 1,000 | 0.038 | +| 10,000 | 0.380 | +| 50,000 | 1.902 | + +These are arithmetic projections from the measured per-operation CPU, not throughput benchmarks at those traffic levels. Other HTTP and Pusher paths in the table add approximately 39–110 µs per outgoing request. Budgeting 40–110 µs corresponds to approximately 0.4–1.1 additional cores at 10,000 outgoing requests per second. Credential-provider contention is listed separately because it deliberately serializes shared provider calls. + +Retained integration code and method metadata are bounded per worker; the 124–188 KiB increase below is not added for every request. Temporary ownership tracking follows outstanding promises and native transfers and releases them as work completes or is canceled. Applications should bound outgoing concurrency and queues: the integration does not cap the amount of unfinished work an application can create. + +## Investigating the remaining costs + +One cached-startup profile and one profile each of async and cancellation used XHProf 2.3.10, loaded only into those diagnostic processes. Instrumented timings are not mixed into the benchmark tables. Profiles and a separate uninstrumented pass-through comparison explain the remaining work: + +- Cached bootstrap performs queue installation and aspect registration, then loads and validates the proxy generator and its inputs. In the profile, provider registration added about 2.17 ms and proxy bootstrap about 7.17 ms. The latter includes roughly 4.19 ms for content fingerprints and their dependencies, including visitor metadata and the installed PHP parser revision. This is startup work, not a per-request scan. The full Composer-map copy, repeated class-rule scan, unused parser construction and Finder loading have been removed. +- The async harness completes batches through `Utils::unwrap()`. At concurrency one, that creates nine mutable promises and makes **27 intercepted calls per operation**: nine constructors, nine resolves, six `then` calls, two waits and one transfer admission. It does more promise work than a single `getAsync()->then()->wait()` chain. Cancellation makes **12 intercepted calls**. +- A separate uninstrumented diagnostic, with 1,000 operations and three warm samples at concurrency one, measured async CPU at **283.28 → 347.88 → 381.40 µs/op** for baseline, pass-through AOP and production ownership. Cancellation measured **106.35 → 142.14 → 159.90 µs/op**. Dispatch therefore accounts for about 64.60 µs and 35.79 µs respectively; ownership and coroutine-local queue handling add about 33.52 µs and 17.76 µs. These are diagnostic process medians, not additional samples in the main paired matrix. + +The warm profiles show argument reconstruction, join-point/closure calls, current-container resolution, weak ownership lookups and coroutine-local queue delegation. There were no GC runs inside the timed operation loops. A single per-call estimate from the shorter promise probe does not account for the different intercepted arguments and lifecycle work. Further caching of aspect instances or coroutine state would change the binding and ownership guarantees; these measurements do not justify that change. + +## Guzzle 7 compatibility comparison + +Both independent checkouts use Guzzle 7.15.5, promises 2.5.3, PSR-7 2.13.1 and AWS SDK 3.399.1. The focused workloads use the same counts and three-pair protocol, except async concurrency 8 uses 32 operations per sample. That smaller batch still exceeds timer granularity and reproduces the native select delay. Raw reports are `complete-g7-*`. + +| Workload | Concurrency | CPU baseline → candidate, µs/op | Added CPU | CPU sample ranges, µs/op | Wall baseline → candidate, µs/op | +| --- | ---: | ---: | ---: | --- | ---: | +| Idle | 1 | 0.34 → 0.34 | Below displayed precision | 0.33–0.43 / 0.32–0.39 | 0.33 → 0.34 | +| Promise chain | 1 | 3.16 → 20.53 | 17.36 (548.7%) | 3.09–3.61 / 20.14–21.80 | 3.16 → 20.53 | +| Synchronous HTTP | 1 | 377.37 → 420.63 | 43.26 (11.5%) | 367.89–382.38 / 409.46–424.58 | 1,544.25 → 1,586.15 | +| Async HTTP | 1 | 249.80 → 339.58 | 89.77 (35.9%) | 238.15–276.98 / 331.91–404.75 | 284.95 → 390.40 | +| Async HTTP | 8 | 180.75 → 234.09 | 53.34 (29.5%) | 166.13–193.22 / 229.53–250.53 | 62,740.06 → 62,802.61 | +| Cancel pending transfer | 1 | 85.15 → 139.39 | 54.24 (63.7%) | 83.67–91.84 / 135.15–148.98 | 85.14 → 139.39 | + +Absolute added CPU closely matches Guzzle 8 on these paths. These runtime samples precede the final bootstrap-only correction. Three refreshed cached-idle process pairs measured startup at **70.59 → 77.85 ms** (70.27–71.63 / 76.86–79.86), retained heap at **3,021.52 → 3,162.02 KiB**, and RSS at **68.94 → 69.62 MiB**. All 36 original runtime processes and six refreshed cached-idle processes returned to six descriptors; warm-sample heap growth was the same 11,312 bytes of report bookkeeping in both versions. No version-specific implementation branch was needed for the AOP optimizations. + +## Resources and cleanup + +Values below are process medians after the coroutine exits and GC runs. RSS includes shared code pages and must not be read as private memory added to every server worker. PHP heap and RSS are different measurements. + +| Guzzle 8 workload | PHP heap baseline → candidate, KiB | RSS baseline → candidate, MiB | +| --- | ---: | ---: | +| Idle | 3,021.33 → 3,164.52 | 68.88 → 69.96 | +| Promise chain | 3,023.77 → 3,175.22 | 68.97 → 70.15 | +| Synchronous HTTP | 4,135.29 → 4,286.07 | 73.18 → 74.27 | +| Async HTTP, concurrency 32 | 3,555.43 → 3,678.95 | 71.77 → 72.80 | +| Coroutine-parallel HTTP, concurrency 32 | 4,155.11 → 4,305.95 | 75.89 → 77.08 | +| Pusher, concurrency 32 | 3,621.02 → 3,809.09 | 72.29 → 73.38 | +| Cancel pending transfer | 3,478.91 → 3,642.44 | 71.07 → 72.01 | + +All 42 refreshed Guzzle 8 processes returned to six open descriptors after exit, as did all 96 in the earlier complete matrix. Across their three warm samples, retained PHP heap increased by exactly 11,312 bytes in both baseline and candidate, matching accumulated result records. These measurements show no additional batch-to-batch retained growth; regression tests separately cover ownership release, cancellation and weak-reference cleanup. The final retained increase is about **124–188 KiB of PHP heap**, including loaded integration code and bounded metadata. The separate loader removes Composer's approximately 1.25 MiB full-map copy; dropping Finder removes additional loaded code. + +For these rows, allocated PHP memory after exit was **16 → 16 MiB**. Median warm-sample allocated peaks were **16 → 16 MiB** for HTTP, Pusher and cancellation, **18 → 18 MiB** for promise chains, and **20 → 20 MiB** for the 50,000-operation idle loop. + +The harness records snapshots before each sample, after its operations, after releasing result/latency arrays and GC, and after coroutine exit. Peak measurements include those arrays and depend on the sample's operation count; they are not application memory forecasts. The measured closure is also released before the GC snapshot so its captured latency array cannot inflate retained-memory results. diff --git a/tests/Broadcasting/PusherClientTest.php b/tests/Broadcasting/PusherClientTest.php new file mode 100644 index 000000000..e37b9acd6 --- /dev/null +++ b/tests/Broadcasting/PusherClientTest.php @@ -0,0 +1,361 @@ +server?->shutdown(); + + if ($this->serverCoroutine !== null) { + Coroutine::join([$this->serverCoroutine], 1); + } + } + + #[DataProvider('drivers')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testConcurrentAsyncCallsKeepTransfersAndStatsInTheirCallingCoroutines(string $driver): void + { + $this->startServer(); + $owners = $observed = []; + $client = $this->client($driver, [ + 'on_stats' => static function (TransferStats $stats) use (&$observed): void { + $payload = json_decode((string) $stats->getRequest()->getBody(), true, flags: JSON_THROW_ON_ERROR); + $name = $payload['name'] ?? $payload['batch'][0]['name']; + $observed[$name] = Coroutine::getCid(); + }, + ]); + + $results = parallel([ + static function () use ($client, &$owners): object { + $owners['single'] = Coroutine::getCid(); + $promise = $client->triggerAsync('channel', 'single', ['value' => 1]); + usleep(1000); + + return $promise->wait(); + }, + static function () use ($client, &$owners): object { + $owners['batch'] = Coroutine::getCid(); + + return $client->triggerBatchAsync([ + ['channel' => 'channel', 'name' => 'batch', 'data' => ['value' => 2]], + ])->wait(); + }, + static function () use ($client, &$owners): object { + $owners['sync'] = Coroutine::getCid(); + + return $client->trigger('channel', 'sync', []); + }, + ]); + + $this->assertCount(3, $results); + $this->assertSame('accepted', $results[0]->result); + $this->assertSame('accepted', $results[1]->result); + $this->assertSame('accepted', $results[2]->result); + ksort($owners); + ksort($observed); + $this->assertSame($owners, $observed); + } + + #[DataProvider('drivers')] + public function testSequentialSynchronousCallsReuseTheirConnection(string $driver): void + { + $this->startServer(); + $client = $this->client($driver); + + $this->assertSame('accepted', $client->trigger('channel', 'first', [])->result); + $this->assertSame('accepted', $client->trigger('channel', 'second', [])->result); + $this->assertCount(2, $this->connections); + $this->assertSame($this->connections[0], $this->connections[1]); + } + + /** + * Provide broadcasting drivers that use the same Pusher client construction. + */ + public static function drivers(): array + { + return ['Pusher' => ['pusher'], 'Reverb' => ['reverb']]; + } + + public function testAsyncRequestsInOneCoroutineMakeProgressTogether(): void + { + $arrivals = 0; + $firstSawBoth = false; + $gate = new Channel(1); + $this->startServer(static function (ServerRequest $request, ServerResponse $response) use (&$arrivals, &$firstSawBoth, $gate): void { + if (++$arrivals === 1) { + $firstSawBoth = $gate->pop(1) === true; + } else { + $gate->push(true); + } + + $response->end('{"result":"accepted"}'); + }); + $client = $this->client('pusher'); + + try { + $results = Utils::all([ + $client->triggerAsync('channel', 'first', []), + $client->triggerAsync('channel', 'second', []), + ])->wait(); + + $this->assertTrue($firstSawBoth, 'Both requests must arrive before the first response is sent.'); + $this->assertCount(2, $results); + $this->assertSame('accepted', $results[0]->result); + $this->assertSame('accepted', $results[1]->result); + } finally { + $gate->close(); + } + } + + public function testCopiedContextDoesNotLetAChildDriveItsParentsPendingTransfer(): void + { + $arrived = []; + $this->startServer(static function (ServerRequest $request, ServerResponse $response) use (&$arrived): void { + $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR); + $arrived[] = $payload['name']; + $response->end('{"result":"accepted"}'); + }); + $observed = []; + $client = $this->client('pusher', [ + 'on_stats' => static function (TransferStats $stats) use (&$observed): void { + $payload = json_decode((string) $stats->getRequest()->getBody(), true, flags: JSON_THROW_ON_ERROR); + $observed[$payload['name']] = Coroutine::getCid(); + }, + ]); + $parentOwner = Coroutine::getCid(); + $parent = $client->triggerAsync('channel', 'parent', []); + $childOwner = null; + $child = parallel([ + static function () use ($client, &$childOwner): object { + $childOwner = Coroutine::getCid(); + + return $client->triggerAsync('channel', 'child', [])->wait(); + }, + ], copyContext: true); + + $this->assertSame(['child'], $arrived); + $this->assertSame('accepted', $child[0]->result); + $this->assertSame('accepted', $parent->wait()->result); + $this->assertSame(['child', 'parent'], $arrived); + $this->assertSame($parentOwner, $observed['parent']); + $this->assertSame($childOwner, $observed['child']); + } + + public function testAsyncFailureDoesNotPreventTheNextRequest(): void + { + $this->startServer(); + $client = $this->client('pusher'); + + try { + $client->triggerAsync('channel', 'fail', [])->wait(); + $this->fail('The failed publish was not reported.'); + } catch (ApiErrorException $exception) { + $this->assertSame(503, $exception->getCode()); + } + + $this->assertSame('accepted', $client->triggerAsync('channel', 'recovered', [])->wait()->result); + $this->assertCount(2, $this->connections); + $this->assertSame($this->connections[0], $this->connections[1]); + } + + public function testCustomHandlerReceivesConfiguredOptionsAndBothRequestModes(): void + { + $requests = []; + $client = $this->client('pusher', [ + 'connect_timeout' => 2, + 'multiplex' => Multiplexing::EAGER, + 'headers' => ['X-Custom' => 'preserved'], + 'handler' => static function (RequestInterface $request, array $options) use (&$requests): PromiseInterface { + $requests[] = [$request, $options]; + + return Create::promiseFor(new Response(200, [], '{"result":"accepted"}')); + }, + ]); + + $client->trigger('channel', 'sync', []); + $client->triggerAsync('channel', 'async', [])->wait(); + + $this->assertCount(2, $requests); + $this->assertTrue($requests[0][1]['synchronous']); + $this->assertFalse($requests[1][1]['synchronous'] ?? false); + + foreach ($requests as [$request, $options]) { + $this->assertSame('preserved', $request->getHeaderLine('X-Custom')); + $this->assertSame(2, $options['connect_timeout']); + $this->assertSame(3, $options['timeout']); + $this->assertSame(Multiplexing::EAGER, $options['multiplex']); + } + } + + #[DataProvider('sharingModes')] + public function testDefaultHandlersPreserveTransportOptions(string $sharing): void + { + $options = [ + 'transport_sharing' => $sharing, + 'multiplex' => Multiplexing::NONE, + 'version' => '2.0', + ]; + + try { + new Client($options); + } catch (InvalidArgumentException|RuntimeException $exception) { + // Required sharing depends on the installed libcurl capabilities. + $this->expectException($exception::class); + $this->expectExceptionMessageIs($exception->getMessage()); + $this->app->make(BroadcastManager::class)->pusher($this->configuration('pusher', $options)); + + return; + } + + $this->startServer(); + $client = $this->client('pusher', $options); + + $this->assertSame('accepted', $client->trigger('channel', 'sync', [])->result); + $this->assertSame('accepted', $client->triggerAsync('channel', 'async', [])->wait()->result); + } + + /** + * Provide optional and mandatory sharing modes for default handlers. + */ + public static function sharingModes(): array + { + return [[TransportSharing::HANDLER_PREFER], [TransportSharing::HANDLER_REQUIRE]]; + } + + #[DataProvider('customHandlerOptions')] + public function testCustomHandlerOptionsRetainGuzzlesValidation(array $options, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->app->make(BroadcastManager::class)->pusher($this->configuration('pusher', $options + [ + 'handler' => static fn (RequestInterface $request, array $options): PromiseInterface => Create::promiseFor(new Response), + ])); + } + + /** + * Provide options Guzzle validates when a custom handler is supplied. + */ + public static function customHandlerOptions(): array + { + return [ + 'required sharing' => [ + ['transport_sharing' => TransportSharing::HANDLER_REQUIRE], + 'can only require sharing when Guzzle creates the default handler', + ], + 'connection cap' => [ + ['max_host_connections' => 2], + 'client options require Guzzle to create the default handler', + ], + ]; + } + + #[DataProvider('connectionCaps')] + public function testConnectionCapsRequireBoundingBroadcastConcurrencyInstead(string $option): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Bound coroutine concurrency or use rate limiting'); + + $this->app->make(BroadcastManager::class)->pusher($this->configuration('pusher', [$option => 2])); + } + + /** + * Provide connection limits that require an unsafe shared multi-handler. + */ + public static function connectionCaps(): array + { + return [['max_host_connections'], ['max_total_connections']]; + } + + /** + * Resolve the public Pusher client through the configured broadcast driver. + */ + protected function client(string $driver, array $options = []): Pusher + { + config(['broadcasting.connections.ownership' => $this->configuration($driver, $options)]); + + return $this->app->make(BroadcastManager::class)->connection('ownership')->getPusher(); + } + + /** + * Configure a local broadcasting endpoint and bounded request timeouts. + */ + protected function configuration(string $driver, array $options): array + { + return [ + 'driver' => $driver, + 'key' => 'key', + 'secret' => 'secret', + 'app_id' => 'app', + 'options' => [ + 'host' => '127.0.0.1', + 'port' => $this->server?->port ?? 80, + 'scheme' => 'http', + 'useTLS' => false, + 'timeout' => 3, + ], + 'client_options' => $options, + ]; + } + + /** + * Start a local Pusher-compatible endpoint with keep-alive support. + */ + protected function startServer(?Closure $handler = null): void + { + $handler ??= static function (ServerRequest $request, ServerResponse $response): void { + $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR); + usleep(10000); + $response->status(($payload['name'] ?? null) === 'fail' ? 503 : 200); + $response->header('Content-Type', 'application/json'); + $response->end('{"result":"accepted"}'); + }; + $this->server = new Server('127.0.0.1', 0, false, false); + $this->server->handle('/', function (ServerRequest $request, ServerResponse $response) use ($handler): void { + $this->connections[] = $request->server['remote_port']; + $handler($request, $response); + }); + $this->serverCoroutine = Coroutine::create(fn (): bool => $this->server->start()); + } +} diff --git a/tests/Cache/ClearCommandTest.php b/tests/Cache/ClearCommandTest.php index cdb5c029a..83a30f1e9 100644 --- a/tests/Cache/ClearCommandTest.php +++ b/tests/Cache/ClearCommandTest.php @@ -133,7 +133,7 @@ public function testClearWillFlushAopProxyDirectory(): void $this->cacheManager->expects('store')->with(null)->andReturn($this->cacheRepository); $this->cacheRepository->expects('flush')->andReturnTrue(); - $this->files->expects('deleteDirectory')->with(storage_path('framework/aop')); + $this->files->expects('deleteDirectory')->with($this->command->getHypervel()->bootstrapPath('cache/aop')); $this->runCommand($this->command); } diff --git a/tests/Di/Aop/AspectManagerTest.php b/tests/Di/Aop/AspectManagerTest.php index 5e79f1262..622299af8 100644 --- a/tests/Di/Aop/AspectManagerTest.php +++ b/tests/Di/Aop/AspectManagerTest.php @@ -5,36 +5,33 @@ namespace Hypervel\Tests\Di\Aop; use Hypervel\Di\Aop\AspectManager; +use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Tests\TestCase; class AspectManagerTest extends TestCase { - public function testHasReturnsFalseForUnsetEntry(): void - { - $this->assertFalse(AspectManager::has('Foo', 'bar')); - } - public function testSetAndGet(): void { - AspectManager::set('Foo', 'bar', ['Aspect1', 'Aspect2']); + $chain = static fn (ProceedingJoinPoint $point): mixed => $point->processOriginalMethod(); + AspectManager::set('Foo', 'bar', $chain); - $this->assertTrue(AspectManager::has('Foo', 'bar')); - $this->assertSame(['Aspect1', 'Aspect2'], AspectManager::get('Foo', 'bar')); + $this->assertSame($chain, AspectManager::get('Foo', 'bar')); } - public function testGetReturnsEmptyArrayForUnsetEntry(): void + public function testGetReturnsNullForUnsetEntry(): void { - $this->assertSame([], AspectManager::get('Foo', 'bar')); + $this->assertNull(AspectManager::get('Foo', 'bar')); } public function testFlushStateRemovesAllEntries(): void { - AspectManager::set('Foo', 'bar', ['Aspect1']); - AspectManager::set('Baz', 'qux', ['Aspect2']); + $chain = static fn (ProceedingJoinPoint $point): mixed => $point->processOriginalMethod(); + AspectManager::set('Foo', 'bar', $chain); + AspectManager::set('Baz', 'qux', $chain); AspectManager::flushState(); - $this->assertFalse(AspectManager::has('Foo', 'bar')); - $this->assertFalse(AspectManager::has('Baz', 'qux')); + $this->assertNull(AspectManager::get('Foo', 'bar')); + $this->assertNull(AspectManager::get('Baz', 'qux')); } } diff --git a/tests/Di/Aop/AspectTest.php b/tests/Di/Aop/AspectTest.php index 7ef72f624..cb2636d71 100644 --- a/tests/Di/Aop/AspectTest.php +++ b/tests/Di/Aop/AspectTest.php @@ -8,6 +8,7 @@ use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\RewriteCollection; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class AspectTest extends TestCase { @@ -68,6 +69,32 @@ public function testMatchClassPattern(): void $this->assertTrue($res->shouldRewrite('test1')); } + #[DataProvider('constructorAndClassRules')] + public function testClassRulesPreserveExplicitConstructorInterception(array $rules): void + { + foreach ($rules as $index => $rule) { + AspectCollector::setAround('Aspect' . $index, [$rule]); + } + + $collection = Aspect::parse('Demo'); + + $this->assertTrue($collection->shouldRewrite('__construct')); + $this->assertTrue($collection->shouldRewrite('test')); + } + + /** + * Provide constructor and class rules in either registration order. + */ + public static function constructorAndClassRules(): array + { + return [ + [['Demo', 'Demo::__construct']], + [['Demo::__construct', 'Demo']], + [['Demo*', 'Demo::__construct']], + [['Demo::__construct', 'Demo*']], + ]; + } + public function testMatchMethodPattern(): void { $aspect = 'App\Aspect\DebugAspect'; @@ -141,4 +168,25 @@ public function testIsMatch(): void $this->assertFalse(Aspect::isMatch('Foo/Bar/Baz', 'method', $rule)); $this->assertFalse(Aspect::isMatch('Foo/Bar', 'test', $rule)); } + + #[DataProvider('constructorMatchingRules')] + public function testConstructorMatchingRequiresAMethodRule(string $rule, bool $matches): void + { + $this->assertSame($matches, Aspect::isMatch('Demo', '__construct', $rule)); + } + + /** + * Provide class-only and explicit method rules for constructors. + */ + public static function constructorMatchingRules(): array + { + return [ + ['Demo', false], + ['Demo*', false], + ['Demo::__construct', true], + ['Demo*::__construct', true], + ['Demo::*', true], + ['Demo*::*', true], + ]; + } } diff --git a/tests/Di/Aop/PipelineTest.php b/tests/Di/Aop/PipelineTest.php deleted file mode 100644 index 915d0cf9e..000000000 --- a/tests/Di/Aop/PipelineTest.php +++ /dev/null @@ -1,49 +0,0 @@ -via('process')->through([ - NoProcessAspect::class, - ])->send($point)->then(function () { - }); - - $this->assertTrue($result); - } - - public function testPipelineDoesNotCreateCircularReference(): void - { - $container = Container::getInstance(); - $pipeline = new Pipeline($container); - - $point = new ProceedingJoinPoint(function () { - }, 'Foo', 'call', []); - - $pipeline->via('process')->through([ - NoProcessAspect::class, - ])->send($point)->then(function () { - }); - - $weakReference = WeakReference::create($pipeline); - unset($pipeline); - $this->assertNull($weakReference->get()); - } -} diff --git a/tests/Di/Aop/ProceedingJoinPointTest.php b/tests/Di/Aop/ProceedingJoinPointTest.php index 0b2f89736..73f805a2e 100644 --- a/tests/Di/Aop/ProceedingJoinPointTest.php +++ b/tests/Di/Aop/ProceedingJoinPointTest.php @@ -15,7 +15,8 @@ public function testProcessOriginalMethod(): void fn () => 1, ProceedingJoinPointTarget::class, 'incr', - ['keys' => []] + ['keys' => []], + null ); $this->assertSame(1, $obj->processOriginalMethod()); @@ -27,7 +28,8 @@ public function testGetArguments(): void fn () => 1, ProceedingJoinPointTarget::class, 'incr', - ['keys' => []] + ['keys' => []], + null ); $this->assertSame([], $obj->getArguments()); @@ -35,7 +37,8 @@ public function testGetArguments(): void fn () => 1, ProceedingJoinPointTarget::class, 'get4', - ['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => []], 'variadic' => 'variadic'] + ['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => []], 'variadic' => 'variadic'], + null ); $this->assertSame([1], $obj->getArguments()); @@ -43,7 +46,8 @@ public function testGetArguments(): void fn () => 1, ProceedingJoinPointTarget::class, 'get4', - ['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => [2, 'foo' => 3]], 'variadic' => 'variadic'] + ['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => [2, 'foo' => 3]], 'variadic' => 'variadic'], + null ); $this->assertSame([1, 2, 'foo' => 3], $obj->getArguments()); @@ -51,46 +55,13 @@ public function testGetArguments(): void fn () => 1, ProceedingJoinPointTarget::class, 'get4', - ['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => [2, 'foo' => 3]], 'variadic' => ''] + ['order' => ['id', 'variadic'], 'keys' => ['id' => 1, 'variadic' => [2, 'foo' => 3]], 'variadic' => ''], + null ); $this->assertSame([1, [2, 'foo' => 3]], $obj->getArguments()); } - - public function testGetInstance(): void - { - $object = new ProceedingJoinPointTarget('TestName'); - - $joinPoint = new ProceedingJoinPoint( - $object->getName(...), - ProceedingJoinPointTarget::class, - 'getName', - ['keys' => []] - ); - - $this->assertSame($object, $joinPoint->getInstance()); - } - - public function testGetInstanceReturnsNullForStaticClosure(): void - { - $joinPoint = new ProceedingJoinPoint( - static fn () => 'value', - ProceedingJoinPointTarget::class, - 'staticMethod', - ['keys' => []] - ); - - $this->assertNull($joinPoint->getInstance()); - } } class ProceedingJoinPointTarget { - public function __construct(public string $name) - { - } - - public function getName(): string - { - return $this->name; - } } diff --git a/tests/Di/Aop/ProxyCallVisitorTest.php b/tests/Di/Aop/ProxyCallVisitorTest.php index bdb4e8ad4..4f4315b3d 100644 --- a/tests/Di/Aop/ProxyCallVisitorTest.php +++ b/tests/Di/Aop/ProxyCallVisitorTest.php @@ -7,6 +7,7 @@ use Attribute; use Closure; use Error; +use Hypervel\Container\Container; use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\Ast; @@ -14,15 +15,67 @@ use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Di\Aop\ProxyCallVisitor; use Hypervel\Di\Aop\ProxyMarker; +use Hypervel\Di\Aop\ProxySource; use Hypervel\Di\Exceptions\InvalidDefinitionException; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionClass; use ReflectionMethod; use RuntimeException; use ValueError; class ProxyCallVisitorTest extends TestCase { + public function testConstructorInterceptionDoesNotOptClassAspectsIntoTheConstructor(): void + { + $className = $this->className(); + $source = $this->classSource($className, <<<'PHP' + public function __construct(public string $value) + { + } + + public function value(): string + { + return $this->value; + } +PHP); + AspectCollector::setAround(ConstructorRecordingAspect::class, [$className . '::__construct']); + $this->evaluate($this->generate($className, '/original/MixedRules.php', $source, ClassRecordingAspect::class)); + + $target = new $className('original'); + + $this->assertSame('original', $target->value()); + $this->assertSame(['__construct'], Container::getInstance()->make(ConstructorRecordingAspect::class)->methods); + $this->assertSame(['value'], Container::getInstance()->make(ClassRecordingAspect::class)->methods); + $this->assertSame([$target], Container::getInstance()->make(ConstructorRecordingAspect::class)->instances); + $this->assertSame([$target], Container::getInstance()->make(ClassRecordingAspect::class)->instances); + } + + public function testPassesTheInterceptedInstanceForEachObjectAndNullForStaticMethods(): void + { + $class = $this->proxyClass(<<<'PHP' + public function value(): string + { + return 'instance'; + } + + public static function staticValue(): string + { + return 'static'; + } +PHP, ClassRecordingAspect::class); + $first = new $class; + $second = new $class; + + $this->assertSame('instance', $first->value()); + $this->assertSame('instance', $second->value()); + $this->assertSame('static', $class::staticValue()); + $this->assertSame( + [$first, $second, null], + Container::getInstance()->make(ClassRecordingAspect::class)->instances + ); + } + public function testPreservesArgumentIntrospectionAcrossCallShapes(): void { $method = <<<'PHP' @@ -264,7 +317,7 @@ public function stop(): never public function testUsesCollisionFreeGeneratedNames(): void { $className = $this->className(); - $hash = substr(hash('sha256', $className . '::target'), 0, 12); + $hash = substr(hash('xxh128', $className . '::target'), 0, 12); $source = $this->classSource($className, <<assertSame('proxied-trait', $instance->aliasedValue()); $this->assertSame("{closure:{$traitName}::descriptor():{$closureLine}}", $instance->descriptor()); $this->assertContains(ProxyMarker::class, class_uses_recursive($instance)); + $this->assertSame( + ['/original/GeneratedTrait.php'], + (new ReflectionClass($traitName))->getAttributes(ProxySource::class)[0]->getArguments() + ); $this->assertFalse(method_exists($instance, '__proxyCall')); } @@ -796,6 +853,28 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed } } +class ClassRecordingAspect extends AbstractAspect +{ + public array $methods = []; + + public array $instances = []; + + /** + * Record the intercepted method before continuing. + */ + public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed + { + $this->methods[] = $proceedingJoinPoint->methodName; + $this->instances[] = $proceedingJoinPoint->getInstance(); + + return $proceedingJoinPoint->process(); + } +} + +class ConstructorRecordingAspect extends ClassRecordingAspect +{ +} + #[Attribute] class ProxyMethodAttribute { diff --git a/tests/Di/Aop/ProxyDispatcherTest.php b/tests/Di/Aop/ProxyDispatcherTest.php index 07dc8f224..50ac9b816 100644 --- a/tests/Di/Aop/ProxyDispatcherTest.php +++ b/tests/Di/Aop/ProxyDispatcherTest.php @@ -4,13 +4,20 @@ namespace Hypervel\Tests\Di\Aop; +use Closure; +use Hypervel\Container\Container; use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\AspectManager; use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Di\Aop\ProxyDispatcher; +use Hypervel\Tests\Di\Fixtures\Aspect\NoProcessAspect; use Hypervel\Tests\TestCase; +use RuntimeException; use ValueError; +use WeakReference; + +use function Hypervel\Coroutine\parallel; class ProxyDispatcherTest extends TestCase { @@ -22,14 +29,15 @@ public function testDispatchesTheOriginalMethodWithoutMatchingAspects(): void ProxyDispatcherTarget::class, 'combine', $this->arguments(['value' => 'first', 'rest' => ['second', 'named' => 'third']]), - $target->combine(...) + $target->combine(...), + $target ); $this->assertSame(['first', 'second', 'named' => 'third'], $result); - $this->assertSame([], AspectManager::get(ProxyDispatcherTarget::class, 'combine')); + $this->assertNotNull(AspectManager::get(ProxyDispatcherTarget::class, 'combine')); } - public function testPublishesAndReusesTheCompletePrioritizedAspectList(): void + public function testPublishesAndReusesTheCompletePrioritizedAspectChain(): void { AspectCollector::setAround(DispatcherIncrementAspect::class, [ ProxyDispatcherTarget::class . '::number', @@ -46,12 +54,10 @@ public function testPublishesAndReusesTheCompletePrioritizedAspectList(): void ProxyDispatcherTarget::class, 'number', $arguments, - $target->number(...) + $target->number(...), + $target )); - $this->assertSame( - [DispatcherIncrementAspect::class, DispatcherDoubleAspect::class], - AspectManager::get(ProxyDispatcherTarget::class, 'number') - ); + $chain = AspectManager::get(ProxyDispatcherTarget::class, 'number'); AspectCollector::flushState(); @@ -59,24 +65,178 @@ public function testPublishesAndReusesTheCompletePrioritizedAspectList(): void ProxyDispatcherTarget::class, 'number', $arguments, - $target->number(...) + $target->number(...), + $target )); + $this->assertSame($chain, AspectManager::get(ProxyDispatcherTarget::class, 'number')); } - public function testExposesTheBoundInstanceToAspects(): void + public function testAspectMayShortCircuitWithoutImplementingAroundInterface(): void { - AspectCollector::setAround(DispatcherInstanceAspect::class, [ - ProxyDispatcherTarget::class . '::name', - ]); + AspectCollector::setAround(NoProcessAspect::class, [ProxyDispatcherTarget::class . '::number']); - $target = new ProxyDispatcherTarget('bound'); + $this->assertTrue(ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + [], + static fn (): never => throw new RuntimeException('The original method must not run.'), + null + )); + } - $this->assertSame('bound', ProxyDispatcher::dispatch( + public function testCachedChainResolvesReboundAspectsFromTheCurrentContainer(): void + { + AspectCollector::setAround(DispatcherCallbackAspect::class, [ProxyDispatcherTarget::class . '::number']); + $target = new ProxyDispatcherTarget; + $dispatch = fn (): mixed => ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 2]), + $target->number(...), + $target + ); + + Container::getInstance()->instance(DispatcherCallbackAspect::class, new DispatcherCallbackAspect( + static fn (ProceedingJoinPoint $point): mixed => $point->process() + 1 + )); + $this->assertSame(3, $dispatch()); + + Container::getInstance()->instance(DispatcherCallbackAspect::class, new DispatcherCallbackAspect( + static fn (ProceedingJoinPoint $point): mixed => $point->process() + 2 + )); + $this->assertSame(4, $dispatch()); + + Container::setInstance(new Container); + Container::getInstance()->instance(DispatcherCallbackAspect::class, new DispatcherCallbackAspect( + static fn (ProceedingJoinPoint $point): mixed => $point->process() + 3 + )); + $this->assertSame(5, $dispatch()); + } + + public function testCachedChainKeepsConcurrentInvocationStateSeparate(): void + { + AspectCollector::setAround(DispatcherScopedAspect::class, [ProxyDispatcherTarget::class . '::name']); + Container::getInstance()->scoped(DispatcherScopedAspect::class); + + $dispatch = function (string $name): mixed { + $target = new ProxyDispatcherTarget($name); + + return ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'name', + $this->arguments([]), + $target->name(...), + $target + ); + }; + + $this->assertSame([ + ['first', 'first', 'first'], + ['second', 'second', 'second'], + ], parallel([ + fn (): mixed => $dispatch('first'), + fn (): mixed => $dispatch('second'), + ])); + } + + public function testNestedDispatchDoesNotReplaceTheOuterJoinPoint(): void + { + AspectCollector::setAround(DispatcherCallbackAspect::class, [ProxyDispatcherTarget::class . '::number']); + $target = new ProxyDispatcherTarget; + Container::getInstance()->instance(DispatcherCallbackAspect::class, new DispatcherCallbackAspect( + function (ProceedingJoinPoint $point) use ($target): int { + if ($point->arguments['keys']['value'] === 2) { + return $point->process(); + } + + $inner = ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 2]), + $target->number(...), + $target + ); + + return $inner + $point->process(); + } + )); + + $this->assertSame(5, ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 3]), + $target->number(...), + $target + )); + } + + public function testOriginalMethodBypassAndExceptionsPreserveTheCachedChain(): void + { + AspectCollector::setAround(DispatcherCallbackAspect::class, [ProxyDispatcherTarget::class . '::number'], 20); + AspectCollector::setAround(DispatcherIncrementAspect::class, [ProxyDispatcherTarget::class . '::number'], 10); + $exception = new RuntimeException('Original failure'); + Container::getInstance()->instance(DispatcherCallbackAspect::class, new DispatcherCallbackAspect( + static fn (ProceedingJoinPoint $point): mixed => $point->arguments['keys']['value'] === 2 + ? $point->processOriginalMethod() + : $point->process() + )); + + try { + ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 1]), + static fn (int $value): never => throw $exception, + null + ); + $this->fail('The original exception must propagate.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $target = new ProxyDispatcherTarget; + $this->assertSame(2, ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 2]), + $target->number(...), + $target + )); + $this->assertSame(4, ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 3]), + $target->number(...), + $target + )); + } + + public function testShortCircuitedInvocationIsNotRetainedByTheCachedChain(): void + { + AspectCollector::setAround(DispatcherCallbackAspect::class, [ProxyDispatcherTarget::class . '::name']); + $pointReference = null; + Container::getInstance()->instance(DispatcherCallbackAspect::class, new DispatcherCallbackAspect( + static function (ProceedingJoinPoint $point) use (&$pointReference): bool { + $pointReference = WeakReference::create($point); + + return true; + } + )); + $target = new ProxyDispatcherTarget; + $targetReference = WeakReference::create($target); + + $this->assertTrue(ProxyDispatcher::dispatch( ProxyDispatcherTarget::class, 'name', $this->arguments([]), - $target->name(...) + $target->name(...), + $target )); + unset($target); + + $this->assertNotNull($pointReference); + $this->assertNull($pointReference->get()); + $this->assertNull($targetReference->get()); } public function testReconstructsVisibleArguments(): void @@ -182,10 +342,36 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed } } -class DispatcherInstanceAspect extends AbstractAspect +class DispatcherCallbackAspect extends AbstractAspect { + /** + * Create an aspect using the supplied test callback. + */ + public function __construct(private Closure $callback) + { + } + + /** + * Invoke the test's aspect callback. + */ public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed { - return $proceedingJoinPoint->getInstance()?->value; + return ($this->callback)($proceedingJoinPoint); + } +} + +class DispatcherScopedAspect extends AbstractAspect +{ + private string $value; + + /** + * Read invocation state after another coroutine has entered the chain. + */ + public function process(ProceedingJoinPoint $proceedingJoinPoint): array + { + $this->value = $proceedingJoinPoint->getInstance()->value; + usleep(1000); + + return [$this->value, $proceedingJoinPoint->getInstance()->value, $proceedingJoinPoint->process()]; } } diff --git a/tests/Di/Bootstrap/GenerateProxiesTest.php b/tests/Di/Bootstrap/GenerateProxiesTest.php index fd2cea8c2..7dc5ab609 100644 --- a/tests/Di/Bootstrap/GenerateProxiesTest.php +++ b/tests/Di/Bootstrap/GenerateProxiesTest.php @@ -6,19 +6,26 @@ use Composer\Autoload\ClassLoader; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Di\Aop\AspectManager; use Hypervel\Di\Aop\AstVisitorRegistry; +use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Di\Aop\ProxyCallVisitor; use Hypervel\Di\Aop\VisitorMetadata; use Hypervel\Di\Bootstrap\GenerateProxies; +use Hypervel\Di\ClassMap\ClassMapManager; use Hypervel\Di\Exceptions\InvalidDefinitionException; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Application; use Hypervel\Support\Composer; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; use PhpParser\NodeVisitorAbstract; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; +use RuntimeException; use Throwable; class GenerateProxiesTest extends TestCase @@ -31,6 +38,9 @@ class GenerateProxiesTest extends TestCase private string $tempDirectory; + /** + * Prepare an isolated Composer loader and proxy directory. + */ protected function setUp(): void { parent::setUp(); @@ -44,13 +54,17 @@ protected function setUp(): void $this->loader = new ClassLoader; $this->loader->register(); Composer::setLoader($this->loader); + IsolatedProxyGenerator::flushState(); } + /** + * Unregister the fixture loaders and remove generated files. + */ protected function tearDown(): void { $this->loader->unregister(); Composer::setLoader($this->originalLoader); - GenerateProxies::flushState(); + IsolatedProxyGenerator::flushState(); $this->filesystem->deleteDirectory($this->tempDirectory); parent::tearDown(); @@ -149,14 +163,14 @@ public function testDoesNotRegisterProxyCallVisitorTwice(): void $this->assertSame(1, $count); } - public function testRegeneratesDeletedProxyFilesFromTheCapturedSourceMap(): void + public function testRegeneratesDeletedProxyFilesFromTheApplicationSourceMap(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); $this->bootstrapProxies($proxyDir); - $proxyFile = $this->loader->getClassMap()[$className]; + $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertFileExists($proxyFile); $this->filesystem->deleteDirectory($proxyDir); @@ -167,24 +181,138 @@ public function testRegeneratesDeletedProxyFilesFromTheCapturedSourceMap(): void }); } - public function testSkipsProxyPathsReturnedByFindFileAfterTheSourceMapIsFlushed(): void + public function testPublishesProxiesWithoutChangingTheApplicationClassMap(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + $this->loader->addClassMap(['Unrelated\ClassName' => '/unrelated/ClassName.php']); + $originalMap = $this->loader->getClassMap(); + AspectCollector::setAround('TestAspect', [$className . '::value']); + + $this->bootstrapProxies($proxyDir); + + $this->assertSame($originalMap, $this->loader->getClassMap()); + $this->assertSame([$className], array_keys(IsolatedProxyGenerator::getProxyMap())); + $this->assertFileExists(IsolatedProxyGenerator::getProxyMap()[$className]); + }); + } + + public function testReleasesSharingStorageKeepTheirOwnProxyFiles(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + $first = new Application($this->tempDirectory . '/release-first'); + $first->useStoragePath($this->tempDirectory . '/shared-storage'); + $second = new Application($this->tempDirectory . '/release-second'); + $second->useStoragePath($this->tempDirectory . '/shared-storage'); + AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); + + (new IsolatedProxyGenerator)->bootstrap($first); + $firstProxy = IsolatedProxyGenerator::getProxyMap()[$className]; + + $this->writeProxySource($overrideFile, $className, 'second-release'); + $this->loader->addClassMap([$className => $overrideFile]); + (new IsolatedProxyGenerator)->bootstrap($second); + + $this->assertNotSame($firstProxy, IsolatedProxyGenerator::getProxyMap()[$className]); + require $firstProxy; + + $this->assertSame('intercepted:original-source', (new $className)->value()); + }); + } + + public function testSelectsMixedExactAndWildcardRulesInOrderWithoutDuplicates(): void + { + $prefix = 'Hypervel\Tests\Di\Bootstrap\Fixtures\Mixed' . bin2hex(random_bytes(4)); + $classes = [$prefix . '\First', $prefix . '\Second', $prefix . '\Other']; + $sources = []; + foreach ($classes as $index => $className) { + $source = $this->tempDirectory . '/Mixed' . $index . '.php'; + $this->writeProxySource($source, $className, (string) $index); + $sources[$className] = $source; + } + $this->loader->addClassMap($sources); + AspectCollector::setAround('TestAspect', [ + $classes[1] . '::val*', + $prefix . '\F*::value', + $classes[1], + $prefix . '\Missing*', + ]); + + $this->bootstrapProxies($this->tempDirectory . '/aop'); + + $this->assertSame( + [$classes[1], $classes[0]], + array_keys(IsolatedProxyGenerator::getProxyMap()) + ); + $this->assertSame($sources[$classes[2]], $this->loader->getClassMap()[$classes[2]]); + $firstProxy = $this->filesystem->get(IsolatedProxyGenerator::getProxyMap()[$classes[0]]); + $secondProxy = $this->filesystem->get(IsolatedProxyGenerator::getProxyMap()[$classes[1]]); + $this->assertStringContainsString('ProxyDispatcher::dispatch', $firstProxy); + $this->assertStringContainsString('ProxyDispatcher::dispatch', $secondProxy); + } + + public function testRejectsTargetsLoadedBeforeProxyGeneration(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + $this->assertTrue(class_exists($className)); + AspectCollector::setAround('TestAspect', [$className . '::value']); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage($className); + $this->expectExceptionMessage('register()'); + $this->bootstrapProxies($proxyDir); + }); + } + + public function testLoadedProxiesAllowRepeatedBootsSubsetRulesAndUnrelatedAspects(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + AspectCollector::setAround('TestAspect', [$className]); + $this->bootstrapProxies($proxyDir); + $this->assertTrue(class_exists($className)); + $this->bootstrapProxies($proxyDir); + + AspectCollector::forgetAspect('TestAspect'); + AspectCollector::setAround('TestAspect', [$className . '::val*']); + AspectCollector::setAround('UnrelatedAspect', ['Missing\UnrelatedTarget']); + $this->bootstrapProxies($proxyDir); + + $this->assertNotSame($sourceFile, (new ReflectionMethod($className, 'value'))->getFileName()); + }); + } + + public function testLoadedProxyRejectsAdditionalMethodRequirements(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + AspectCollector::setAround('TestAspect', [$className . '::value']); + $this->bootstrapProxies($proxyDir); + $this->assertTrue(class_exists($className)); + + AspectCollector::setAround('TestAspect', [$className]); + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage($className . '::other'); + $this->bootstrapProxies($proxyDir); + }); + } + + public function testRegeneratesPsr4ProxiesWithoutChangingSourceResolution(): void { $this->withPsr4ProxyFixture(function (string $className, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); $this->bootstrapProxies($proxyDir); - $proxyFile = $this->loader->getClassMap()[$className]; + $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertFileExists($proxyFile); - GenerateProxies::flushState(); - - $this->assertArrayNotHasKey($className, $this->buildClassMap()); + $source = $this->loader->findFile($className); + $this->assertSame($source, $this->buildClassMap()[$className]); + $this->assertNotSame($source, $proxyFile); $this->filesystem->deleteDirectory($proxyDir); $this->bootstrapProxies($proxyDir); - $this->assertFileDoesNotExist($proxyFile); + $this->assertFileExists($proxyFile); + $this->assertStringContainsString('psr4-source', $this->filesystem->get($proxyFile)); }); } @@ -200,7 +328,7 @@ public function testRegeneratesProxyWhenSourceChangesWithoutAnMtimeChange(): voi touch($sourceFile, $sourceMtime); $this->bootstrapProxies($proxyDir); - $proxyFile = $this->loader->getClassMap()[$className]; + $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertStringContainsString('same-mtime-source', $this->filesystem->get($proxyFile)); }); } @@ -217,18 +345,108 @@ public function testRegeneratesProxyWhenSourcePathChanges(): void $this->bootstrapProxies($proxyDir); - $proxyFile = $this->loader->getClassMap()[$className]; + $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertStringContainsString('override-source', $this->filesystem->get($proxyFile)); }); } + public function testReplacementProxyDoesNotOverwriteTheOrdinaryProxy(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); + + $this->bootstrapProxies($proxyDir); + $ordinaryProxy = IsolatedProxyGenerator::getProxyMap()[$className]; + $this->writeProxySource($overrideFile, $className, 'override-source'); + ClassMapManager::add([$className => $overrideFile]); + + $this->bootstrapProxies($proxyDir); + + $this->assertSame('intercepted:override-source', (new $className)->value()); + $this->assertStringContainsString('original-source', $this->filesystem->get($ordinaryProxy)); + $this->assertSame([$className => $overrideFile], ClassMapManager::getEntries()); + $this->assertSame( + rawurlencode($className) . '.replacement.proxy.php', + basename((new ReflectionMethod($className, 'value'))->getFileName()) + ); + }); + } + + #[DataProvider('ordinaryProxyCases')] + public function testUnloadedReplacementProxyDoesNotSurviveReset(bool $ordinaryProxyExists): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir) use ($ordinaryProxyExists): void { + AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); + + if ($ordinaryProxyExists) { + $this->bootstrapProxies($proxyDir); + } + + $this->writeProxySource($overrideFile, $className, 'override-source'); + ClassMapManager::add([$className => $overrideFile]); + $this->bootstrapProxies($proxyDir); + + ClassMapManager::flushState(); + AspectCollector::flushState(); + AspectManager::flushState(); + + $this->assertSame('original-source', (new $className)->value()); + }); + } + + /** + * Provide reset cases with and without a previously generated ordinary proxy. + */ + public static function ordinaryProxyCases(): array + { + return [[false], [true]]; + } + + public function testLoadedOverrideProxyAcceptsOnlyItsOriginalSourceAfterReset(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + $this->writeProxySource($overrideFile, $className, 'override-source'); + ClassMapManager::add([$className => $overrideFile]); + AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); + $this->bootstrapProxies($proxyDir); + $this->assertSame('intercepted:override-source', (new $className)->value()); + + ClassMapManager::flushState(); + ClassMapManager::add([$className => $overrideFile]); + $this->bootstrapProxies($proxyDir); + + $this->assertSame('intercepted:override-source', (new $className)->value()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageIsOrContains('Cannot override class map'); + + ClassMapManager::add([$className => $sourceFile]); + }); + } + + public function testProxyKeepsPriorityOverAnOverrideRegisteredOnTheNextBoot(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { + $this->writeProxySource($overrideFile, $className, 'override-source'); + ClassMapManager::add([$className => $overrideFile]); + AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); + $this->bootstrapProxies($proxyDir); + + ClassMapManager::flushState(); + ClassMapManager::add([$className => $overrideFile]); + $this->bootstrapProxies($proxyDir); + + $this->assertSame('intercepted:override-source', (new $className)->value()); + }); + } + public function testRegeneratesProxyWhenAspectRulesChange(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('FirstAspect', [$className . '::value']); $this->bootstrapProxies($proxyDir); - $proxyFile = $this->loader->getClassMap()[$className]; + $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $first = $this->filesystem->get($proxyFile); AspectCollector::setAround('SecondAspect', [$className . '::value']); @@ -246,7 +464,7 @@ public function testRegeneratesProxyWhenVisitorOrderChanges(): void AstVisitorRegistry::insert(FingerprintVisitorTwo::class, 10); $this->bootstrapProxies($proxyDir); - $proxyFile = $this->loader->getClassMap()[$className]; + $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $first = $this->filesystem->get($proxyFile); AstVisitorRegistry::flushState(); @@ -276,8 +494,8 @@ public function testUsesCollisionFreeEncodedProxyFilenames(): void $this->bootstrapProxies($proxyDir); - $firstProxy = $this->loader->getClassMap()[$firstClass]; - $secondProxy = $this->loader->getClassMap()[$secondClass]; + $firstProxy = IsolatedProxyGenerator::getProxyMap()[$firstClass]; + $secondProxy = IsolatedProxyGenerator::getProxyMap()[$secondClass]; $this->assertNotSame($firstProxy, $secondProxy); $this->assertSame(rawurlencode($firstClass) . '.proxy.php', basename($firstProxy)); $this->assertSame(rawurlencode($secondClass) . '.proxy.php', basename($secondProxy)); @@ -291,7 +509,7 @@ public function testReusesAProxyFromItsFingerprintHeaderWithoutParsingItsBody(): AspectCollector::setAround('TestAspect', [$className . '::value']); $this->bootstrapProxies($proxyDir); - $proxyFile = $this->loader->getClassMap()[$className]; + $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $lines = file($proxyFile); $this->assertIsArray($lines); $cachedBody = $lines[0] . $lines[1] . "cached-body-is-not-parsed\n"; @@ -334,6 +552,7 @@ class ExtraClass {} } $this->assertInstanceOf(InvalidDefinitionException::class, $exception); + $this->assertSame([], IsolatedProxyGenerator::getProxyMap()); $this->assertSame($validSource, $this->loader->getClassMap()[$validClass]); $this->assertSame($invalidSource, $this->loader->getClassMap()[$invalidClass]); $this->assertFileDoesNotExist( @@ -348,7 +567,7 @@ class ExtraClass {} */ private function buildClassMap(): array { - return (new ReflectionMethod(GenerateProxies::class, 'buildClassMap'))->invoke(new GenerateProxies); + return (new ReflectionMethod(GenerateProxies::class, 'buildClassMap'))->invoke(new IsolatedProxyGenerator); } /** @@ -393,11 +612,11 @@ private function withPsr4ProxyFixture(callable $callback): void private function bootstrapProxies(string $proxyDir): void { $app = m::mock(ApplicationContract::class); - $app->shouldReceive('storagePath') - ->with('framework/aop/') + $app->shouldReceive('bootstrapPath') + ->with('cache/aop') ->andReturn($proxyDir); - (new GenerateProxies)->bootstrap($app); + (new IsolatedProxyGenerator)->bootstrap($app); } /** @@ -422,11 +641,42 @@ public function value(): string { return '{$marker}'; } + + public function other(): string + { + return 'other'; + } } PHP); } } +class OverrideSourceAspect extends AbstractAspect +{ + /** + * Distinguish intercepted calls from calls through the plain replacement loader. + */ + public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed + { + return 'intercepted:' . $proceedingJoinPoint->process(); + } +} + +class IsolatedProxyGenerator extends GenerateProxies +{ + protected static ?ClassLoader $proxyLoader = null; + + /** + * Get the proxy entries published by this fixture's loader. + * + * @return array + */ + public static function getProxyMap(): array + { + return static::$proxyLoader?->getClassMap() ?? []; + } +} + class FingerprintVisitorOne extends NodeVisitorAbstract { public function __construct(VisitorMetadata $visitorMetadata) diff --git a/tests/Di/ClassMap/ClassMapManagerTest.php b/tests/Di/ClassMap/ClassMapManagerTest.php index e14e8c4e6..bf7f5a24d 100644 --- a/tests/Di/ClassMap/ClassMapManagerTest.php +++ b/tests/Di/ClassMap/ClassMapManagerTest.php @@ -7,7 +7,9 @@ use Composer\Autoload\ClassLoader; use Countable; use Hypervel\Di\ClassMap\ClassMapManager; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Composer; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use RuntimeException; @@ -17,6 +19,13 @@ class ClassMapManagerTest extends TestCase private ClassLoader $isolatedLoader; + private Filesystem $files; + + private string $directory; + + /** + * Prepare isolated source files and an application loader. + */ protected function setUp(): void { parent::setUp(); @@ -25,12 +34,20 @@ protected function setUp(): void $this->isolatedLoader = new ClassLoader; $this->isolatedLoader->register(); Composer::setLoader($this->isolatedLoader); + $this->files = new Filesystem; + $this->directory = ParallelTesting::tempDir('ClassMapManagerTest'); + $this->files->deleteDirectory($this->directory); + $this->files->ensureDirectoryExists($this->directory); } + /** + * Restore the application loader and remove fixture sources. + */ protected function tearDown(): void { $this->isolatedLoader->unregister(); Composer::setLoader($this->originalLoader); + $this->files->deleteDirectory($this->directory); parent::tearDown(); } @@ -42,22 +59,33 @@ public function testHasEntriesReturnsFalseWhenEmpty(): void public function testAddRegistersEntriesAndAppliesToAutoloader(): void { - $fakePath = '/tmp/fake_replacement.php'; + $class = __NAMESPACE__ . '\Replacement' . bin2hex(random_bytes(4)); + $path = $this->directory . '/Replacement.php'; + $this->writeSource($path, $class, 'replacement'); + $applicationMap = $this->isolatedLoader->getClassMap(); - // Use a class name that definitely doesn't exist - ClassMapManager::add([ - 'Hypervel\Tests\Di\ClassMap\NonExistentClassForTesting' => $fakePath, - ]); + ClassMapManager::add([$class => $path]); $this->assertTrue(ClassMapManager::hasEntries()); - $this->assertSame( - ['Hypervel\Tests\Di\ClassMap\NonExistentClassForTesting' => $fakePath], - ClassMapManager::getEntries() - ); - - // Verify it was added to Composer's class map - $composerMap = Composer::getLoader()->getClassMap(); - $this->assertSame($fakePath, $composerMap['Hypervel\Tests\Di\ClassMap\NonExistentClassForTesting']); + $this->assertSame([$class => $path], ClassMapManager::getEntries()); + $this->assertSame($applicationMap, $this->isolatedLoader->getClassMap()); + $this->assertSame('replacement', (new $class)->value()); + } + + public function testLoadedReplacementCanBeRegisteredAgainAfterReset(): void + { + $class = __NAMESPACE__ . '\Repeated' . bin2hex(random_bytes(4)); + $path = $this->directory . '/Repeated.php'; + $this->writeSource($path, $class, 'repeated'); + ClassMapManager::add([$class => $path]); + $this->assertSame('repeated', (new $class)->value()); + + ClassMapManager::flushState(); + $equivalentPath = $this->directory . '/./Repeated.php'; + ClassMapManager::add([$class => $equivalentPath]); + + $this->assertSame([$class => $equivalentPath], ClassMapManager::getEntries()); + $this->assertSame('repeated', (new $class)->value()); } public function testAddThrowsWhenClassAlreadyLoaded(): void @@ -108,14 +136,43 @@ public function testAddMergesMultipleCalls(): void public function testFlushStateRemovesAllEntries(): void { - ClassMapManager::add([ - 'Fake\ClassA' => '/tmp/a.php', - ]); + $class = __NAMESPACE__ . '\Unloaded' . bin2hex(random_bytes(4)); + $original = $this->directory . '/Original.php'; + $replacement = $this->directory . '/Replacement.php'; + $this->writeSource($original, $class, 'original'); + $this->writeSource($replacement, $class, 'replacement'); + $this->isolatedLoader->addClassMap([$class => $original]); + ClassMapManager::add([$class => $replacement]); ClassMapManager::flushState(); $this->assertFalse(ClassMapManager::hasEntries()); $this->assertSame([], ClassMapManager::getEntries()); + $this->assertSame('original', (new $class)->value()); + } + + /** + * Write a class source that identifies the implementation being loaded. + */ + private function writeSource(string $path, string $class, string $value): void + { + $shortName = substr($class, strrpos($class, '\\') + 1); + $namespace = __NAMESPACE__; + $this->files->put($path, <<register(); - Composer::setLoader($isolatedLoader); - - try { - $provider = new ClassMapServiceProvider($this->createMockApp()); - $provider->register(); - - $this->assertTrue(ClassMapManager::hasEntries()); - $this->assertSame( - ['Fake\OriginalClass' => '/tmp/replacement.php'], - ClassMapManager::getEntries() - ); - } finally { - $isolatedLoader->unregister(); - Composer::setLoader($originalLoader); - } + $provider = new ClassMapServiceProvider($this->createMockApp()); + $provider->register(); + + $this->assertTrue(ClassMapManager::hasEntries()); + $this->assertSame( + ['Fake\OriginalClass' => '/tmp/replacement.php'], + ClassMapManager::getEntries() + ); } + /** + * Create the application contract used by the fixture providers. + */ protected function createMockApp(): ApplicationContract { return m::mock(ApplicationContract::class); @@ -76,6 +67,9 @@ protected function createMockApp(): ApplicationContract class TestServiceProvider extends ServiceProvider { + /** + * Register the fixture aspect. + */ public function register(): void { $this->aspects([TestAspect::class]); @@ -84,6 +78,9 @@ public function register(): void class NoPriorityServiceProvider extends ServiceProvider { + /** + * Register an aspect using the default priority. + */ public function register(): void { $this->aspects([NoPriorityAspect::class]); @@ -92,6 +89,9 @@ public function register(): void class MultiAspectServiceProvider extends ServiceProvider { + /** + * Register multiple fixture aspects. + */ public function register(): void { $this->aspects(TestAspect::class, NoPriorityAspect::class); @@ -100,6 +100,9 @@ public function register(): void class ClassMapServiceProvider extends ServiceProvider { + /** + * Register the fixture replacement. + */ public function register(): void { $this->classMap([ diff --git a/tests/Engine/CoroutineTest.php b/tests/Engine/CoroutineTest.php index fb85fe86d..b62953da7 100644 --- a/tests/Engine/CoroutineTest.php +++ b/tests/Engine/CoroutineTest.php @@ -253,6 +253,8 @@ public function testCoroutineList(): void public function testCoroutineListCount(): void { + $initialCount = iterator_count(Coroutine::list()); + Coroutine::create(function () { usleep(100000); }); @@ -262,6 +264,6 @@ public function testCoroutineListCount(): void Coroutine::create(function () { usleep(100000); }); - $this->assertEquals(4, iterator_count(Coroutine::list())); + $this->assertSame($initialCount + 3, iterator_count(Coroutine::list())); } } diff --git a/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php b/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php index 5352a6b94..3f7c51268 100644 --- a/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php +++ b/tests/Foundation/Bootstrap/LoadEnvironmentVariablesTest.php @@ -15,6 +15,9 @@ class LoadEnvironmentVariablesTest extends TestCase { + // PHPUnit's output capture runs outside the test coroutine. + protected bool $runTestsInCoroutine = false; + private string|false $originalAppEnvPutenv; private mixed $originalAppEnvServer; diff --git a/tests/Foundation/Testing/ApplicationBootstrapTest.php b/tests/Foundation/Testing/ApplicationBootstrapTest.php index 60b0113c4..613d37ad9 100644 --- a/tests/Foundation/Testing/ApplicationBootstrapTest.php +++ b/tests/Foundation/Testing/ApplicationBootstrapTest.php @@ -58,7 +58,6 @@ protected function setUp(): void $this->filesystem->ensureDirectoryExists($this->appBasePath . '/bootstrap/cache'); $this->filesystem->ensureDirectoryExists($this->appBasePath . '/config'); $this->filesystem->ensureDirectoryExists($this->appBasePath . '/routes'); - $this->filesystem->ensureDirectoryExists($this->appBasePath . '/storage/framework/aop'); $this->filesystem->put($this->appBasePath . '/bootstrap/app.php', <<<'PHP' assertSame([1, 'default'], $result); } + public function testCallWithAspectsExposesNoInstanceForStaticMethods(): void + { + AspectCollector::setAround(UsesInstanceNameAspect::class, [InteractsWithAopTarget::class . '::staticName']); + + $this->assertSame('static', $this->callWithAspects(new InteractsWithAopTarget, 'staticName')); + } + public function testCallWithAspectsHandlesVariadicParameters(): void { AspectCollector::setAround(CaptureArgumentsAspect::class, [InteractsWithAopTarget::class . '::withVariadic']); @@ -154,6 +161,14 @@ public function instanceName(): string return 'unknown'; } + /** + * Return a value without an intercepted object instance. + */ + public static function staticName(): string + { + return 'static'; + } + public function withDefaults(int $count = 1, string $label = 'default'): array { return [$count, $label]; @@ -213,7 +228,7 @@ class UsesInstanceNameAspect extends AbstractAspect { public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed { - return $proceedingJoinPoint->getInstance()->name; + return $proceedingJoinPoint->getInstance()?->name ?? $proceedingJoinPoint->process(); } } diff --git a/tests/Foundation/Testing/WithCachedStateTest.php b/tests/Foundation/Testing/WithCachedStateTest.php index 3e91d464d..676459765 100644 --- a/tests/Foundation/Testing/WithCachedStateTest.php +++ b/tests/Foundation/Testing/WithCachedStateTest.php @@ -46,7 +46,6 @@ protected function setUp(): void $this->filesystem->deleteDirectory($this->appBasePath); $this->filesystem->ensureDirectoryExists($this->appBasePath . '/bootstrap/cache'); $this->filesystem->ensureDirectoryExists($this->appBasePath . '/config'); - $this->filesystem->ensureDirectoryExists($this->appBasePath . '/storage/framework/aop'); $this->filesystem->put($this->appBasePath . '/bootstrap/app.php', <<<'PHP' directory = ParallelTesting::tempDir('GuzzleBootstrapTest'); + (new Filesystem)->deleteDirectory($this->directory); + } + + /** + * Remove generated subprocess proxies. + */ + protected function tearDown(): void + { + try { + (new Filesystem)->deleteDirectory($this->directory); + } finally { + parent::tearDown(); + } + } + + public function testColdAndCachedBootActivateOwnershipWithoutObservability(): void + { + for ($boot = 0; $boot < 2; ++$boot) { + $process = $this->runFixture('normal'); + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $parserState = $boot === 0 ? 'loaded' : 'unloaded'; + $this->assertSame("parser:{$parserState}\nprinter:{$parserState}\ncold-boot-enforced\nowner\n", $process->getOutput()); + } + } + + public function testLoadingAnOriginalTargetBeforeGenerationFailsWithGuidance(): void + { + $process = $this->runFixture('early'); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString('GuzzleHttp\Promise\Promise', $process->getErrorOutput()); + $this->assertStringContainsString('Bind a factory in register()', $process->getErrorOutput()); + } + + public function testProductionShutdownStillDrainsOutsideCallbacks(): void + { + $process = $this->runFixture('shutdown'); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertSame("outside-shutdown\n", $process->getOutput()); + } + + public function testOutsideTransfersSurviveAnotherCoroutinesGarbageCollection(): void + { + $process = $this->runFixture('outside-transfer'); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertSame("outside-transfer-retained-and-released\n", $process->getOutput()); + } + + /** + * Run production registration in a clean process without the PHPUnit extension. + */ + protected function runFixture(string $mode): Process + { + $process = new Process([ + PHP_BINARY, + '-d', 'display_errors=stderr', + '-d', 'log_errors=0', + __DIR__ . '/Fixtures/bootstrap.php', + dirname(__DIR__, 4) . '/vendor/autoload.php', + $mode, + $this->directory, + ], timeout: 15); + $process->run(); + + return $process; + } +} diff --git a/tests/Http/Client/Guzzle/CoroutineIsolationTest.php b/tests/Http/Client/Guzzle/CoroutineIsolationTest.php new file mode 100644 index 000000000..08b5569b2 --- /dev/null +++ b/tests/Http/Client/Guzzle/CoroutineIsolationTest.php @@ -0,0 +1,81 @@ +make('config')->set("database.connections.{$tenant}", [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'pool' => ['testing_enabled' => true], + ]); + } + } + + public function testYieldingCallbacksResolveTheirOwnRequestAndDatabaseTransaction(): void + { + $callbacks = $observed = $expected = []; + + foreach (['first', 'second'] as $tenant) { + $callbacks[] = static function () use ($tenant, &$observed, &$expected): void { + RequestContext::set(Request::create('/' . $tenant)); + DB::setDefaultConnection($tenant); + $connection = DB::connection(); + $connection->statement('create table messages (tenant varchar(255))'); + $connection->beginTransaction(); + $connection->table('messages')->insert(['tenant' => $tenant]); + $expected[$tenant] = [Coroutine::id(), '/' . $tenant, $connection, 1, $tenant]; + + try { + $promise = Create::promiseFor(null); + foreach ([true, false] as $yield) { + $promise->then(static function () use ($tenant, $yield, &$observed): void { + if ($yield) { + usleep(1000); + } + + $resolved = DB::connection(); + $observed[$tenant][] = [ + $yield, + Coroutine::id(), + RequestContext::get()->getPathInfo(), + $resolved, + $resolved->transactionLevel(), + $resolved->table('messages')->value('tenant'), + ]; + }); + } + Utils::queue()->run(); + } finally { + $connection->rollBack(); + } + }; + } + + parallel($callbacks); + + foreach ($expected as $tenant => $snapshot) { + $this->assertSame([[true, ...$snapshot], [false, ...$snapshot]], $observed[$tenant]); + $this->assertSame(0, DB::connection($tenant)->table('messages')->count()); + } + } +} diff --git a/tests/Http/Client/Guzzle/CoroutineTaskQueueNonCoroutineTest.php b/tests/Http/Client/Guzzle/CoroutineTaskQueueNonCoroutineTest.php new file mode 100644 index 000000000..8217f9f72 --- /dev/null +++ b/tests/Http/Client/Guzzle/CoroutineTaskQueueNonCoroutineTest.php @@ -0,0 +1,70 @@ +add(static function () use (&$ran): void { + $ran[] = 'outside'; + }); + Utils::queue($outside); + + try { + CoroutineTaskQueue::install(); + $installed = Utils::queue(); + CoroutineTaskQueue::install(); + $this->assertSame($installed, Utils::queue()); + + run(static function () use ($installed, &$ran): void { + $installed->add(static function () use (&$ran): void { + $ran[] = 'inside'; + }); + $installed->run(); + }); + + $this->assertSame(['inside'], $ran); + $installed->run(); + $this->assertSame(['inside', 'outside'], $ran); + $this->assertTrue($outside->isEmpty()); + } finally { + Utils::queue($previous); + } + } + + public function testTestResetReleasesAbandonedTasksWithoutRunningThem(): void + { + $queue = new CoroutineTaskQueue(new TaskQueue(false)); + $retained = new stdClass; + $reference = WeakReference::create($retained); + $observed = null; + $queue->add(static function () use ($retained, &$observed): void { + $observed = $retained; + }); + unset($retained); + + $this->assertNotNull($reference->get()); + $queue->resetOutside(); + $queue->run(); + + $this->assertNull($observed); + $this->assertNull($reference->get()); + } +} diff --git a/tests/Http/Client/Guzzle/CoroutineTaskQueueTest.php b/tests/Http/Client/Guzzle/CoroutineTaskQueueTest.php new file mode 100644 index 000000000..04c169a7b --- /dev/null +++ b/tests/Http/Client/Guzzle/CoroutineTaskQueueTest.php @@ -0,0 +1,103 @@ +add(static function () use ($name, &$observed): void { + usleep(1000); + $observed[$name][] = [1, Coroutine::id()]; + }); + $queue->add(static function () use ($name, &$observed): void { + $observed[$name][] = [2, Coroutine::id()]; + }); + $queue->run(); + }; + } + + parallel($callbacks); + + foreach ($owners as $name => $owner) { + $this->assertSame([[1, $owner], [2, $owner]], $observed[$name]); + } + } + + #[DataProvider('copyModes')] + public function testChildrenDoNotInheritPendingTasks(string $mode): void + { + $queue = new CoroutineTaskQueue(new TaskQueue(false)); + $parentRan = false; + $queue->add(static function () use (&$parentRan): void { + $parentRan = true; + }); + $done = new Channel(1); + $child = static function () use ($queue, $done): void { + $empty = $queue->isEmpty(); + $queue->run(); + $done->push($empty); + }; + + match ($mode) { + 'fork' => Coroutine::fork($child), + 'forkOwned' => Coroutine::forkOwned($child, static function (callable $operation): void { + $operation(); + }), + 'parallel' => parallel([$child], copyContext: true), + }; + + $this->assertTrue($done->pop(1)); + $this->assertFalse($parentRan); + $queue->run(); + $this->assertTrue($parentRan); + } + + /** + * Provide supported child-creation paths that copy coroutine context. + */ + public static function copyModes(): array + { + return [['fork'], ['forkOwned'], ['parallel']]; + } + + public function testCoroutineExitRunsAndReleasesCallbacksInTheirOwner(): void + { + $queue = new CoroutineTaskQueue(new TaskQueue(false)); + $observed = null; + [[$reference, $expected]] = parallel([ + static function () use ($queue, &$observed): array { + $retained = new stdClass; + $queue->add(static function () use ($retained, &$observed): void { + $observed = [spl_object_id($retained), Coroutine::id()]; + }); + + return [WeakReference::create($retained), [spl_object_id($retained), Coroutine::id()]]; + }, + ]); + + $queue->run(); + + $this->assertSame($expected, $observed); + $this->assertNull($reference->get()); + } +} diff --git a/tests/Http/Client/Guzzle/Fixtures/bootstrap.php b/tests/Http/Client/Guzzle/Fixtures/bootstrap.php new file mode 100644 index 000000000..287494524 --- /dev/null +++ b/tests/Http/Client/Guzzle/Fixtures/bootstrap.php @@ -0,0 +1,95 @@ +register(); +(new GenerateProxies)->generate($directory); + +if ($mode === 'normal') { + echo 'parser:' . (class_exists(ParserFactory::class, false) ? 'loaded' : 'unloaded') . "\n"; + echo 'printer:' . (class_exists(Standard::class, false) ? 'loaded' : 'unloaded') . "\n"; +} + +if ($mode === 'shutdown') { + Utils::queue()->add(static function (): void { + echo "outside-shutdown\n"; + }); + + exit; +} + +if ($mode === 'outside-transfer') { + $handler = new CurlMultiHandler; + $promise = $handler(new Request('GET', 'http://127.0.0.1:1/'), ['timeout' => 1]); + $handlerReference = WeakReference::create($handler); + $promiseReference = WeakReference::create($promise); + unset($handler, $promise); + + Coroutine\run(static function () use ($handlerReference, $promiseReference): void { + gc_collect_cycles(); + if ($handlerReference->get() === null || $promiseReference->get() === null) { + throw new RuntimeException('An outside transfer was collected in another coroutine.'); + } + }); + + CoroutineState::current()?->cancel(); + gc_collect_cycles(); + if ($handlerReference->get() !== null || $promiseReference->get() !== null) { + throw new RuntimeException('Canceled outside transfers were retained.'); + } + + echo "outside-transfer-retained-and-released\n"; + exit; +} + +$queue = Utils::queue(); +$provider->register(); +(new GenerateProxies)->generate($directory); +if (Utils::queue() !== $queue) { + throw new RuntimeException('Repeated boot replaced the task queue.'); +} + +foreach (['__construct', 'then', 'wait', 'cancel', 'resolve', 'reject'] as $method) { + if ((new ReflectionMethod(Promise::class, $method))->getAttributes(ProxyMethod::class) === []) { + throw new RuntimeException("Missing interception: {$method}"); + } +} + +$promise = new Promise; +Coroutine\run(static function () use ($promise): void { + try { + $promise->resolve('foreign'); + } catch (CoroutineOwnershipException) { + echo "cold-boot-enforced\n"; + return; + } + + throw new RuntimeException('Cold boot did not activate ownership.'); +}); +$promise->resolve('owner'); +echo $promise->wait() . "\n"; diff --git a/tests/Http/Client/Guzzle/Fixtures/run-guzzle-promise-tests.php b/tests/Http/Client/Guzzle/Fixtures/run-guzzle-promise-tests.php new file mode 100644 index 000000000..927d34171 --- /dev/null +++ b/tests/Http/Client/Guzzle/Fixtures/run-guzzle-promise-tests.php @@ -0,0 +1,64 @@ +addPsr4('GuzzleHttp\Promise\Tests\\', $upstream . '/tests'); +$arguments = [$script, '--no-configuration', '--colors=never', '--do-not-cache-result', '--disallow-test-output']; + +if ($mode !== 'stock') { + // This one assertion requires Guzzle's default concrete queue, before a custom queue is installed. + $arguments[] = '--filter'; + $arguments[] = '/^(?!.*::testReturnsTrampoline$)/'; +} + +$arguments[] = $upstream . '/tests'; + +$run = static function () use ($mode, $arguments): int { + if ($mode !== 'stock') { + (new HttpServiceProvider(new Application))->register(); + $directory = sys_get_temp_dir() . '/hypervel-upstream-promises-' . getmypid(); + register_shutdown_function(static function () use ($directory): void { + (new Filesystem)->deleteDirectory($directory); + }); + (new GenerateProxies)->generate($directory); + + if (! Utils::queue() instanceof CoroutineTaskQueue) { + throw new RuntimeException('The production queue integration was not installed.'); + } + foreach (['__construct', 'then', 'wait', 'cancel', 'resolve', 'reject'] as $method) { + if ((new ReflectionMethod(Promise::class, $method))->getAttributes(ProxyMethod::class) === []) { + throw new RuntimeException("Promise::{$method} was not intercepted."); + } + } + } + + return (new Command)->run($arguments, false); +}; + +$exitCode = 1; +if ($mode === 'coroutine') { + Swoole\Coroutine\run(static function () use ($run, &$exitCode): void { + $exitCode = $run(); + }); +} else { + $exitCode = $run(); +} + +exit($exitCode); diff --git a/tests/Http/Client/Guzzle/PromiseOwnershipTest.php b/tests/Http/Client/Guzzle/PromiseOwnershipTest.php new file mode 100644 index 000000000..52c3f32b3 --- /dev/null +++ b/tests/Http/Client/Guzzle/PromiseOwnershipTest.php @@ -0,0 +1,142 @@ +assertCount(1, (new ReflectionMethod(Promise::class, $method))->getAttributes(ProxyMethod::class)); + } + + $promise = new Promise; + $result = parallel([static function () use ($promise): string { + try { + $promise->resolve('foreign'); + } catch (CoroutineOwnershipException $exception) { + return $exception->getMessage(); + } + + return 'allowed'; + }]); + + $this->assertStringContainsString('cannot call [resolve] on a pending promise', $result[0]); + $this->assertSame(PromiseInterface::PENDING, $promise->getState()); + $promise->resolve('owner'); + $this->assertSame('owner', $promise->wait()); + } + + #[DataProvider('foreignOperations')] + public function testForeignPendingOperationsFailBeforeSideEffects(string $operation): void + { + $sideEffects = []; + $promise = new Promise( + static function () use (&$sideEffects): void { $sideEffects[] = 'wait'; }, + static function () use (&$sideEffects): void { $sideEffects[] = 'cancel'; }, + ); + + $results = parallel([static function () use ($promise, $operation, &$sideEffects): ?CoroutineOwnershipException { + try { + match ($operation) { + 'then' => $promise->then(static function () use (&$sideEffects): void { $sideEffects[] = 'callback'; }), + 'wait' => $promise->wait(false), + 'cancel' => $promise->cancel(), + 'resolve' => $promise->resolve('value'), + 'reject' => $promise->reject('reason'), + 'adopt' => (new Promise)->resolve($promise), + }; + } catch (CoroutineOwnershipException $exception) { + return $exception; + } + + return null; + }]); + + $this->assertInstanceOf(CoroutineOwnershipException::class, $results[0]); + $this->assertSame([], $sideEffects); + $this->assertSame(PromiseInterface::PENDING, $promise->getState()); + $promise->resolve('done'); + } + + /** + * Provide operations that can execute or alter pending work. + */ + public static function foreignOperations(): array + { + return array_map(static fn (string $operation): array => [$operation], [ + 'then', 'wait', 'cancel', 'resolve', 'reject', 'adopt', + ]); + } + + public function testCompletedFulfillmentAndRejectionCanBeReusedInOtherCoroutines(): void + { + $fulfilled = new Promise; + $fulfilled->resolve('done'); + $failure = new RuntimeException('failed'); + $rejected = new Promise; + $rejected->reject($failure); + + $results = parallel([static function () use ($fulfilled, $rejected): array { + return [ + $fulfilled->then(static fn (string $value): string => $value . '!')->wait(), + $rejected->otherwise(static fn (RuntimeException $exception): RuntimeException => $exception)->wait(), + ]; + }]); + + $this->assertSame(['done!', $failure], $results[0]); + } + + public function testAdoptedPendingChainsRemainOwnedUntilTheirValueCompletes(): void + { + $inner = new Promise; + $middle = new Promise; + $outer = new Promise; + $middle->resolve($inner); + $outer->resolve($middle); + + $this->assertSame(PromiseInterface::FULFILLED, $outer->getState()); + $blocked = parallel([static function () use ($outer): bool { + try { + $outer->then(static fn (mixed $value): mixed => $value); + } catch (CoroutineOwnershipException) { + return true; + } + + return false; + }]); + $this->assertTrue($blocked[0]); + + $inner->resolve('done'); + $this->assertSame(['done'], parallel([static fn (): mixed => $outer->wait()])); + } + + public function testFailedSelfResolutionDoesNotLeaveAdoptionMetadata(): void + { + $promise = new Promise; + try { + $promise->resolve($promise); + $this->fail('Self-resolution was accepted.'); + } catch (LogicException $exception) { + $this->assertStringContainsString('itself', $exception->getMessage()); + } + + $this->assertSame(PromiseInterface::PENDING, $promise->getState()); + $promise->resolve('done'); + $this->assertSame(['done'], parallel([static fn (): mixed => $promise->wait()])); + } +} diff --git a/tests/Http/Client/Guzzle/README.md b/tests/Http/Client/Guzzle/README.md new file mode 100644 index 000000000..13a94e3e4 --- /dev/null +++ b/tests/Http/Client/Guzzle/README.md @@ -0,0 +1,31 @@ +# Guzzle compatibility checks + +These tests exercise the generated production proxies, callback context, native transport ownership and cleanup. They run through the normal framework suite. The focused Guzzle 7 CI job also covers affected HTTP, broadcasting, AWS and bootstrap paths; the ordinary unlocked jobs exercise the current dependency family. + +The manual runner in `Fixtures/run-guzzle-promise-tests.php` runs Guzzle's unchanged promise tests. It is separate from Composer's test scripts because those upstream suites use PHPUnit 9, while Hypervel uses PHPUnit 13. Install each dependency family in its own temporary Composer project; never share its vendor directory with another checkout. + +For example, set `COMPONENTS` to your checkout's absolute path: + +```sh +COMPONENTS=/absolute/path/to/components +RUNTIME=/tmp/guzzle-promises-runtime +git clone --depth 1 --branch 3.0.2 https://github.com/guzzle/promises.git /tmp/guzzle-promises-tests +mkdir "$RUNTIME" +cd "$RUNTIME" +composer init --no-interaction --name=hypervel/guzzle-promises-check +composer config repositories.components path "$COMPONENTS" +composer config minimum-stability dev +composer config prefer-stable true +composer require --no-interaction 'hypervel/components:@dev' \ + 'guzzlehttp/guzzle:8.2.0' 'guzzlehttp/promises:3.0.2' 'guzzlehttp/psr7:3.1.0' \ + 'phpunit/phpunit:^9.6.34' + +php "$COMPONENTS/tests/Http/Client/Guzzle/Fixtures/run-guzzle-promise-tests.php" \ + "$RUNTIME/vendor/autoload.php" /tmp/guzzle-promises-tests coroutine +``` + +Repeat with `outside` to exercise production integration outside coroutines, and `stock` for the untouched Guzzle baseline. Both integrated modes check that the queue and all six Promise method proxies are installed. They omit only `UtilsTest::testReturnsTrampoline`, which requires the concrete stock `TaskQueue` class rather than its replaceable interface. The stock run retains that assertion; Hypervel's queue tests cover installation and identity under integration. + +For the other supported family, use a separate project and test clone with Guzzle 7.15.5, promises 2.5.3 and PSR-7 2.13.1. Run all three modes. When updating dependencies, use matching promise source tags and record the exact resolved versions. Do not change upstream assertions to make a failure pass; investigate the behavior first. + +Remove temporary projects and clones after verification. Performance measurements have their own [harness](../../../Benchmarks/GuzzleOwnership/README.md) and require an idle machine. diff --git a/tests/Http/Client/Guzzle/TransportOwnershipTest.php b/tests/Http/Client/Guzzle/TransportOwnershipTest.php new file mode 100644 index 000000000..65e7d6efa --- /dev/null +++ b/tests/Http/Client/Guzzle/TransportOwnershipTest.php @@ -0,0 +1,410 @@ +server?->shutdown(); + if ($this->serverCoroutine !== null) { + Coroutine::join([$this->serverCoroutine], 1); + } + } + + public function testForeignAdmissionAndDrivingFailWhileOwnerWorkStillCompletes(): void + { + $url = $this->startServer(); + $handler = new CurlMultiHandler; + $promise = $handler(new Request('GET', $url), ['timeout' => 2]); + $operations = ['__invoke', 'tick', 'execute']; + if (method_exists($handler, 'close')) { + $operations[] = 'close'; + } + + $refused = parallel([static function () use ($handler, $url, $operations): array { + $refused = []; + foreach ($operations as $operation) { + try { + $operation === '__invoke' + ? $handler(new Request('GET', $url), ['timeout' => 2]) + : $handler->{$operation}(); + } catch (CoroutineOwnershipException) { + $refused[] = $operation; + } + } + + return $refused; + }]); + + $this->assertSame($operations, $refused[0]); + $this->assertSame('ok', (string) $promise->wait()->getBody()); + $this->assertSame(['ok'], parallel([static fn (): string => (string) $handler(new Request('GET', $url), ['timeout' => 2])->wait()->getBody()])); + } + + public function testSameOwnerCanFinishOverlappingTransfers(): void + { + $arrivals = new Channel(2); + $arrived = 0; + $url = $this->startServer(static function (ServerRequest $request, ServerResponse $response) use ($arrivals, &$arrived): void { + if (++$arrived === 2) { + $arrivals->push(true); + $arrivals->push(true); + } + $response->status($arrivals->pop(2) === true ? 200 : 503); + $response->end('ok'); + }); + $handler = new CurlMultiHandler; + + try { + $responses = Utils::all([ + $handler(new Request('GET', $url), ['timeout' => 3]), + $handler(new Request('GET', $url), ['timeout' => 3]), + ])->wait(); + $this->assertSame([200, 200], array_map(static fn (ResponseInterface $response): int => $response->getStatusCode(), $responses)); + } finally { + $arrivals->close(); + } + } + + public function testPreparationReservesOwnershipBeforeReadingTheBody(): void + { + $url = $this->startServer(); + $handler = new CurlMultiHandler; + $reading = new Channel(1); + $continue = new Channel(1); + $body = FnStream::decorate(Psr7Utils::streamFor('upload'), [ + 'getSize' => static function () use ($reading, $continue): int { + $reading->push(true); + if ($continue->pop(2) !== true) { + throw new RuntimeException('The preparation probe was not released.'); + } + + return 6; + }, + ]); + + try { + $results = parallel([ + static fn (): string => (string) $handler(new Request('POST', $url, [], $body), ['timeout' => 2])->wait()->getBody(), + static function () use ($reading, $continue, $handler, $url): bool { + if ($reading->pop(2) !== true) { + throw new RuntimeException('Request preparation did not read the body.'); + } + try { + $handler(new Request('GET', $url), ['timeout' => 2]); + } catch (CoroutineOwnershipException) { + return true; + } finally { + $continue->push(true); + } + + return false; + }, + ]); + $this->assertSame(['ok', true], $results); + } finally { + $reading->close(); + $continue->close(); + } + } + + public function testFailedPreparationReleasesTheHandler(): void + { + $url = $this->startServer(); + $handler = new CurlMultiHandler; + $failure = new RuntimeException('body failed'); + $body = FnStream::decorate(Psr7Utils::streamFor('upload'), [ + 'getSize' => static fn (): never => throw $failure, + ]); + + try { + $handler(new Request('POST', $url, [], $body), ['timeout' => 2])->wait(); + $this->fail('The body exception was swallowed.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception->getPrevious() ?? $exception); + } + + $this->assertSame(['ok'], parallel([static fn (): string => (string) $handler(new Request('GET', $url), ['timeout' => 2])->wait()->getBody()])); + } + + public function testOwnerExitPropagatesCancellationInItsOwnContext(): void + { + $url = $this->startServer(); + $callbacks = []; + [$result] = parallel([static function () use ($url, &$callbacks): array { + $handler = new CurlMultiHandler; + $promise = $handler(new Request('GET', $url), ['timeout' => 2]); + $promise->otherwise(static function () use (&$callbacks): void { $callbacks[] = Coroutine::getCid(); }); + + return [$promise, WeakReference::create($handler), Coroutine::getCid()]; + }]); + [$promise, $handler, $owner] = $result; + + $this->assertSame(PromiseInterface::REJECTED, $promise->getState()); + gc_collect_cycles(); + Utils::queue()->run(); + $this->assertSame([$owner], $callbacks); + $this->assertNull($handler->get()); + } + + #[DataProvider('ownerExitModes')] + public function testOwnerExitResetsMemoizedCredentialsForTheNextCaller(bool $cancelOwner): void + { + $url = $this->startServer(); + $invocations = 0; + $provider = CredentialProvider::memoize(static function () use ($url, &$invocations): PromiseInterface { + ++$invocations; + + return PromiseCoroutine::of(static function () use ($url): Generator { + yield (new CurlMultiHandler)(new Request('GET', $url), ['timeout' => 2]); + yield new Credentials('test-key', 'test-secret'); + }); + }); + + if ($cancelOwner) { + $abandoned = null; + $blocked = new Channel(1); + $owner = FrameworkCoroutine::create(static function () use ($provider, &$abandoned, $blocked): void { + $abandoned = $provider(); + $blocked->pop(2); + }); + try { + $this->assertTrue(EngineCoroutine::cancelById($owner, true)); + if (Coroutine::exists($owner)) { + Coroutine::join([$owner], 2); + } + } finally { + $blocked->close(); + } + } else { + [$abandoned] = parallel([static fn (): PromiseInterface => $provider()]); + } + + try { + $abandoned->wait(); + $this->fail('The abandoned credential lookup was not canceled.'); + } catch (CancellationException) { + } + + $this->assertSame(['test-key'], parallel([static fn (): string => $provider()->wait()->getAccessKeyId()])); + $this->assertSame(2, $invocations); + } + + /** + * Provide normal exit and cancellation before waiting on credentials. + */ + public static function ownerExitModes(): array + { + return [ + 'normal exit' => [false], + 'canceled owner' => [true], + ]; + } + + public function testExitCleanupCancelsTransfersCreatedByRetryCallbacks(): void + { + $url = $this->startServer(); + $transfers = []; + $callbacks = []; + parallel([static function () use ($url, &$transfers, &$callbacks): void { + $handler = new CurlMultiHandler; + $first = $transfers[] = $handler(new Request('GET', $url), ['timeout' => 2]); + $first->otherwise(static function () use ($handler, $url, &$transfers, &$callbacks): PromiseInterface { + $callbacks[] = 'first'; + $retry = $transfers[] = $handler(new Request('GET', $url), ['timeout' => 2]); + $retry->otherwise(static function () use (&$callbacks): void { + $callbacks[] = 'retry'; + }); + + return $retry; + }); + }]); + + $this->assertSame(['first', 'retry'], $callbacks); + $this->assertCount(2, $transfers); + foreach ($transfers as $transfer) { + $this->assertSame(PromiseInterface::REJECTED, $transfer->getState()); + } + } + + public function testThrowingExitTaskReportsAndCancelsTransfersWithoutSkippingEarlierDefers(): void + { + $url = $this->startServer(); + $failure = new RuntimeException('exit callback failed'); + $reporter = m::mock(ExceptionHandler::class); + $reporter->shouldReceive('report')->once()->with($failure); + $this->app->instance(ExceptionHandler::class, $reporter); + $transfer = null; + $earlierDeferRan = false; + + parallel([static function () use ($url, $failure, &$transfer, &$earlierDeferRan): void { + FrameworkCoroutine::defer(static function () use (&$earlierDeferRan): void { + $earlierDeferRan = true; + }); + Utils::queue()->add(static function () use ($url, $failure, &$transfer): void { + $transfer = (new CurlMultiHandler)(new Request('GET', $url), ['timeout' => 2]); + + throw $failure; + }); + }]); + + $this->assertTrue($earlierDeferRan); + $this->assertSame(PromiseInterface::REJECTED, $transfer->getState()); + } + + public function testConcurrentSynchronousRequestsShareTheDefaultClientAndKeepCallbackContext(): void + { + $url = $this->startServer(static function (ServerRequest $request, ServerResponse $response): void { + if ($request->server['request_uri'] === '/redirect') { + $response->status(302); + $response->header('Location', '/'); + } + $response->end('ok'); + }); + $client = new Client; + $callbacks = []; + $middlewareCallbacks = []; + $client->getConfig('handler')->push(static function (callable $handler) use (&$middlewareCallbacks): callable { + return static function (RequestInterface $request, array $options) use ($handler, &$middlewareCallbacks): PromiseInterface { + $owner = Coroutine::getCid(); + $name = CoroutineContext::get('guzzle-test.request'); + + return $handler($request, $options)->then(static function (ResponseInterface $response) use ($owner, $name, &$middlewareCallbacks): ResponseInterface { + usleep(1000); + $middlewareCallbacks[$name][] = [Coroutine::getCid() === $owner, CoroutineContext::get('guzzle-test.request'), $response->getStatusCode()]; + + return $response; + }); + }; + }); + $requests = []; + foreach (['first', 'second'] as $name) { + $requests[] = static function () use ($client, $url, $name, &$callbacks): string { + CoroutineContext::set('guzzle-test.request', $name); + $owner = Coroutine::getCid(); + + return (string) $client->get($url . 'redirect', [ + 'timeout' => 2, + 'on_stats' => static function (TransferStats $stats) use ($name, $owner, &$callbacks): void { + usleep(1000); + $callbacks[$name][] = [Coroutine::getCid() === $owner, CoroutineContext::get('guzzle-test.request'), $stats->getResponse()->getStatusCode()]; + }, + ])->getBody(); + }; + } + + $this->assertSame(['ok', 'ok'], parallel($requests)); + foreach (['first', 'second'] as $name) { + $this->assertSame([[true, $name, 302], [true, $name, 200]], $callbacks[$name]); + $this->assertSame([[true, $name, 302], [true, $name, 200]], $middlewareCallbacks[$name]); + } + } + + public function testCancellationReleasesTheHandlerForAnotherOwner(): void + { + $url = $this->startServer(); + $handler = new CurlMultiHandler; + $promise = $handler(new Request('GET', $url), ['timeout' => 2]); + $promise->cancel(); + + $this->assertSame(PromiseInterface::REJECTED, $promise->getState()); + $this->assertSame(['ok'], parallel([static fn (): string => (string) $handler(new Request('GET', $url), ['timeout' => 2])->wait()->getBody()])); + } + + public function testCompletedTransfersDoNotRetainTheirHandlerUntilCoroutineExit(): void + { + $url = $this->startServer(); + $handler = new CurlMultiHandler; + $reference = WeakReference::create($handler); + $promise = $handler(new Request('GET', $url), ['timeout' => 2]); + + $this->assertSame('ok', (string) $promise->wait()->getBody()); + unset($promise, $handler); + gc_collect_cycles(); + + $this->assertNull($reference->get()); + } + + public function testAStreamingBodyCanBeReadLaterInAnotherCoroutine(): void + { + $continue = new Channel(1); + $url = $this->startServer(static function (ServerRequest $request, ServerResponse $response) use ($continue): void { + $response->write('first'); + if ($continue->pop(2) !== true) { + $response->end('timed out'); + return; + } + $response->end('second'); + }); + + try { + $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 2])->get($url); + $bodies = parallel([static function () use ($response, $continue): string { + $continue->push(true); + + return $response->body(); + }]); + $this->assertSame(['firstsecond'], $bodies); + } finally { + $continue->close(); + } + } + + /** + * Start a bounded loopback server on an automatically assigned port. + */ + protected function startServer(?callable $callback = null): string + { + $this->server = new Server('127.0.0.1', 0, false, false); + $this->server->handle('/', $callback ?? static function (ServerRequest $request, ServerResponse $response): void { + $response->end('ok'); + }); + $this->serverCoroutine = Coroutine::create(fn (): bool => $this->server->start()); + + return 'http://127.0.0.1:' . $this->server->port . '/'; + } +} diff --git a/tests/Http/HttpConnectionTest.php b/tests/Http/HttpConnectionTest.php index e5889f910..ad765541f 100644 --- a/tests/Http/HttpConnectionTest.php +++ b/tests/Http/HttpConnectionTest.php @@ -326,7 +326,8 @@ public static function universallyReservedOptionsProvider(): array ]; } - public function testTransportSharingOnlyConfiguresTheConnectionHandler(): void + #[DataProvider('requestModes')] + public function testTransportSharingOnlyConfiguresTheConnectionHandler(bool $async): void { $factory = new RecordingHttpConnectionFactory; $factory->registerConnection('api', [ @@ -334,8 +335,12 @@ public function testTransportSharingOnlyConfiguresTheConnectionHandler(): void 'timeout' => 12, ]); - $factory->connection('api')->get('https://example.com'); + $response = $factory->connection('api')->async($async)->get('https://example.com'); + if ($async) { + $response = $response->wait(); + } + $this->assertSame('handler-1', $response->body()); $this->assertSame( [['transport_sharing' => TransportSharing::HANDLER_PREFER]], $factory->createdHandlerOptions, @@ -344,17 +349,55 @@ public function testTransportSharingOnlyConfiguresTheConnectionHandler(): void $this->assertSame(12, $factory->invocations[0]['options']['timeout']); } - public function testDisablingMultiplexingConfiguresTheHandlerAndRequest(): void + #[DataProvider('requestModes')] + public function testDisablingMultiplexingConfiguresTheHandlerAndRequest(bool $async): void { $factory = new RecordingHttpConnectionFactory; $factory->registerConnection('api', ['multiplex' => Multiplexing::NONE]); - $factory->connection('api')->get('https://example.com'); + $response = $factory->connection('api')->async($async)->get('https://example.com'); + if ($async) { + $response = $response->wait(); + } + $this->assertSame('handler-1', $response->body()); $this->assertSame([['multiplex' => Multiplexing::NONE]], $factory->createdHandlerOptions); $this->assertSame(Multiplexing::NONE, $factory->invocations[0]['options']['multiplex']); } + #[DataProvider('requestModes')] + public function testReplacingThePresetPreservesRegisteredTransportOptions(bool $async): void + { + $factory = new RecordingHttpConnectionFactory; + $factory->registerConnection('api', [ + 'multiplex' => Multiplexing::NONE, + 'transport_sharing' => TransportSharing::HANDLER_REQUIRE, + 'timeout' => 12, + ]); + + $response = $factory->connection('api', [])->async($async)->get('https://example.com'); + if ($async) { + $response = $response->wait(); + } + + $this->assertSame('handler-1', $response->body()); + $this->assertSame([[ + 'transport_sharing' => TransportSharing::HANDLER_REQUIRE, + 'multiplex' => Multiplexing::NONE, + ]], $factory->createdHandlerOptions); + $this->assertArrayNotHasKey('multiplex', $factory->invocations[0]['options']); + $this->assertArrayNotHasKey('transport_sharing', $factory->invocations[0]['options']); + $this->assertSame(30, $factory->invocations[0]['options']['timeout']); + } + + /** + * Provide synchronous and asynchronous request modes. + */ + public static function requestModes(): array + { + return ['sync' => [false], 'async' => [true]]; + } + public function testOtherMultiplexingModesConfigureOnlyTheRequest(): void { $factory = new RecordingHttpConnectionFactory; @@ -369,15 +412,14 @@ public function testOtherMultiplexingModesConfigureOnlyTheRequest(): void public function testRegisteredAsynchronousRequestsDoNotUseTheSharedHandler(): void { $factory = new RecordingHttpConnectionFactory; - $factory->registerConnection('api'); - - $factory->connection('api')->async()->buildHandlerStack(); - - $this->assertSame([], $factory->createdHandlerOptions); + $factory->registerConnection('api', ['multiplex' => Multiplexing::NONE]); - $factory->connection('api')->buildHandlerStack(); + $this->assertSame('handler-1', $factory->connection('api')->get('https://example.com')->body()); + $this->assertSame('handler-2', $factory->connection('api')->async()->get('https://example.com')->wait()->body()); + $this->assertSame('handler-3', $factory->connection('api')->async()->get('https://example.com')->wait()->body()); + $this->assertSame('handler-1', $factory->connection('api')->get('https://example.com')->body()); - $this->assertSame([[]], $factory->createdHandlerOptions); + $this->assertSame(array_fill(0, 3, ['multiplex' => Multiplexing::NONE]), $factory->createdHandlerOptions); } public function testReregisteringAConnectionReplacesItsSharedHandler(): void diff --git a/tests/Integration/Foundation/Console/AboutCommandTest.php b/tests/Integration/Foundation/Console/AboutCommandTest.php index 16b2ec1a4..7ddbd7bbf 100644 --- a/tests/Integration/Foundation/Console/AboutCommandTest.php +++ b/tests/Integration/Foundation/Console/AboutCommandTest.php @@ -18,7 +18,7 @@ public function testItCanDisplayAboutCommandAsJson(): void { $process = remote('about --json', ['APP_ENV' => 'local', 'APP_DEBUG' => 'true'])->mustRun(); - tap(json_decode($process->getOutput(), true), function ($output) { + tap(json_decode($process->getOutput(), true), function (array $output): void { Assert::assertArraySubset([ 'php_version' => PHP_VERSION, 'swoole_version' => swoole_version(), @@ -26,7 +26,7 @@ public function testItCanDisplayAboutCommandAsJson(): void 'debug_mode' => true, ], $output['environment']); - $this->assertArrayHasKey('aop_proxies', $output['cache']); + $this->assertTrue($output['cache']['aop_proxies']); }); } diff --git a/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php b/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php index a7baedf34..7fef5c12b 100644 --- a/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php +++ b/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Filesystem\Filesystem; +use Hypervel\RateLimiter\Limiter; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Testbench\Attributes\WithMigration; @@ -40,11 +41,6 @@ public static function tearDownAfterClass(): void parent::tearDownAfterClass(); } - public function testStoreContractCooldownExtendsWithoutShorteningAndExpires(): void - { - $this->freezeTime(fn () => parent::testStoreContractCooldownExtendsWithoutShorteningAndExpires()); - } - // @TODO Remove these overrides when the first tagged Swoole release containing // https://github.com/swoole/swoole-src/pull/6140 is the minimum supported version. public function testConcurrentFirstUseAdmitsExactlyTheConfiguredCapacity(): void @@ -67,6 +63,17 @@ public function testConcurrentCooldownBlocksRetainTheLongestExpiry(): void $this->markTestSkipped('Requires the Swoole AIO scheduler fix from PR #6140.'); } + /** + * Create a limiter with a deterministic clock for the shared contract. + */ + protected function rateLimiterStoreContract(): Limiter + { + // SQLite uses PHP time, so elapsed I/O must not change back-to-back decisions. + $this->freezeTime(); + + return parent::rateLimiterStoreContract(); + } + /** * Advance the SQLite store's clock when the contract test freezes time. */ diff --git a/tests/Queue/QueueSqsConnectorTest.php b/tests/Queue/QueueSqsConnectorTest.php index c0cd86ae0..0179d8a1d 100644 --- a/tests/Queue/QueueSqsConnectorTest.php +++ b/tests/Queue/QueueSqsConnectorTest.php @@ -619,15 +619,17 @@ public function testConnectBuildsStaticCredentialsWithToken(): void $this->assertNull($queue->getSqs()->getConfig('token')); } - public function testCallableAndObjectCredentialsPassThrough(): void + public function testCallableCredentialsResolveTheirResultAndCredentialObjectsPassThrough(): void { $connector = new QueueSqsConnectorStub; - $callable = static fn () => null; $object = new Credentials('key', 'secret'); + $callable = static fn (): PromiseInterface => Create::promiseFor($object); - $this->assertSame($callable, $connector->resolveCredentials([ + $resolved = $connector->resolveCredentials([ 'credentials' => $callable, - ])); + ]); + + $this->assertSame($object, $resolved()->wait()); $this->assertSame($object, $connector->resolveCredentials([ 'credentials' => $object, ])); diff --git a/tests/RateLimiter/SwooleStoreTest.php b/tests/RateLimiter/SwooleStoreTest.php index 741420cd9..8c63cb219 100644 --- a/tests/RateLimiter/SwooleStoreTest.php +++ b/tests/RateLimiter/SwooleStoreTest.php @@ -367,8 +367,13 @@ private function fillUntilAllocationFails(TableState $state, int $expiresAt): ar $this->fail('The test table did not reach capacity.'); } + /** + * Create a limiter with a deterministic clock for the shared contract. + */ protected function rateLimiterStoreContract(): Limiter { + // Keep back-to-back decisions independent of elapsed execution time. + CarbonImmutable::setTestNow(CarbonImmutable::now()); [$store] = $this->store(); return new Limiter( diff --git a/tests/RateLimiter/WorkerArrayStoreTest.php b/tests/RateLimiter/WorkerArrayStoreTest.php index 382fbdb17..49a9a7f21 100644 --- a/tests/RateLimiter/WorkerArrayStoreTest.php +++ b/tests/RateLimiter/WorkerArrayStoreTest.php @@ -206,8 +206,14 @@ private function limiter(): Limiter ); } + /** + * Create a limiter with a deterministic clock for the shared contract. + */ protected function rateLimiterStoreContract(): Limiter { + // Keep back-to-back decisions independent of elapsed execution time. + CarbonImmutable::setTestNow(CarbonImmutable::now()); + return $this->limiter(); } diff --git a/tests/Routing/ResponseFactoryTest.php b/tests/Routing/ResponseFactoryTest.php index a47e95efe..f85383da8 100644 --- a/tests/Routing/ResponseFactoryTest.php +++ b/tests/Routing/ResponseFactoryTest.php @@ -20,6 +20,9 @@ class ResponseFactoryTest extends TestCase { + // PHPUnit's output capture runs outside the test coroutine. + protected bool $runTestsInCoroutine = false; + public function testStreamRetainsGeneratorChunksLazily(): void { $iterations = 0; @@ -161,15 +164,13 @@ public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrW } : static fn () => throw $failure; $response = $this->factory()->{$method}($callback); - ob_start(); + $this->expectOutputString($method === 'eventStream' ? "event: update\ndata: first\n\n" : ''); try { $response->sendContent(); $this->fail('Expected response production to propagate cancellation.'); } catch (CanceledException $exception) { $this->assertSame($failure, $exception); - } finally { - ob_end_clean(); } } diff --git a/tests/Saloon/Feature/AuthenticatesRequestsTest.php b/tests/Saloon/Feature/AuthenticatesRequestsTest.php index d541521dd..ded94ae7a 100644 --- a/tests/Saloon/Feature/AuthenticatesRequestsTest.php +++ b/tests/Saloon/Feature/AuthenticatesRequestsTest.php @@ -4,13 +4,10 @@ namespace Hypervel\Tests\Saloon\Feature; -use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Http\Client\Factory; -use Hypervel\Http\Client\Request as HttpRequest; use Hypervel\Saloon\SaloonServiceProvider; -use Hypervel\Support\Facades\Http; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Http\Fixtures\LoopbackHttpServer; use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector; use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest; @@ -24,31 +21,27 @@ protected function getPackageProviders(ApplicationContract $app): array return [SaloonServiceProvider::class]; } - // Upstream inspects the auth option in Guzzle middleware added through its sender. Guzzle's auth middleware - // consumes that option before the transport, so this answers its unauthenticated probe with a digest challenge - // and asserts the credentials Guzzle sends in reply. + // Digest negotiation runs below Saloon, in cURL or Guzzle middleware depending on the Guzzle version. + // Assert the real exchange instead of upstream's sender option, which middleware can consume. public function testYouCanProvideDigestAuthenticationAndGuzzleWillSendIt(): void { - $connector = new TestConnector; + $server = LoopbackHttpServer::start([ + ['status' => 401, 'headers' => ['WWW-Authenticate' => 'Digest realm="saloon", nonce="nonce", qop="auth"']], + ['body' => 'authenticated'], + ]); + $connector = new TestConnector('http://127.0.0.1:' . $server->port); $request = new UserRequest; $request->withDigestAuth('Sammyjo20', 'Cowboy1'); - $authorization = []; - - Http::fake(function (HttpRequest $request) use (&$authorization): PromiseInterface { - $authorization[] = $request->header('Authorization'); - - return count($authorization) === 1 - ? Factory::response('', 401, ['WWW-Authenticate' => 'Digest realm="saloon", nonce="nonce", qop="auth"']) - : Factory::response(); - }); - $response = $connector->send($request); $this->assertSame(200, $response->status()); - $this->assertCount(2, $authorization); - $this->assertSame([], $authorization[0]); - $this->assertStringStartsWith('Digest username="Sammyjo20", realm="saloon", nonce="nonce"', $authorization[1][0]); + $this->assertStringNotContainsString('Authorization:', $server->request()); + $authenticated = $server->request(); + $this->assertStringContainsString('Authorization: Digest ', $authenticated); + $this->assertStringContainsString('username="Sammyjo20"', $authenticated); + $this->assertStringContainsString('realm="saloon"', $authenticated); + $this->assertStringContainsString('nonce="nonce"', $authenticated); } } diff --git a/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php b/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php index 74179b4ff..9d91a47d0 100644 --- a/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php +++ b/tests/Sentry/Aspects/GuzzleHttpClientAspectTest.php @@ -10,7 +10,6 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\TransferStats; -use Hypervel\Foundation\Testing\Concerns\InteractsWithAop; use Hypervel\Tests\Sentry\SentryTestCase; use Psr\Http\Message\RequestInterface; use RuntimeException; @@ -19,8 +18,6 @@ class GuzzleHttpClientAspectTest extends SentryTestCase { - use InteractsWithAop; - protected array $defaultSetupConfig = [ 'sentry.traces_sample_rate' => 1.0, ]; @@ -31,7 +28,7 @@ public function testBreadcrumbIsRecorded(): void new Response(200, [], 'OK'), ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com/api/test')); + $client->send(new Request('GET', 'https://example.com/api/test')); $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); @@ -57,7 +54,7 @@ public function testBreadcrumbIsNotRecordedWhenDisabled(): void new Response(200, [], 'OK'), ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); } @@ -70,13 +67,13 @@ public function testBreadcrumbLevelReflectsHttpStatus(): void new Response(500, [], 'Internal Server Error'), ], ['http_errors' => false]); - $this->executeTransfer($client, new Request('GET', 'https://example.com/ok'), ['http_errors' => false]); + $client->send(new Request('GET', 'https://example.com/ok'), ['http_errors' => false]); $this->assertEquals('info', $this->getLastSentryBreadcrumb()->getLevel()); - $this->executeTransfer($client, new Request('GET', 'https://example.com/not-found'), ['http_errors' => false]); + $client->send(new Request('GET', 'https://example.com/not-found'), ['http_errors' => false]); $this->assertEquals('warning', $this->getLastSentryBreadcrumb()->getLevel()); - $this->executeTransfer($client, new Request('GET', 'https://example.com/error'), ['http_errors' => false]); + $client->send(new Request('GET', 'https://example.com/error'), ['http_errors' => false]); $this->assertEquals('error', $this->getLastSentryBreadcrumb()->getLevel()); } @@ -88,7 +85,7 @@ public function testSpanIsRecorded(): void new Response(200, [], 'OK'), ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $span = last($transaction->getSpanRecorder()->getSpans()); @@ -107,11 +104,11 @@ public function testSpanIsRecordedWithCorrectStatus(): void new Response(500, [], 'Internal Server Error'), ], ['http_errors' => false]); - $this->executeTransfer($client, new Request('GET', 'https://example.com/success'), ['http_errors' => false]); + $client->send(new Request('GET', 'https://example.com/success'), ['http_errors' => false]); $span = last($transaction->getSpanRecorder()->getSpans()); $this->assertEquals(SpanStatus::ok(), $span->getStatus()); - $this->executeTransfer($client, new Request('GET', 'https://example.com/error'), ['http_errors' => false]); + $client->send(new Request('GET', 'https://example.com/error'), ['http_errors' => false]); $span = last($transaction->getSpanRecorder()->getSpans()); $this->assertEquals(SpanStatus::internalError(), $span->getStatus()); } @@ -128,7 +125,7 @@ static function () use (&$observedSpan): Response { }, ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $this->assertSame($transaction, $observedSpan); $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); @@ -141,7 +138,7 @@ public function testFailedTransferFinishesTheExactHttpSpan(): void $client = $this->makeClient([$exception]); try { - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $this->fail('Expected the transfer to fail.'); } catch (RuntimeException $thrown) { $this->assertSame($exception, $thrown); @@ -169,7 +166,7 @@ public function testSpanIsNotRecordedWhenDisabled(): void new Response(200, [], 'OK'), ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $span = last($transaction->getSpanRecorder()->getSpans()); $this->assertNotEquals('http.client', $span->getOp()); @@ -189,12 +186,12 @@ public function testTracingHeadersAreAttached(): void $this->startTransaction(); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $sentRequest = $mock->getLastRequest(); $this->assertTrue($sentRequest->hasHeader('sentry-trace')); $this->assertTrue($sentRequest->hasHeader('baggage')); - $this->executeTransfer($client, new Request('GET', 'https://no-headers.example.com')); + $client->send(new Request('GET', 'https://no-headers.example.com')); $sentRequest = $mock->getLastRequest(); $this->assertFalse($sentRequest->hasHeader('sentry-trace')); $this->assertFalse($sentRequest->hasHeader('baggage')); @@ -211,7 +208,7 @@ public function testTracingHeadersAreAttachedWhenLocalRecordingIsDisabled(): voi $mock = new MockHandler([new Response(200, [], 'OK')]); $client = new Client(['handler' => HandlerStack::create($mock)]); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $sentRequest = $mock->getLastRequest(); $this->assertTrue($sentRequest->hasHeader('sentry-trace')); @@ -238,7 +235,7 @@ static function (RequestInterface $request, array $options) use (&$observedOnSta }, ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com'), [ + $client->send(new Request('GET', 'https://example.com'), [ 'on_stats' => $existingOnStats, ]); @@ -257,7 +254,7 @@ public function testLegacyEnableTracingOptionRecordsSpansWithoutAnExplicitSample $transaction = $this->startTransaction(); $client = $this->makeClient([new Response(200, [], 'OK')]); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $span = last($transaction->getSpanRecorder()->getSpans()); $this->assertSame('http.client', $span->getOp()); @@ -269,7 +266,7 @@ public function testPerRequestOptOut(): void new Response(200, [], 'OK'), ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com'), ['no_sentry_aspect' => true]); + $client->send(new Request('GET', 'https://example.com'), ['no_sentry_aspect' => true]); $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); } @@ -282,7 +279,7 @@ public function testPerClientOptOut(): void 'no_sentry_aspect' => true, ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com')); + $client->send(new Request('GET', 'https://example.com')); $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); } @@ -295,7 +292,7 @@ public function testExistingOnStatsCallbackIsPreserved(): void new Response(200, [], 'OK'), ]); - $this->executeTransfer($client, new Request('GET', 'https://example.com'), [ + $client->send(new Request('GET', 'https://example.com'), [ 'on_stats' => function (TransferStats $stats) use (&$callbackFired) { $callbackFired = true; }, @@ -308,8 +305,8 @@ public function testExistingOnStatsCallbackIsPreserved(): void /** * Create a Guzzle client with a MockHandler queuing the given responses. * - * The AOP proxy for GuzzleHttp\Client is generated by Testbench's bootstrap - * (GenerateProxies), so the aspect intercepts transfer() automatically. + * The shared PHPUnit extension generates GuzzleHttp\Client's proxy before + * test discovery, so registered aspects intercept transfer() automatically. */ private function makeClient(array $responses, array $config = []): Client { @@ -317,43 +314,4 @@ private function makeClient(array $responses, array $config = []): Client 'handler' => HandlerStack::create(new MockHandler($responses)), ])); } - - private function executeTransfer(Client $client, RequestInterface $request, array $options = []): void - { - if ($this->isAopProxied($client)) { - $client->send($request, $options); - - return; - } - - ['request' => $preparedRequest, 'options' => $preparedOptions] = $this->prepareTransferArguments( - $client, - $request, - $options - ); - - $this->callWithAspects($client, 'transfer', [ - 'request' => $preparedRequest, - 'options' => $preparedOptions, - ])->wait(); - } - - /** - * Mirror Guzzle's sendAsync() setup before manually invoking transfer(). - * - * @return array{request: RequestInterface, options: array} - */ - private function prepareTransferArguments(Client $client, RequestInterface $request, array $options): array - { - $preparedOptions = (fn (array $options): array => $this->prepareDefaults($options)) - ->call($client, $options); - - $preparedUri = (fn ($uri, array $options) => $this->buildUri($uri, $options)) - ->call($client, $request->getUri(), $preparedOptions); - - return [ - 'request' => $request->withUri($preparedUri, $request->hasHeader('Host')), - 'options' => $preparedOptions, - ]; - } } diff --git a/tests/Support/AwsCredentialConsumerTest.php b/tests/Support/AwsCredentialConsumerTest.php new file mode 100644 index 000000000..1a845dcb2 --- /dev/null +++ b/tests/Support/AwsCredentialConsumerTest.php @@ -0,0 +1,182 @@ +resolve(new Credentials(CoroutineContext::get('__test.aws.tenant'), 'secret')); + }); + + return $promise; + }; + $configuration = [ + 'region' => 'us-east-1', + 'version' => 'latest', + 'endpoint' => 'https://aws.example.test', + 'credentials' => $provider, + 'retries' => 0, + 'pool' => ['fingerprint' => 'shared-provider'], + 'http_handler' => static function (RequestInterface $request, array $options) use (&$authorization, $consumer): PromiseInterface { + $authorization[CoroutineContext::get('__test.aws.tenant')][] = $request->getHeaderLine('Authorization'); + + return Create::promiseFor(new Response(200, ['Content-Type' => 'application/json'], match ($consumer) { + 'sqs' => '{"Attributes":{"ApproximateNumberOfMessages":"0","ApproximateNumberOfMessagesDelayed":"0","ApproximateNumberOfMessagesNotVisible":"0"}}', + 'ses' => '{"MessageId":"message-id"}', + 's3' => '', + })); + }, + ]; + + switch ($consumer) { + case 'sqs': + $configuration += ['driver' => 'sqs', 'queue' => 'credentials', 'prefix' => 'https://aws.example.test/account']; + config(['queue.connections.first' => $configuration, 'queue.connections.second' => $configuration]); + $manager = $this->app->make(QueueManager::class); + $first = $manager->connection('first'); + $second = $manager->connection('second'); + $operation = static fn (object $connection): int => $connection->size(); + break; + case 's3': + $configuration += ['driver' => 's3', 'bucket' => 'credentials', 'use_path_style_endpoint' => true]; + config(['filesystems.disks.first' => $configuration, 'filesystems.disks.second' => $configuration]); + $manager = $this->app->make(FilesystemManager::class); + $first = $manager->disk('first'); + $second = $manager->disk('second'); + $operation = static fn (object $disk): bool => $disk->fileExists('example.txt'); + break; + case 'ses': + $configuration += ['transport' => 'ses-v2']; + config(['mail.mailers.first' => $configuration, 'mail.mailers.second' => $configuration]); + $manager = $this->app->make(MailManager::class); + $first = $manager->mailer('first')->getSymfonyTransport(); + $second = $manager->mailer('second')->getSymfonyTransport(); + $operation = static fn (object $transport): string => $transport->send( + (new Email)->from('sender@example.test')->to('recipient@example.test')->text('Hello'), + )->getMessageId(); + break; + } + + $this->assertSame($first->getPoolName(), $second->getPoolName()); + $pools = $this->app->make(PoolFactory::class); + + try { + $results = parallel([ + static function () use ($first, $operation): mixed { + CoroutineContext::set('__test.aws.tenant', 'tenant-first'); + + return $operation($first); + }, + static function () use ($second, $operation): mixed { + CoroutineContext::set('__test.aws.tenant', 'tenant-second'); + + return $operation($second); + }, + ]); + + $this->assertCount(2, $results); + $this->assertSame(1, $maximumActive); + $this->assertSame(0, $active); + $this->assertCount(2, $authorization); + $this->assertCount(1, $authorization['tenant-first']); + $this->assertCount(1, $authorization['tenant-second']); + $this->assertStringContainsString('Credential=tenant-first/', $authorization['tenant-first'][0]); + $this->assertStringContainsString('Credential=tenant-second/', $authorization['tenant-second'][0]); + $this->assertCount(1, $pools->getPools()); + $this->assertArrayHasKey($first->getPoolName(), $pools->getPools()); + } finally { + $pools->purgeAll(); + } + } + + /** + * Provide SDK consumers that share custom credential providers across pooled clients. + */ + public static function consumers(): array + { + return ['SQS' => ['sqs'], 'S3' => ['s3'], 'SES v2' => ['ses']]; + } + + #[DataProvider('credentialExpirations')] + public function testSdkCallbacksCanReenterASharedMemoizedProvider(int $expiresIn): void + { + $fetches = 0; + $requests = 0; + $provider = CredentialProvider::memoize(static function () use (&$fetches, $expiresIn): PromiseInterface { + ++$fetches; + $promise = new Promise(static function () use (&$promise, $expiresIn): void { + $promise->resolve(new Credentials('key', 'secret', null, time() + $expiresIn)); + }); + + return $promise; + }); + $configuration = [ + 'region' => 'us-east-1', + 'version' => 'latest', + 'bucket' => 'credentials', + 'credentials' => $provider, + 'retries' => 0, + 'http_handler' => static function (RequestInterface $request) use (&$requests): PromiseInterface { + ++$requests; + self::assertStringContainsString('Credential=key/', $request->getHeaderLine('Authorization')); + + return Create::promiseFor(new Response(200, [], '')); + }, + ]; + $manager = $this->app->make(FilesystemManager::class); + $first = $manager->createS3Driver($configuration)->getClient(); + $configuration['credentials'] = [$provider, '__invoke']; + $second = $manager->createS3Driver($configuration)->getClient(); + + $pending = $first->listBucketsAsync()->then(static fn (): PromiseInterface => $second->listBucketsAsync()); + $this->assertSame([], $first->listBuckets()['Buckets']); + $this->assertSame([], $pending->wait()['Buckets']); + $this->assertSame([[]], parallel([static fn (): array => $second->listBuckets()['Buckets']])); + $this->assertSame(4, $requests); + + if ($expiresIn > CredentialProvider::REFRESH_WINDOW) { + $this->assertSame(1, $fetches); + } else { + $this->assertGreaterThan(1, $fetches); + } + } + + /** + * Provide credentials outside and inside the SDK's refresh window. + */ + public static function credentialExpirations(): array + { + return ['cached' => [3600], 'refreshing' => [30]]; + } +} diff --git a/tests/Support/SerializedCredentialProviderNonCoroutineTest.php b/tests/Support/SerializedCredentialProviderNonCoroutineTest.php new file mode 100644 index 000000000..30f8e1490 --- /dev/null +++ b/tests/Support/SerializedCredentialProviderNonCoroutineTest.php @@ -0,0 +1,34 @@ +resolve($credentials); + }); + + return $promise; + }); + + $result = $provider(); + + $this->assertSame(PromiseInterface::FULFILLED, $result->getState()); + $this->assertSame($credentials, $result->wait()); + $this->assertSame($credentials, $provider()->wait()); + } +} diff --git a/tests/Support/SerializedCredentialProviderTest.php b/tests/Support/SerializedCredentialProviderTest.php new file mode 100644 index 000000000..2ab45ab11 --- /dev/null +++ b/tests/Support/SerializedCredentialProviderTest.php @@ -0,0 +1,330 @@ +assertSame($owner, Coroutine::id()); + --$active; + $promise->resolve(new Credentials($key, 'secret')); + }); + + return $promise; + }; + + $first = new SerializedCredentialProvider($provider); + $second = new SerializedCredentialProvider($provider); + $results = parallel([ + static fn (): string => $first('first')->wait()->getAccessKeyId(), + static fn (): string => $second('second')->wait()->getAccessKeyId(), + ]); + + $this->assertSame(['first', 'second'], $results); + $this->assertSame(1, $maximumActive); + $this->assertSame(0, $active); + } + + public function testInvokableAndBoundMethodAliasesShareTheSameProviderLock(): void + { + $provider = new SerializedCredentialProviderTestProvider; + $first = new SerializedCredentialProvider($provider); + $second = new SerializedCredentialProvider([$provider, '__invoke']); + $third = new SerializedCredentialProvider([$provider, 'resolve']); + + $results = parallel([ + static fn (): string => $first('first')->wait()->getAccessKeyId(), + static fn (): string => $second('second')->wait()->getAccessKeyId(), + static fn (): string => $third('third')->wait()->getAccessKeyId(), + ]); + + $this->assertSame(['first', 'second', 'third'], $results); + $this->assertSame(1, $provider->maximumActive); + } + + #[DataProvider('staticCallableForms')] + public function testNamedCallableAliasesShareTheSameProviderLock(callable $first, callable $second): void + { + SerializedCredentialProviderTestProvider::$shared = $provider = new SerializedCredentialProviderTestProvider; + + try { + $first = new SerializedCredentialProvider($first); + $second = new SerializedCredentialProvider($second); + $results = parallel([ + static fn (): string => $first('first')->wait()->getAccessKeyId(), + static fn (): string => $second('second')->wait()->getAccessKeyId(), + ]); + + $this->assertSame(['first', 'second'], $results); + $this->assertSame(1, $provider->maximumActive); + } finally { + SerializedCredentialProviderTestProvider::$shared = null; + } + } + + /** + * Provide alternate spellings of named callable providers. + */ + public static function staticCallableForms(): array + { + return [ + 'static method' => [ + [SerializedCredentialProviderTestProvider::class, 'resolveShared'], + '\\' . strtoupper(SerializedCredentialProviderTestProvider::class) . '::resolveShared', + ], + 'function' => [ + __NAMESPACE__ . '\serializedCredentialProviderTestFunction', + '\\' . strtoupper(__NAMESPACE__ . '\serializedCredentialProviderTestFunction'), + ], + ]; + } + + #[DataProvider('failureModes')] + public function testFailureReleasesTheLockWithoutCachingTheFailure(bool $reject): void + { + $failure = new RuntimeException('Credentials unavailable.'); + $calls = 0; + $provider = new SerializedCredentialProvider(function () use (&$calls, $failure, $reject): PromiseInterface { + if (++$calls === 1) { + return $reject ? Create::rejectionFor($failure) : throw $failure; + } + + return Create::promiseFor(new Credentials('recovered', 'secret')); + }); + + $result = $provider(); + $this->assertSame(PromiseInterface::REJECTED, $result->getState()); + + try { + $result->wait(); + $this->fail('The credential failure was not propagated.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame('recovered', $provider()->wait()->getAccessKeyId()); + $this->assertSame(2, $calls); + } + + /** + * Provide synchronous and asynchronous credential failures. + */ + public static function failureModes(): array + { + return ['throws' => [false], 'rejects' => [true]]; + } + + public function testExplicitSdkMemoizationStillReusesCompletedCredentials(): void + { + $calls = 0; + $credentials = new Credentials('cached', 'secret', null, time() + 3600); + $memoized = CredentialProvider::memoize(function () use (&$calls, $credentials): PromiseInterface { + ++$calls; + $owner = Coroutine::id(); + $promise = new Promise(function () use (&$promise, $credentials, $owner): void { + usleep(1000); + $this->assertSame($owner, Coroutine::id()); + $promise->resolve($credentials); + }); + + return $promise; + }); + + $first = new SerializedCredentialProvider($memoized); + $second = new SerializedCredentialProvider($memoized); + $results = parallel([ + static fn (): mixed => $first()->wait(), + static fn (): mixed => $second()->wait(), + ]); + + $this->assertSame([$credentials, $credentials], $results); + $this->assertSame(1, $calls); + } + + public function testCancellationExceptionIsPropagatedUnchangedAndReleasesTheLock(): void + { + $cancellation = new CanceledException('Canceled credential resolution.'); + $calls = 0; + $provider = new SerializedCredentialProvider(function () use (&$calls, $cancellation): PromiseInterface { + if (++$calls === 1) { + throw $cancellation; + } + + return Create::promiseFor(new Credentials('next', 'secret')); + }); + + try { + $provider(); + $this->fail('Cancellation was converted into an ordinary rejection.'); + } catch (CanceledException $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame('next', $provider()->wait()->getAccessKeyId()); + } + + public function testTranslatedCancellationRemainsCancellationAndReleasesTheLock(): void + { + $started = new Channel(1); + $blocked = new Channel(1); + $finished = new Channel(1); + $failure = new RuntimeException('The SDK translated an interrupted fetch.'); + $calls = 0; + $provider = new SerializedCredentialProvider(function () use (&$calls, $started, $blocked, $failure): PromiseInterface { + if (++$calls > 1) { + return Create::promiseFor(new Credentials('next', 'secret')); + } + + return new Promise(static function () use ($started, $blocked, $failure): never { + $started->push(true); + $blocked->pop(); + + throw $failure; + }); + }); + + $owner = go(static function () use ($provider, $finished): void { + try { + $provider(); + $finished->push(null); + } catch (CanceledException $exception) { + $finished->push($exception); + } + }); + + try { + $this->assertTrue($started->pop(1)); + $this->assertTrue(Coroutine::cancelById($owner)); + $result = $finished->pop(1); + $this->assertInstanceOf(CanceledException::class, $result); + $this->assertSame($failure, $result->getPrevious()); + $this->assertSame('next', $provider()->wait()->getAccessKeyId()); + } finally { + $blocked->close(); + } + } + + public function testCancelingALockWaiterDoesNotReleaseTheOwnersLock(): void + { + $entered = new Channel(2); + $release = new Channel(1); + $finished = new Channel(3); + $calls = 0; + $provider = new SerializedCredentialProvider(function () use (&$calls, $entered, $release): PromiseInterface { + ++$calls; + $entered->push(true); + $release->pop(); + + return Create::promiseFor(new Credentials('done', 'secret')); + }); + + go(static function () use ($provider, $finished): void { + $finished->push($provider()->wait()); + }); + + try { + $this->assertTrue($entered->pop(1)); + $waiter = go(static function () use ($provider, $finished): void { + try { + $provider(); + } catch (CanceledException $exception) { + $finished->push($exception); + } + }); + + $this->assertTrue(Coroutine::cancelById($waiter)); + $this->assertInstanceOf(CanceledException::class, $finished->pop(1)); + + go(static function () use ($provider, $finished): void { + $finished->push($provider()->wait()); + }); + + $this->assertSame(1, $calls); + $this->assertTrue($entered->isEmpty()); + $release->push(true); + $this->assertInstanceOf(Credentials::class, $finished->pop(1)); + $this->assertTrue($entered->pop(1)); + $release->push(true); + $this->assertInstanceOf(Credentials::class, $finished->pop(1)); + $this->assertSame(2, $calls); + } finally { + $release->close(); + } + } +} + +class SerializedCredentialProviderTestProvider +{ + public static ?self $shared = null; + + public int $maximumActive = 0; + + protected int $active = 0; + + /** + * Invoke the named provider's shared state. + */ + public static function resolveShared(string $key): PromiseInterface + { + return self::$shared->resolve($key); + } + + /** + * Resolve credentials after yielding to competing callers. + */ + public function resolve(string $key): PromiseInterface + { + ++$this->active; + $this->maximumActive = max($this->maximumActive, $this->active); + $promise = new Promise(function () use (&$promise, $key): void { + usleep(1000); + --$this->active; + $promise->resolve(new Credentials($key, 'secret')); + }); + + return $promise; + } + + /** + * Invoke the credential provider. + */ + public function __invoke(string $key): PromiseInterface + { + return $this->resolve($key); + } +} + +/** + * Invoke a named function provider with shared state. + */ +function serializedCredentialProviderTestFunction(string $key): PromiseInterface +{ + return SerializedCredentialProviderTestProvider::resolveShared($key); +} diff --git a/tests/Support/SupportComposerTest.php b/tests/Support/SupportComposerTest.php index 6510dde5b..5082f07eb 100644 --- a/tests/Support/SupportComposerTest.php +++ b/tests/Support/SupportComposerTest.php @@ -34,6 +34,23 @@ public function testSetAndGetLoader(): void Composer::setLoader($original); } + public function testFindsTheApplicationLoaderAfterAnAuxiliaryLoaderIsPrepended(): void + { + $original = Composer::getLoader(); + $auxiliary = new ClassLoader; + $auxiliary->setClassMapAuthoritative(true); + $auxiliary->register(true); + + try { + Composer::flushState(); + + $this->assertSame($original, Composer::getLoader()); + } finally { + $auxiliary->unregister(); + Composer::setLoader($original); + } + } + public function testDumpAutoloadRunsTheCorrectCommand(): void { $composer = $this->mockComposer(['composer', 'dump-autoload']); diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php index 2a1b5f376..e31dd525d 100644 --- a/tests/Telescope/Watchers/ClientRequestWatcherTest.php +++ b/tests/Telescope/Watchers/ClientRequestWatcherTest.php @@ -12,7 +12,6 @@ use GuzzleHttp\Psr7\Response; use GuzzleHttp\TransferStats; use Hypervel\Contracts\Telescope\TelescopeTag; -use Hypervel\Foundation\Testing\Concerns\InteractsWithAop; use Hypervel\Http\UploadedFile; use Hypervel\Support\Facades\DB; use Hypervel\Support\Json; @@ -34,8 +33,6 @@ enum ClientRequestWatcherTestIntTag: int ])] class ClientRequestWatcherTest extends FeatureTestCase { - use InteractsWithAop; - public function testClientRequestWatcherRegistersSuccessfulClientRequestAndResponse(): void { $client = $this->makeClient([ @@ -1215,53 +1212,18 @@ private function nestedValue(int $depth): array return $value; } + /** + * Send the request through the generated proxy, including failed connections. + */ private function executeTransfer( Client $client, RequestInterface $request, array $options = [], ): void { - if ($this->isAopProxied($client)) { - try { - $client->send($request, $options); - } catch (ConnectException) { - // Expected for failed connection tests. - } - - return; - } - - ['request' => $preparedRequest, 'options' => $preparedOptions] = $this->prepareTransferArguments( - $client, - $request, - $options - ); - try { - $this->callWithAspects($client, 'transfer', [ - 'request' => $preparedRequest, - 'options' => $preparedOptions, - ])->wait(); + $client->send($request, $options); } catch (ConnectException) { // Expected for failed connection tests. } } - - /** - * Mirror Guzzle's sendAsync() setup before manually invoking transfer(). - * - * @return array{request: RequestInterface, options: array} - */ - private function prepareTransferArguments(Client $client, RequestInterface $request, array $options): array - { - $preparedOptions = (fn (array $options): array => $this->prepareDefaults($options)) - ->call($client, $options); - - $preparedUri = (fn ($uri, array $options) => $this->buildUri($uri, $options)) - ->call($client, $request->getUri(), $preparedOptions); - - return [ - 'request' => $request->withUri($preparedUri, $request->hasHeader('Host')), - 'options' => $preparedOptions, - ]; - } } diff --git a/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php b/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php index 07e025a4a..a01b26afd 100644 --- a/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php +++ b/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php @@ -98,7 +98,7 @@ public function itCanPurgeTheSkeletonBackToACleanState(): void $sessionFile = $this->app->storagePath(join_paths('framework', 'sessions', 'session.txt')); $purgeFile = $this->app->basePath('purge-me.txt'); $purgeWildcardFile = $this->app->basePath('purge-test.log'); - $aopDirectory = $this->app->storagePath(join_paths('framework', 'aop')); + $aopDirectory = $this->app->bootstrapPath(join_paths('cache', 'aop')); $buildDirectory = $this->app->basePath(join_paths('public', 'build')); $vendorDirectory = $this->app->basePath(join_paths('public', 'vendor', 'package')); $purgeDirectory = $this->app->basePath('purge-dir'); @@ -280,7 +280,7 @@ private function purgeSkeletonArtifactPaths(): array $this->app->storagePath(join_paths('framework', 'sessions', 'session.txt')), $this->app->basePath('purge-me.txt'), $this->app->basePath('purge-test.log'), - $this->app->storagePath(join_paths('framework', 'aop')), + $this->app->bootstrapPath(join_paths('cache', 'aop')), $this->app->basePath(join_paths('public', 'build')), $this->app->basePath(join_paths('public', 'vendor', 'package')), $this->app->basePath('purge-dir'), diff --git a/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php b/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php index 422c5b86a..bf974ec4c 100644 --- a/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php @@ -4,9 +4,14 @@ namespace Hypervel\Tests\Testing\PHPUnit; +use GuzzleHttp\Client; +use GuzzleHttp\Promise\Promise; +use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Di\Aop\ProxyMethod; use Hypervel\Filesystem\Filesystem; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use ReflectionMethod; use Symfony\Component\Process\Process; class AfterEachTestExtensionTest extends TestCase @@ -73,6 +78,21 @@ public function testFinalUnpreparedTestIsCleanedWhenTheRunnerFinishes(): void $this->assertSame(['cleaned'], file($marker, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); } + public function testGuzzleProxiesArePreparedWithoutActivatingInstrumentation(): void + { + $this->assertFalse(AspectCollector::hasAspects()); + $this->assertCount(1, (new ReflectionMethod(Promise::class, '__construct'))->getAttributes(ProxyMethod::class)); + $this->assertCount(1, (new ReflectionMethod(Client::class, 'transfer'))->getAttributes(ProxyMethod::class)); + } + + public function testOutsideGuzzleCallbacksAreDiscardedBetweenTestsAndAtShutdown(): void + { + $process = $this->runFixture('GuzzleCleanupFixture.php', []); + + $this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput()); + $this->assertStringNotContainsString('abandoned-callback-ran', $process->getOutput()); + } + /** * Run a PHPUnit fixture in a separate process with the real extension. * diff --git a/tests/Testing/PHPUnit/Fixtures/GuzzleCleanupFixture.php b/tests/Testing/PHPUnit/Fixtures/GuzzleCleanupFixture.php new file mode 100644 index 000000000..984744be8 --- /dev/null +++ b/tests/Testing/PHPUnit/Fixtures/GuzzleCleanupFixture.php @@ -0,0 +1,36 @@ + 'abandoned-callback-ran']; + self::$reference = WeakReference::create($payload); + Utils::queue()->add(static function () use ($payload): void { + echo $payload->message; + }); + + $this->assertFalse(Utils::queue()->isEmpty()); + } + + public function testPreviousCallbacksHaveBeenReleasedBeforeTheNextTest(): void + { + $this->assertNull(self::$reference->get()); + $this->assertTrue(Utils::queue()->isEmpty()); + Utils::queue()->add(static function (): void { + echo 'abandoned-callback-ran'; + }); + } +} diff --git a/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php b/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php index 1f3ebf8df..a5963dfa0 100644 --- a/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php +++ b/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php @@ -4,7 +4,10 @@ namespace Hypervel\Tests\Testing\PHPUnit\Fixtures; +use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Tests\TestCase; +use Swoole\Coroutine; +use Swoole\Coroutine\Channel; class TimeLimitFixture extends TestCase { @@ -12,4 +15,38 @@ public function testNonYieldingWorkIsAborted(): void { while (true); } + + public function testSleepingWorkIsAborted(): void + { + Coroutine::sleep(30); + $this->fail('The sleeping test was not interrupted.'); + } + + public function testPendingChildrenAreAborted(): void + { + Coroutine::create(function (): void { + $this->assertFalse((new Channel)->pop()); + }); + } + + public function testChildrenCompleteNormally(): void + { + $root = Coroutine::getCid(); + Coroutine::create(function () use ($root): void { + $this->assertTrue(EngineCoroutine::join([$root])); + }); + + Coroutine::create(function (): void { + $child = Coroutine::create(static function (): void { + Coroutine::sleep(0.01); + }); + + $this->assertTrue(EngineCoroutine::join([$child])); + }); + } + + public function testWorkWithoutTimeLimitCompletes(): void + { + $this->assertTrue(Coroutine::sleep(0.01)); + } } diff --git a/tests/Testing/PHPUnit/TimeLimitTest.php b/tests/Testing/PHPUnit/TimeLimitTest.php index 24e658b85..5d4bd3c9b 100644 --- a/tests/Testing/PHPUnit/TimeLimitTest.php +++ b/tests/Testing/PHPUnit/TimeLimitTest.php @@ -5,35 +5,76 @@ namespace Hypervel\Tests\Testing\PHPUnit; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Process\Process; class TimeLimitTest extends TestCase { protected bool $runTestsInCoroutine = false; - public function testNonYieldingTestsFailWithTheirIdentity(): void + #[DataProvider('timedOutTests')] + public function testTimedOutTestsFailWithTheirIdentity(string $method): void { - // Leave enough outer-process headroom for PHPUnit's one-second limit to report under parallel load. + $process = $this->runFixture($method, 1); + $output = $process->getOutput() . $process->getErrorOutput(); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString('TimeLimitFixture::' . $method, $output); + $this->assertStringContainsString('This test was aborted after 1 second', $output); + $this->assertStringNotContainsString('deadlock', $output); + } + + /** + * Provide tests that must be interrupted by PHPUnit's deadline. + */ + public static function timedOutTests(): array + { + return [ + 'non-yielding' => ['testNonYieldingWorkIsAborted'], + 'sleeping root' => ['testSleepingWorkIsAborted'], + 'pending child after root exits' => ['testPendingChildrenAreAborted'], + ]; + } + + #[DataProvider('completedTests')] + public function testCompletedWorkDoesNotTimeOut(string $method, int $limit): void + { + $process = $this->runFixture($method, $limit); + + $this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput()); + } + + /** + * Provide normal completion and disabled deadline cases. + */ + public static function completedTests(): array + { + return [ + 'child completes after root exits' => ['testChildrenCompleteNormally', 1], + 'no time limit' => ['testWorkWithoutTimeLimitCompletes', 0], + ]; + } + + /** + * Run a fixture with its own PHPUnit time limit. + */ + protected function runFixture(string $method, int $limit): Process + { + // Leave headroom for the native timeout report, but do not wait for the fixture's 30-second sleep. $process = new Process( command: [ PHP_BINARY, 'vendor/bin/phpunit', '--no-progress', - '--default-time-limit=1', + '--default-time-limit=' . $limit, + '--filter=' . $method, 'tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php', ], cwd: dirname(__DIR__, 3), - timeout: 30, + timeout: 15, ); $process->run(); - $output = $process->getOutput() . $process->getErrorOutput(); - - $this->assertFalse($process->isSuccessful()); - $this->assertStringContainsString( - 'TimeLimitFixture::testNonYieldingWorkIsAborted', - $output, - ); - $this->assertStringContainsString('This test was aborted after 1 second', $output); + return $process; } }